Implementing game-specific actions
Actions extend the core.actions.AbstractAction.java
abstract class (or any of the generic actions available in the core.actions
package).
- Implement the
execute(AbstractGameState gameState)
method, which should modify the given game state according to the purpose of the action, and return true if the action was executed successfully, or false otherwise. - Implement the
copy()
method, copying all variables in your class. Do not hold any references to mutable objects in your actions, primitive types or immutable objects only! This is important for the correct objects to be updated by the AI when copying game states (use theAbstractGameState.getComponentById(int componentId)
method to retrieve components, passing theComponent.getComponentId()
method result as a parameter). - If your action requires a card to be played, implement the
getCard(AbstractGameState gameState)
method as well, which should return the card used for this action (usually discarded afterwards). - Override the
equals
,hashCode
andtoString
methods, including all variables in your class in the implementations and a short informative message in the toString method. It is vital that the standard Java equals/hashCode contract is followed for all Actions (see Action Contract).
If at all possible, make your Actions immutable. This make their implementation very straightforward. For example:
public class RollDice extends AbstractAction {
@Override
public boolean execute(AbstractGameState gs) {
CantStopGameState state = (CantStopGameState) gs;
state.rollDice();
state.setGamePhase(CantStopGamePhase.Allocation);
return true;
}
@Override
public AbstractAction copy() {
return this; // immutable
}
@Override
public boolean equals(Object obj) {
return obj instanceof RollDice;
}
@Override
public int hashCode() {
return 929134894;
}
@Override
public String getString(AbstractGameState gameState) {
return toString();
}
@Override
public String toString() {
return "Roll Dice";
}
}
Action Contract
When writing Actions a few points are important to bear in mind:
- Don’t store any object references (unless you are absolutely certain they are immutable)! These refer to a specific game state and when that is copied they will no longer point to the correct place. This is the most common mistake when writing your first game (see Forward Model tips).
- Ensure that the hashCode() and equals() methods are implemented properly. hashCode() is important as this is used as the Action key in much of the internal framework (especially in MCTS). If the hashcode of an Action changes, or you break the java equals() contract, then unexpected things will happen.
- Where possible, encapsulate all logic for a specific action in the Action class. This is helpful in avoiding code-bloat in ForwardModel. For complicated actions consider setting up an action sequence.
IExtendedSequence Action chains
The IExtendedSequence
interface supports linked extended actions, potentially across multiple players.
This is useful in two related use-cases:
-
Move Groups
This is where we have to make what is formally a single ‘action’, but one which makes sense to break up into a set of distinct decisions to avoid combinatorial explosions. An example in Dominion is where we have to discard 3 cards out of 6. This gives a total of 20 possible actions (6C3); but might be more tractable to consider as three distinct decisions - the first card to discard (out of 6), then the second (out of 5), and then the third (out of 4). This formally gives 120 options over the three decisions, but can be very helpful where there is clearly one best card to discard, which will focus MCTS rapidly on this as the first action. An example in Catan might be first deciding to what to build (Development Card, Settlement, or Road if you have cards for any of them), and then secondly deciding where to build it.
(The ‘Move Group’ comes originally (I think) from Saito et a. 2007 in Go, and the idea of first deciding whether to play a) in the corners, b) adjacent to the last piece played, c) one of the other possible board positions; and then secondly deciding which exact space to play on from the reduced options.)
-
Extended Actions
In Dominion we can have chains of decisions that cascade from each other. For example if I play a ‘Militia’, each other player decides first whether to defend themselves with a Reaction card (which may in turn enable a whole set of further decision), or else discards down to a hand of three cards (their decision as to which to discard). This circulates round the table before I continue with my turn.
This sort of thing is perfectly trackable within
ForwardModel
directly (as, for example, Exploding Kittens does with aNope
action that cancels the last action card played), but when one has 15 different cards (and many more to come in expansions) that have some chain of actions in this way, it makes better design sense to try and encapsulate all this logic and tracking in one place - otherwiseForwardModel
(orGameState
) becomes very bloated.
If an Action extends IExtendedSequence, then it can temporarily take control of who takes the next action, and what actions are available to them, and cleaning up after each action (all usually the responsibilities of the ForwardModel or GameState). Once the sequence of actions is complete, then it passes control back to ForwardModel.
The interface has five methods that need to be implemented, on top of those required for any AbstractAction
:
/**
* An Action (usually) that entails a sequence of linked actions/decisions. This takes temporary control of deciding
* which player is currently making a decision (the currentPlayer) from TurnOrder, and of what actions they have
* available from ForwardModel.
*
* ForwardModel will register all actions taken and the current state just before execution of each action in next().
*
* IExtendedSequence is then responsible for tracking all local state necessary for its set of actions, and marking
* itself as complete. (ForwardModel will then detect this, and remove it from the Stack of open actions.)
*
* Assuming this interface is used by an Action, then the execute(AbstractGameState state) method should call:
* state.setActionInProgress(this)
* The core framework will then trigger delegation from ForwardModel and TurnOrder.
*/
public interface IExtendedSequence {
/**
* Forward Model delegates to this from computeAvailableActions() if this Extended Sequence is currently active.
*
* @param state The current game state
* @return the list of possible actions for the currentPlayer
*/
List<AbstractAction> _computeAvailableActions(AbstractGameState state);
/**
* TurnOrder delegates to this from getCurrentPlayer() if this Extended Sequence is currently active.
*
* @param state The current game state
* @return The player Id whose move it is
*/
int getCurrentPlayer(AbstractGameState state);
/**
* This is called by ForwardModel whenever an action has just been taken. It enables the IExtendedSequence
* to maintain local state in whichever way is most suitable.
* It is called as well as (and before) the _afterAction method on the ForwardModel.
*
* After this call, the state of IExtendedSequence should be correct ahead of the next decision to be made.
* In some cases there is no need to implement anything in this method - if for example you can tell if all
* actions are complete from the state directly, then that can be implemented purely in executionComplete()
*
*
* @param state The current game state
* @param action The action just taken
*/
void _afterAction(AbstractGameState state, AbstractAction action);
/**
* Return true if this extended sequence has now completed and there is nothing left to do.
*
* @param state The current game state
* @return True if all decisions are now complete
*/
boolean executionComplete(AbstractGameState state);
/**
* Usual copy() standards apply.
* NO REFERENCES TO COMPONENTS TO BE KEPT, PRIMITIVE TYPES ONLY.
*
* @return a copy of the Object
*/
IExtendedSequence copy();
}
An example is shown below.
On execution, the implementing action should call AbstractGameState.setActionInProgress(this)
as well as any other immediate effects (in this example there are none). This example of an Artisan card requires a player to make two linked decisions - first which card to gain into their hand, and then which card from their hand to put onto their Deck.
public class Artisan extends DominionAction implements IExtendedSequence {
public Artisan(int playerId) {
super(CardType.ARTISAN, playerId);
}
public Artisan(int playerId, boolean dummy) {
super(CardType.ARTISAN, playerId, dummy);
}
public final int MAX_COST_OF_GAINED_CARD = 5;
public boolean gainedCard;
public boolean putCardOnDeck;
@Override
boolean _execute(DominionGameState state) {
state.setActionInProgress(this);
return true;
}
@Override
public List<AbstractAction> _computeAvailableActions(AbstractGameState gs) {
DominionGameState state = (DominionGameState) gs;
if (!gainedCard) {
return state.cardsToBuy().stream()
.filter(c -> c.cost <= MAX_COST_OF_GAINED_CARD)
.map(c -> new GainCard(c, player, DeckType.HAND))
.collect(toList());
} else {
return state.getDeck(DeckType.HAND, player).stream()
.map(c -> new MoveCard(c.cardType(), player, DeckType.HAND, player, DeckType.DRAW, false))
.distinct()
.collect(toList());
}
}
@Override
public int getCurrentPlayer(AbstractGameState state) {
return player;
}
@Override
public void _afterAction(AbstractGameState state, AbstractAction action) {
if (action instanceof GainCard && ((GainCard) action).buyingPlayer == player)
gainedCard = true;
if (action instanceof MoveCard && ((MoveCard) action).playerFrom == player)
putCardOnDeck = true;
}
@Override
public boolean executionComplete(AbstractGameState state) {
return gainedCard && putCardOnDeck;
}
@Override
public Artisan copy() {
Artisan retValue = new Artisan(player, dummyAction);
retValue.putCardOnDeck = putCardOnDeck;
retValue.gainedCard = gainedCard;
return retValue;
}
@Override
public boolean equals(Object obj) {
if (obj instanceof Artisan) {
Artisan other = (Artisan) obj;
return other.gainedCard == gainedCard && other.putCardOnDeck == putCardOnDeck && super.equals(obj);
}
return false;
}
@Override
public int hashCode() {
return Objects.hash(gainedCard, putCardOnDeck) + 31 * super.hashCode();
}
}
Behind the scenes
The implementation within the framework has the following components:
a) Stack in GameState to track the chain of interrupts and reactions (exactly like that used in ExplodingKittens):
Stack<IExtendedSequence> actionsInProgress = new Stack<>();
b) In ForwardModel
, if an IExtendedSequence is in progress, then we delegate to that to provide the set of available actions.
protected List<AbstractAction> _computeAvailableActions(AbstractGameState state) {
if (state.isActionInProgress()) {
return state.actionsInProgress.peek().followOnActions(state);
}
...
c) In ForwardModel
we update the stack to keep it informed of changes to the state whenever we apply the forward model.
And then call the _afterAction
method.
protected void _next(AbstractGameState currentState, AbstractAction action) {
_beforeAction(currentState, action);
if (action != null) {
action.execute(currentState);
} else {
throw new AssertionError("No action selected by current player");
}
// We then register the action with the top of the stack ... unless the top of the stack is this action
// in which case go to the next action
// We can't just register with all items in the Stack, as this may represent some complex dependency
// For example in Dominion where one can Throne Room a Throne Room, which then Thrones a Smithy
if (currentState.actionsInProgress.size() > 0) {
IExtendedSequence topOfStack = currentState.actionsInProgress.peek();
if (!topOfStack.equals(action)) {
topOfStack._afterAction(currentState, action);
} else {
if (currentState.actionsInProgress.size() > 1) {
IExtendedSequence nextOnStack = currentState.actionsInProgress.get(currentState.actionsInProgress.size() - 2);
nextOnStack._afterAction(currentState, action);
}
}
}
_afterAction(currentState, action);
}