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 the AbstractGameState.getComponentById(int componentId) method to retrieve components, passing the Component.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 and toString 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:

  1. 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.)

  2. 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 a Nope 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 - otherwise ForwardModel (or GameState) becomes very bloated.

An IExtendedSequence is best thought of as subordinate Forward Model that takes control of the game flow until a particular sequence of actions is over. By encapsulating the code in associated with this sequence in a single class we reduce code-bloat and make it easier to see what is going on. When triggered the IExtendedSequence temporarily takes control of who takes the next action, what actions are available to them, and for cleaning up after each action (all usually the responsibilities of the ForwardModel). Once the sequence of actions is complete, then it passes control back to ForwardModel.

One option is to have an initial Action that extends extends IExtendedSequence, and this is used in many games. However, this is now not the recommended approach as it tends to confuse responsibilities between different Objects. It is generally cleaner to have a clear separation between ‘Actions’ and ‘Game Flow Control’.

The IExtendedSequence interface has five methods that need to be implemented:

/**
 * This is a mini-ForwardModel that takes temporary control of:
 *      i) which player is currently making a decision (the getCurrentPlayer()),
 *      ii) what actions they have (computeAvailableActions()), and
 *      iii) what happens after an action is taken (_afterAction()).
 * These are the three normal responsibilities of ForwardModel.
 *
 * IExtendedSequence is also 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.)
 * This means that - unlike ForwardModel - IExtendedSequence is not stateless, and hence must implement a copy() method.
 * Effectively an IExtendedSequence also incorporates a mini-GameState that tracks game progress within the sequence.
 *
 * ForwardModel retains responsibility for applying all actions (via next()).
 * 
 * The GameState stores a Stack of IExtendedSequences, and the current one is always the one at the top of the stack.
 * This stack is deep-copied whenever the GameState is copied, so that the IExtendedSequences are also copied.
 *
 * To trigger an IExtendedSequence, it is added to the stack by calling:
 *      state.setActionInProgress(sequenceObject)
 * The core framework will then trigger delegation from ForwardModel.
 * 
 * There are two common patterns for IExtendedSequence:
 *     i) Extending an Action directly, so that this then controls the later decisions that are part of the action.
 *     ii) A distinct sub-phase of the game, encapsulating a linked series of decisions.
 * In general the current advice is not to extend an Action directly, but to use the second pattern.
 * For example:
 *      - Player chooses Action A that requires a number of other decisions to be made.
 *      - Action A does not extend IExtendedSequence, but created a new Object (let's call it SubPhaseA) that does.
 *      - in execute() of Action A, it adds SubPhaseA to the stack with state.setActionInProgress(SubPhaseA)
 *      - SubPhaseA then controls the next set of decisions. Once the last decision is taken, SubPhaseA marks itself as complete.
 *
 * It does not need to be the Action that puts the IExtendedSequence on the stack. It could be triggered by any event.
 * Another common pattern is for this to be done in the _afterAction() method of the ForwardModel once certain
 * preconditions for SubPhaseA are met.
 * 
 * After every action is taken, the ForwardModel will check the top of the stack to see if it is finished (and will 
 * continue until it finds one that is not). If it is finished, it will remove it from the stack. 
 */
 */
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.
     * This means that ForwardModel._afterAction() may need check to see if an action is in progress and skip
     * its own logic in this case:
     *          if (state.isActionInProgress()) continue;
     * This line of code has not yet been incorporated into the framework due to a couple of older games.
     *
     * 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. (This uses the old common pattern of extending an Action directly; it would in hindsight have been preferable to split this into an Artisan action that plays the Artisan card, and an ArtisanPhase class that implements IExtendedActionSequence, and is triggered by the Artisan action.)

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);
    }