Skip to content
AMMayberry1 edited this page Oct 3, 2024 · 17 revisions

Implementing Cards

To implement a card, follow these steps:

Important note: legacy L5R code

This code was ported from the Ringteki codebase powering the online L5R client, Jigoku. During the initial implementation phase, we have included the legacy code from L5R under the folder legacy_jigoku/. These are included for reference as there is still a lot of useful code that hasn't been fully ported yet, but be careful when making changes or searching for files that you do not accidentally start doing your work in the L5R folder.

Unit tests

We have a policy of creating at least one unit test with each new card. Please see the Unit Testing Guide and Test Cheat Sheet for details.

Debugging

If you are having issues with your card implementation, see the Debugging Guide.

Set up for implementation

Find relevant info

Choose a card

For set 1 - 3 implementation we are maintaining a Google Doc for tracking which cards are ready to be implemented and who is working on which card. Please check this list before choosing a card to work on. If you are just getting started, please choose a card marked "Easy" to smooth the onboarding process.

NOTE: Our first goal is to implement the set 1 starter decks. If possible, please prioritize cards from those decks. They are marked with the "Starter Deck" checkbox.

Note that some cards are marked as Trivial in the document. These cards either have no text or only have keywords, which do not require explicit implementation. We will auto-generate implementation files for all trivial cards so do not choose one of these to implement manually.

Find a similar implementation

If you're just getting started or working on a more complex card, it can be very useful to find another card that has similar behavior and use its implementation as a starting point.

Since we are still building up a catalog of SWU cards, if you are implementing a complex card with no existing reference you can also look through the catalog of L5R cards to see if you can find any that use similar key words / phrases. You can search EmeraldDB and find the matching card implementation under legacy_jigoku/server/game/cards. Note that the repo has changed slightly from the L5R version so some details will have changed, the dev team can help with that (or with finding relevant card impls).

Create a card file

Cards are organized under the /server/game/cards directory, grouped by set. Please make sure to match the PascalCase naming format of the other cards. All card implementation files must be in TypeScript (no vanilla JavaScript files). We recommend copy-pasting from another card implementation to get started.

Set class name and base class

Card class names should be PascalCase and match the file name exactly.

There is a specific base class that each card type should inherit from:

Card Type Base Class Name
Unit (non-leader, non-token) NonLeaderUnitCard
Event EventCard
Upgrade UpgradeCard
Base BaseCard
Leader LeaderUnitCard

Tokens require extra steps for implementation that will not be covered here.

Add ID data and implemented flag

Each card class should start with an override of getImplementationId() which returns the card's id and internalName. You can find these in the test/json/_cardMap.json file which is generated by the npm run get-cards command along with the card data files.

Copy-paste these values into the card impl file, and add a static class variable <className>.implemented = true to mark for the system that the card is implemented. The final result should look like below:

import AbilityHelper from '../../AbilityHelper';
import { NonLeaderUnitCard } from '../../core/card/NonLeaderUnitCard';

export default class GroguIrresistible extends NonLeaderUnitCard {
    protected override getImplementationId() {
        return {
            id: '6536128825',
            internalName: 'grogu#irresistible'
        };
    }

    // implementation here
}

GroguIrresistible.implemented = true;

Implement card abilities (quickstart)

The below is a quickstart guide on how to implement each ability type with some examples without going into too much detail on the components.

  • See section Advanced Usage for details on implementing more complex card abilities.
  • See section Ability Building Blocks for a reference on the how individual components of an ability definition work (immediateEffect, targetResolver, etc.).

Almost all card abilities (i.e., any card text with an effect) should be defined in the setupCardAbilities method:

class GroguIrresistible extends NonLeaderUnitCard {
    public override setupCardAbilities() {
        // Declare all ability types (action, triggered, constant, event, epic, replacement) here
        this.addActionAbility({
            title: 'Exhaust an enemy unit',
            cost: AbilityHelper.costs.exhaustSelf(),
            targetResolver: {
                controller: RelativePlayer.Opponent,
                immediateEffect: AbilityHelper.immediateEffects.exhaust()
            }
        });
    }
}

The only card type that uses a different pattern is leaders, which are discussed in more detail in Leader Abilities.

There are several ability types in SWU, each with its own initialization method. As shown above in the example of an action ability, each method accepts a property object defining the ability's behavior. Use the AbilityHelper import to get access to tools to help with implementations. Additionally, see Interfaces.ts for a list of available parameters for each ability type.

The ability types and methods are:

Ability Type Method Definition Example Cards
Constant ability addConstantAbility Abilities with no bold text that have an ongoing effect Entrenched, Sabine
Action ability addActionAbility Abilities with bold text and a cost that provide an action the player can take Grogu, Salacious Crumb
Triggered ability addTriggeredAbility Abilities with bold text that trigger off of a game event to provide some effect Avenger, Fleet Lieutenant
Event ability setEventAbility Any ability printed on an Event card Daring Raid, Vanquish
Epic action ability setEpicActionAbility The Epic Action ability on a Base or Leader card Tarkintown
Replacement ability addReplacementAbility Any ability using the term "would" or "instead" which modifies another effect Shield
Keyword ability N/A, handled automatically Abilities provided by keywords See keyword unit tests

Additionally, there are specific helper methods that extend the above to make common cases simpler, such as "onAttack" triggers or upgrades that cause the attached card to gain an ability or keyword. See the relevant section below for specific details.

Keywords

Most Keywords (sentinel, raid, smuggle, etc.) are automatically parsed from the card text, including for leaders. It isn't necessary to explicitly implement them unless they are provided by a conditional ability. Some examples of keywords requiring explicit implementation:

  • Baze Malbus: While you have initiative, this unit gains Sentinel.
  • Red Three: Each other [Heroic] unit gains Raid 1.
  • Protector: Attached unit gains Restore 2.

Constant abilities

Many cards provide continuous bonuses to other cards you control or detrimental effects to opponents cards in certain situations. These abilities are referred to in SWU as "constant abilities" and can be defined using the addConstantAbility method. Cards that enter play while the constant ability is in play will automatically have the ongoing effect applied, and cards that leave play will have the effect removed. If the card providing the effect becomes blank, the ongoing effect is automatically removed from all previously applied cards.

For a full list of properties that can be set when declaring an ongoing effect, look at OngoingEffect.js (NOTE: this is possibly stale). To see all the types of effect which you can use (and whether they apply to cards or players), look at EffectLibrary.js. Here are some common scenarios:

Matching conditions vs matching specific cards

The ongoing effect declaration (for card effects, not player effects) takes a matchTarget property. In most cases this will be a function that takes a Card object and should return true if the ongoing effect should be applied to that card.

// Each Rebel unit you control gains +1/+1
this.constantAbility({
    matchTarget: card => card.hasSomeTrait(Trait.Rebel),
    ongoingEffect: AbilityHelper.ongoingEffects.modifyStats({ power: 1, hp: 1 }),
});

In some cases, an ongoing effect should be applied to a specific card. While you could write a matchTarget function to match only that card, you can provide the Card or Player object as a shorthand.

// This player's leader unit gets Sentinel while it is deployed (i.e., in the arena)
this.constantAbility({
    matchTarget: this.controller.leader,
    targetLocationFilter: WildcardLocation.AnyArena,
    ongoingEffect: AbilityHelper.ongoingEffects.gainKeyword(KeywordName.Sentinel),
});

If not provided, matchTarget will default to targeting only the card that owns the constant ability.

Conditional ongoing effects

Some ongoing effects have a 'when', 'while' or 'if' clause within their text. These cards can be implemented by passing a condition function into the constant ability declaration. The ongoing effect will only be applied when the function returns true. If the function returns false later on, the ongoing effect will be automatically unapplied from the cards it matched.

// While this unit is exhausted, it gains +1/+1
this.constantAbility({
    condition: () => this.exhausted,
    ongoingEffect: AbilityHelper.ongoingEffects.modifyStats({ power: 1, hp: 1 })
});

Filtering by card type, owner, location

Note also that, similar to target resolvers described below, there are shorthand filters for the card properties location, owner, and card type. See section Target filtering below for more details.

All of these filters are available for filtering target cards (e.g., targetLocationFilter), but for checking the properties of the source card (the card that owns the ability) only sourceLocationFilter is available:

// While this card is in the ground arena, all of the opponent's units in the space arena get -1/-1
this.constantAbility({
    sourceLocationFilter: Location.GroundArena,
    targetLocationFilter: Location.SpaceArena,
    targetCardType: WildcardCardType.Unit,
    targetController: RelativePlayer.Opponent,
    ongoingEffect: AbilityHelper.ongoingEffects.modifyStats({ power: -1, hp: -1 })
});

IGNORE THIS SECTION, STILL WIP: Applying effects to cards which aren't in play

By default, ongoing effects will only be applied to cards in the play area. Certain cards effects refer to cards in your hand, such as reducing their cost. In these cases, set the targetLocation property to 'hand'.

// Each Direwolf card in your hand gains ambush (X). X is that card's printed cost.
this.constantAbility({
    // Explicitly target the effect to cards in hand.
    targetLocationFilter: 'hand',
    match: card => card.hasTrait('Direwolf'),
    effect: AbilityHelper.effects.modifyCost()
});

This also applies to provinces, holdings and strongholds, which the game considers to be 'in play' even though they aren't in the play area. Where an effect needs to be applied to these cards (or to characters who are in a province), set targetLocation to 'province'.

// This province gets +5 strength during [political] conflicts.
this.constantAbility({
    match: this,
    targetLocation: 'province',
    condition: () => this.game.isDuringConflict('political'),
    effect: AbilityHelper.effects.modifyProvinceStrength(5)
});

IGNORE THIS SECTION, STILL WIP: Player modifying effects

Certain cards provide bonuses or restrictions on the player itself instead of on any specific cards. These effects are marked as Player effects in /server/game/effects.js. For player effects, targetController indicates which players the effect should be applied to (with 'current' acting as the default). Player effects should not have a match property.

// While this character is participating in a conflict, opponents cannot play events.
this.constantAbility({
    condition: () => this.isParticipating(),
    targetController: 'opponent',
    effect: AbilityHelper.effects.playerCannot(context => context.source.type === 'event')
});

Action abilities

Action abilities are abilities from card text with the bold text "Action [one or more costs]:", followed by an effect. This provides an action the player may trigger during the action phase. They are declared using the addActionAbility method. See ActionAbility.ts for full documentation (NOTE: may be stale). Here are some common scenarios:

Declaring an action

When declaring an action, use the addActionAbility method and provide it with a title property. The title is what will be displayed in the menu players see when clicking on the card.

export default class GroguIrresistible extends NonLeaderUnitCard {
    public override setupCardAbilities() {
        this.addActionAbility({
            title: 'Exhaust an enemy unit',
            cost: AbilityHelper.costs.exhaustSelf(),
            targetResolver: {
                controller: RelativePlayer.Opponent,
                immediateEffect: AbilityHelper.immediateEffects.exhaust()
            }
        });
    }
}

Checking ability restrictions

To ensure that the action's play restrictions are met, pass a condition function that returns true when the restrictions are met, and false otherwise. If the condition returns false, the action will not be executed and costs will not be paid.

// Give this unit +2/+2, but the action is only available if the friendly leader is deployed
this.action({
    title: 'Give this unit +2/+2',
    condition: () => this.controller.leader.isDeployed(),
    // ...
});

Paying additional costs for action

Some actions have an additional cost, such as exhausting the card. In these cases, specify the cost parameter. The action will check if the cost can be paid. If it can't, the action will not execute. If it can, costs will be paid automatically and then the action will execute.

For a full list of costs, look at CostLibrary.ts.

One example is Salacious Crumb's action ability, which has two costs - exhaust the card and return it to hand:

public override setupCardAbilities() {
    this.addActionAbility({
        title: 'Deal 1 damage to a ground unit',
        cost: [
            AbilityHelper.costs.exhaustSelf(),
            AbilityHelper.costs.returnSelfToHandFromPlay()
        ],
        cannotTargetFirst: true,
        targetResolver: {
            locationFilter: Location.GroundArena,
            immediateEffect: AbilityHelper.immediateEffects.damage({ amount: 1 }),
        }
    });
}

Triggered abilities

Triggered abilities are abilities with bold text indicating a game event to be triggered off. Typical examples are "When played," "On attack," and "When defeated." Implementing a triggered ability is similar to action abilities above, except that we use this.addTriggeredAbility. Costs and targets (discussed below) are declared in the same way. For full documentation of properties, see TriggeredAbility.ts. Here are some common scenarios:

Defining the triggering condition

Each triggered ability has an associated triggering condition. This is done using the when property. This should be an object with one property which named for the name of the event - see EventName in Constants.ts for a current list of available events to trigger on. The value of the when property should be a function which takes the event and the context object. When the function returns true, the ability will be executed.

Here is an example with the deployed Cassian leader ability:

this.reaction({
    // When damage is dealt to an enemy base, draw a card
    when: {
    	onDamageDealt: (event, context) => event.target.isBase() && event.target.controller !== context.source.controller
    },
    immediateEffect: AbilityHelper.immediateEffects.drawCard(),
    limit: AbilityHelper.limit.perRound(1)
});

Triggering condition helpers

There are several ability triggers that are extremely common. For these, we provide helper methods which wrap the when clause so that it doesn't need to be typed out every time. For example, Mon Mothma's "when played" ability:

this.addWhenPlayedAbility({
    title: 'Search the top 5 cards of your deck for a Rebel card, then reveal and draw it.',
    immediateEffect: AbilityHelper.immediateEffects.deckSearch({
        searchCount: 5,
        cardCondition: (card) => card.hasSomeTrait(Trait.Rebel),
        selectedCardsImmediateEffect: AbilityHelper.immediateEffects.drawSpecificCard()
    })
});

The following triggers have helper methods:

Trigger Helper method
When played addWhenPlayedAbility
On attack addOnAttackAbility
When defeated addWhenDefeatedAbility

Optionally triggered abilities / "you may"

If the triggered ability uses the word "may," then the ability is considered optional and the player may choose to pass it when it is triggered. In these cases, the triggered ability must be flagged with the "optional" property. For example, Fleet Lieutenant's ability:

this.addWhenPlayedAbility({
    title: 'Attack with a unit',
    optional: true,
    initiateAttack: {
        effects: AbilityHelper.ongoingEffects.conditionalAttackStatBonus(
            (attacker: UnitCard) => attacker.hasSomeTrait(Trait.Rebel),
            { power: 2, hp: 0 }
        )
    }
});

Multiple triggers

In some cases there may be multiple triggering conditions for the same ability, such as Avenger's ability being triggered on play and on attack. In these cases, just define an additional event on the when object. For example, see the ability on The Ghost:

this.addTriggeredAbility({
    title: 'Give a shield to another Spectre unit',
    when: {
        onCardPlayed: (event, context) => event.card === context.source,
        onAttackDeclared: (event, context) => event.attack.attacker === context.source
    },
    targetResolver: {
        cardCondition: (card, context) => card.hasSomeTrait(Trait.Spectre) && 
        immediateEffect: AbilityHelper.immediateEffects.giveShield()
    }
});

IGNORE THIS SECTION, STILL WIP: Abilities outside of play

Certain abilities, such as that of Vengeful Oathkeeper can only be activated in non-play locations. Such reactions should be defined by specifying the location property with the location from which the ability may be activated. The player can then activate the ability when prompted.

this.reaction({
	when: {
		afterConflict: (event, context) => context.conflict.loser === context.player && context.conflict.conflictType === 'military'
	},
    location: 'hand',
    gameAction: AbilityHelper.actions.putIntoPlay()
})

Helper methods for upgrade cards

Some helper methods are available to make it easier to declare constant abilities on upgrades, since these are extremely common.

Static stat bonuses from upgrades

Static upgrade stat bonuses from the printed upgrade values are automatically included in combat calculations for the attached unit.

Effects targeting attached card

Since most upgrade abilities target the attached card, we have helper methods available to declare such abilities succintly.

Most upgrades say that the attached unit gains a triggered ability:

// Attached character gains ability 'On Attack: Exhaust the defender'
this.addGainTriggeredAbilityTargetingAttached({
    title: 'Exhaust the defender on attack',
    // note here that context.source refers to the attached unit card, not the upgrade itself
    when: { onAttackDeclared: (event, context) => event.attack.attacker === context.source },
    targetResolver: {
        cardCondition: (card, context) => card === context.event.attack.target,
        immediateEffect: AbilityHelper.immediateEffects.exhaust()
    }
});

It is also common for an upgrade to grant a keyword to the attached:

// Attached character gains keyword 'Restore 2'
this.addGainKeywordTargetingAttached({
    keyword: KeywordName.Restore,
    amount: 2
});

If an attachment effect has a condition, it can be set using the optional second parameter of the setup method. See the implementation of the Fallen Lightsaber text, "If attached unit is a Force unit, it gains: “On Attack: Deal 1 damage to each ground unit the defending player controls.”

this.addGainTriggeredAbilityTargetingAttached({
    title: 'Deal 1 damage to each ground unit the defending player controls',
    when: { onAttackDeclared: (event, context) => event.attack.attacker === context.source },
    immediateEffect: AbilityHelper.immediateEffects.damage((context) => {
        return { target: context.source.controller.opponent.getUnitsInPlay(Location.GroundArena), amount: 1 };
    })
},
(context) => context.source.parentCard?.hasSomeTrait(Trait.Force));

In some rare cases an upgrade's ability targets the attached card without giving it any new abilities

// Entrenched ability
this.addConstantAbilityTargetingAttached({
    title: 'Attached unit cannot attack bases',
    ongoingEffect: AbilityHelper.ongoingEffects.cannotAttackBase(),
});

Event abilities

All ability text printed on an event card is considered the "event ability" for that card. Event abilities are defined exactly the same way as action abilities, except that there can only be one ability defined and it uses the setEventAbility method. E.g. Daring Raid:

this.setEventAbility({
    title: 'Deal 2 damage to a unit or base',
    targetResolver: {
        immediateEffect: AbilityHelper.immediateEffects.damage({ amount: 2 })
    }
});

Epic action abilities

Epic action abilities are printed on leader and base cards, and can only be activated once per game. Like event cards, they are defined the same way as action abilities except that only one can be set and it is set using the setEpicActionAbility method. See Tarkintown:

this.setEpicActionAbility({
    title: 'Deal 3 damage to a damaged non-leader unit',
    targetResolver: {
        cardTypeFilter: CardType.NonLeaderUnit,
        cardCondition: (card) => (card as UnitCard).damage !== 0,
        immediateEffect: AbilityHelper.immediateEffects.damage({ amount: 3 })
    }
});

Replacement effects

Some abilities allow the player to cancel or modify an effect. These abilities are always defined with the word "instead" or "would." Some examples:

  • Shield, which cancels the normal resolution of damage and replaces it with another effect (defeating the shield token)
  • Boba Fett's armor, which modifies the normal resolution of an instance of damage and reduces its value by 2

These abilities are called "replacement effects" in the SWU rules and are defined using the addReplacementEffectAbility method. Otherwise the ability is defined very similar to a triggered ability, except that it has a replaceWith property object which defines an optional replacement effect in the replacementImmediateEffect sub-property. If replacementImmediateEffect is null, the triggering effect is canceled with no replacement. An optional target sub-property is also availabe to define a target for the replacement effect.

Here is the Shield implementation as an example:

this.addReplacementEffectAbility({
    title: 'Defeat shield to prevent attached unit from taking damage',
    when: {
        onDamageDealt: (event, context) => event.card === (context.source as UpgradeCard).parentCard
    },
    replaceWith: {
        target: this,
        replacementImmediateEffect: AbilityHelper.immediateEffects.defeat()
    },
    effect: 'shield prevents {1} from taking damage',
    effectArgs: (context) => [(context.source as UpgradeCard).parentCard],
});

Leader abilities

Leader cards need to be implemented slightly differently than other card types:

// IMPORTANT: must extend LeaderUnitCard, not LeaderCard
export default class GrandMoffTarkinOversectorGovernor extends LeaderUnitCard {

    // setup for "Leader" side abilities
    protected override setupLeaderSideAbilities() {
        this.addActionAbility({
            title: 'Give an experience token to an Imperial unit',
            cost: [AbilityHelper.costs.abilityResourceCost(1), AbilityHelper.costs.exhaustSelf()],
            targetResolver: {
                controller: RelativePlayer.Self,
                cardCondition: (card) => card.hasSomeTrait(Trait.Imperial),
                immediateEffect: AbilityHelper.immediateEffects.giveExperience()
            }
        });
    }

    // setup for "Leader Unit"" side abilities
    protected override setupLeaderUnitSideAbilities() {
        this.addOnAttackAbility({
            title: 'Give an experience token to another Imperial unit',
            optional: true,
            targetResolver: {
                controller: RelativePlayer.Self,
                cardCondition: (card, context) => card.hasSomeTrait(Trait.Imperial) && card !== context.source,
                immediateEffect: AbilityHelper.immediateEffects.giveExperience()
            }
        });
    }
}

There are two important things to remember when implementing leaders:

  1. The class must extend LeaderUnitCard, not LeaderCard. Using the latter will cause the card to not work correctly.
  2. Instead of the typical setupCardAbilities method, there are two methods - one for each side of the leader card: setupLeaderSideAbilities and setupLeaderUnitSideAbilities. Both of these must be implemented for the card to function correctly.

IT'S A TRAP: Reusing leader ability properties

There are a lot of cases where both sides of the leader card have the exact same ability. To reduce duplicated code, you can use a pattern like this:

export default class DirectorKrennicAspiringToAuthority extends LeaderUnitCard {

    // IMPORTANT: use a method to generate the properties, do not create a variable
    private buildKrennicAbilityProperties() {
        return {
            title: 'Give each friendly damaged unit +1/+0',
            matchTarget: (card) => card.isUnit() && card.damage !== 0,
            ongoingEffect: AbilityHelper.ongoingEffects.modifyStats({ power: 1, hp: 0 })
        };
    }

    protected override setupLeaderSideAbilities() {
        this.addConstantAbility(this.buildKrennicAbilityProperties());
    }

    protected override setupLeaderUnitSideAbilities() {
        this.addConstantAbility(this.buildKrennicAbilityProperties());
    }
}

It is important to have a method like buildKrennicAbilityProperties above instead of doing something like this:

export default class DirectorKrennicAspiringToAuthority extends LeaderUnitCard {

    // this will cause test problems
    private readonly krennicAbilityProperties = {
        title: 'Give each friendly damaged unit +1/+0',
        matchTarget: (card) => card.isUnit() && card.damage !== 0,
        ongoingEffect: AbilityHelper.ongoingEffects.modifyStats({ power: 1, hp: 0 })
    };

    protected override setupLeaderSideAbilities() {
        this.addConstantAbility(this.buildKrennicAbilityProperties());
    }

    protected override setupLeaderUnitSideAbilities() {
        this.addConstantAbility(this.buildKrennicAbilityProperties());
    }
}

The above will not work correctly because the shared properties object krennicAbilityProperties will be modified during setup, causing it to behave incorrectly in some cases.

Advanced usage

This section describes features for handling more complex card card behaviors.

The word "if"

Many cards will have an effect conditioned with "if", e.g. "if [X], do [Y]." How exactly to implement this condition depends on whether it is being used in a triggered or action ability vs constant abilities.

If conditions in triggered / action abilities

If an effect in a triggered or action ability has the word "if", except in the special case of "if you do," then use AbilityHelper.immediateEffects.conditional(). See Jedha Agitator:

this.addOnAttackAbility({
    title: 'If you control a leader unit, deal 2 damage to a ground unit or base',
    targetResolver: {
        cardCondition: (card) => (card.isUnit() && card.location === Location.GroundArena) || card.isBase(),
        immediateEffect: AbilityHelper.immediateEffects.conditional({
            condition: (context) => context.source.controller.leader.deployed,
            onTrue: AbilityHelper.immediateEffects.damage({ amount: 2 }),
            onFalse: AbilityHelper.immediateEffects.noAction()
        })
    }
});

Special case: "if you do"

Abilities that say "if you do," usually in the form "you may [X]. If you do, then [Y]", are not yet supported.

If conditions in constant abilities

If there is an "if" condition in a constant ability, it is handled using the condition property. For example, see the Sabine unit's constant ability:

this.addConstantAbility({
    title: 'Cannot be attacked if friendly units have at least 3 unique aspects',
    condition: (context) => countUniqueAspects(this.controller.getOtherUnitsInPlay(context.source)) >= 3,
    ongoingEffect: AbilityHelper.ongoingEffects.cardCannot(AbilityRestriction.BeAttacked)
});

Abilities with multiple effects

If a card ability has multiple discrete effects, use one of the following "meta-effects" which allow chaining other effects together.

Effects resolving in the same window

In most cases, when an ability says to do multiple things they are being resolved simultaneously in the same window. These are typically worded in one of the following forms:

  • "Do [X] and do [Y]." Covert Strength: "Heal 2 damage from a unit and give an Experience token to it."
  • "Do [X]. Do [Y]." Asteroid Sanctuary: "Exhaust an enemy unit. Give a Shield token to a friendly unit that costs 3 or less."

These are examples of effects that resolve simultaneously. In these cases, use AbilityHelper.immediateEffects.simultaneous() with a list of effects to resolve. For example, The Force is With Me:

this.setEventAbility({
    title: 'Give 2 Experience, a Shield if there is a Force unit, and optionally attack',
    targetResolver: {
        controller: RelativePlayer.Self,
        immediateEffect: AbilityHelper.immediateEffects.simultaneous([
            AbilityHelper.immediateEffects.giveExperience({ amount: 2 }),
            AbilityHelper.immediateEffects.conditional({
                condition: (context) => context.source.controller.isTraitInPlay(Trait.Force),
                onTrue: AbilityHelper.immediateEffects.giveShield({ amount: 1 }),
                onFalse: AbilityHelper.immediateEffects.noAction()
            }),
            AbilityHelper.immediateEffects.attack({ optional: true })
        ])
    }
});

Effects resolved sequentially

In some specific cases, the ability will indicate that an effect(s) should be fully resolved before the next effect(s) take place. This is usually indicated in one of two ways:

  • The word "then." Leia leader: "Attack with a Rebel unit. Then, you may attack with another Rebel unit."
  • "Do [X] [N] times." Headhunting: "Attack with up to 3 units (one at a time). ..."

In these situations, there are two equivalent options. First, you can use the then property to chain abilities together. See the Leia leader:

this.addActionAbility({
    title: 'Attack with a Rebel unit',
    cost: AbilityHelper.costs.exhaustSelf(),
    initiateAttack: {
        attackerCondition: (card) => card.hasSomeTrait(Trait.Rebel)
    },
    then: {
        title: 'Attack with a second Rebel unit',
        optional: true,
        initiateAttack: {
            attackerCondition: (card) => card.hasSomeTrait(Trait.Rebel)
        }
    }
});

An alternative that is useful for longer chains is using the sequential system. See Headhunting:

public override setupCardAbilities() {
    this.setEventAbility({
        title: 'Attack with up to 3 units',
        immediateEffect: AbilityHelper.immediateEffects.sequential([
            this.buildBountyHunterAttackEffect(),
            this.buildBountyHunterAttackEffect(),
            this.buildBountyHunterAttackEffect()
        ])
    });
}

// create the effect that selects the target for attack. See section below for details.
private buildBountyHunterAttackEffect() {
    return AbilityHelper.immediateEffects.selectCard({
        innerSystem: AbilityHelper.immediateEffects.attack({
            targetCondition: (card) => !card.isBase(),
            attackerLastingEffects: {
                effect: AbilityHelper.ongoingEffects.modifyStats({ power: 2, hp: 0 }),
                condition: (attack: Attack) => attack.attacker.hasSomeTrait(Trait.BountyHunter)
            },
            optional: true
        })
    });
}

Resolving targets inside an effect chain

One current drawback of simultaneous() and sequential() is that you cannot use a standard target resolver inside of the chain. For situations where you need to resolve a target in an effect sequence (such as the Headhunting example above), use the helper tool AbilityHelper.immediateEffects.selectCard().

As shown above, selectCard() has an innerSystem property which declares the system that is being targeted for. It also supports all of the same filtering and condition options as targetResolver.

Ability building blocks

This section describes some of the major components that are used in the definitions of abilities:

  • Context objects
  • Game systems
  • Target resolvers

Context object

When the game starts to resolve an ability, it creates a context object for that ability. Generally, the context ability has the following structure:

class AbilityContext {
    constructor(properties) {
        this.game = properties.game;
        this.source = properties.source || new OngoingEffectSource(this.game);
        this.player = properties.player;
        this.ability = properties.ability || null;
        this.costs = properties.costs || {};
        this.costAspects = properties.costAspects || [];
        this.targets = properties.targets || {};
        this.selects = properties.selects || {};
        this.stage = properties.stage || Stage.Effect;
        this.targetAbility = properties.targetAbility;
        this.playType = this.player && this.player.findPlayType(this.source);
    }
}

context.source is the card with the ability being used, and context.player is the player who is using the ability (almost always the controller of the context.source). When implementing actions and other triggered abilities, context should almost always be used (instead of this) to reference cards or players. The only exception is that this.game can be used as an alternative to context.game.

context.source and upgrades

Note that in the case of upgrade abilities that give an ability to the attached card, context.source has to be used slightly differently than normal:

// Attached character gains ability 'On Attack: Exhaust the defender'
this.addGainTriggeredAbilityTargetingAttached({
    title: 'Exhaust the defender on attack',
    // note here that context.source refers to the attached unit card, not the upgrade itself
    when: { onAttackDeclared: (event, context) => event.attack.attacker === context.source },
    targetResolver: {
        cardCondition: (card, context) => card === context.event.attack.target,
        immediateEffect: AbilityHelper.immediateEffects.exhaust()
    }
});

Whereas in most cases context.source refers to this (i.e., the source card of the ability), since in this case the ability is being triggered on the attached unit card, context.source refers to the unit that the upgrade is attached to. The above when condition is equivalent to:

when: { onAttackDeclared: (event, context) => event.attack.attacker === this.parentCard }

Target resolvers

Most ability types (other than constant, keyword, and replacement abilities) can specify to 'choose' or otherwise target a specific card. This should be implemented using a "target resolver," which defines a set of criteria that will be used to select the set of target cards to allow the player to choose between. Target resolvers are provided using targetResolver or targetResolvers property.

The targetResolver property should include any limitations set by the ability, using the cardTypeFilter, locationFilter, controller and/or cardCondition property. A game system can also be included by using the immediateEffect property, which will restrict the card chosen to those for which that game system is legal (e.g. only units in an arena and base can be damaged, only upgrades can be unattached, etc.).

For example, see the Sabine Wren (unit) "on attack" ability:

// cardCondition returns true only for cards that are a base or the target of Sabine's attack
this.addOnAttackAbility({
    title: 'Deal 1 damage to the defender or a base',
    targetResolver: {
        cardCondition: (card, context) => card.isBase() || card === context.event.attack.target,
        immediateEffect: AbilityHelper.immediateEffects.damage({ amount: 1 })
    }
});

See additional details in the GameSystems section below. If an array of game systems is specified in immediateEffect, then the target only needs to meet the requirements of one of them.

Target filtering

As mentioned above, targets can be filtered using one of multiple properties. The cardCondition property is the most flexible but the most cumbersome to write and to read, as it requires passing a handler function. Since most ability targets are restricted by a simple category such as "non-leader unit" or "friendly ground unit", properties are available for filtering on these attributes (see example below).

'Wildcard' enum types: for location and card type, we have a concept of "wildcard" enum types which represent more than one concrete value. For example, Location.SpaceArena and Location.GroundArena are concrete locations, but WildcardLocation.AnyArena is a value that represents both (or either) for matching and filtering purposes. Similarly for card types, we have values such as WildcardCardType.Unit which represents leader and non-leader units as well as token units. For a detailed list, see Constants.ts.

// Death Trooper
this.addWhenPlayedAbility({
    title: 'Deal 2 damage to a friendly ground unit and an enemy ground unit',
    targetResolvers: {
        myGroundUnit: {
            cardTypeFilter: WildcardCardType.Unit,
            controller: RelativePlayer.Self,
            locationFilter: Location.GroundArena,
            immediateEffect: AbilityHelper.immediateEffects.damage({ amount: 2 })
        },
        theirGroundUnit: {
            cardTypeFilter: WildcardCardType.Unit,
            controller: RelativePlayer.Opponent,
            locationFilter: Location.GroundArena,
            immediateEffect: AbilityHelper.immediateEffects.damage({ amount: 2 })
        }
    },
    effect: 'deal 2 damage to {1} and {2}',
    effectArgs: (context) => [context.targets.myGroundUnit, context.targets.theirGroundUnit]
});

Multiple targets

Some card abilities require multiple targets. These may be specified using the targetResolvers property. Each sub key under targetResolvers is the name that will be given to the chosen card, and the value is the prompt properties. See the Death Trooper example above for reference.

Once all targets are chosen, they will be set using their specified name under the targetResolvers property on the handler context object.

IGNORE FOR NOW, WIP: Select options

Some abilities require the player (or their opponent) to choose between multiple options. This is done in the same way as targets above, but by using the mode property set to 'select'. In addition, a choices object should be included, which contains key:value pairs where the key is the option to display to the player, and the value is either a function which takes the context object and returns a boolean indicating whether this option is legal, or a game action which will be evaluated on the basis of the specified target (or default as detailed below) to determine whether the choice is legal. The selected option is stored in context.select.choice (or context.selects[targetName].choice for an ability with multiple targets).

// Action: During a conflict at this province, select one – switch the contested ring with an unclaimed 
// ring, or switch the conflict type.
this.action({
    title: 'Switch the conflict type or ring',
    condition: context => context.source.isConflictProvince(),
    target: {
        player: 'self',
        mode: 'select',
        choices: {
            'Switch the contested ring': () => _.any(this.game.rings, ring => ring.isUnclaimed()),
            'Switch the conflict type': () => true
        }
    },
    // ...
});
// Action: If an opponent has declared 2 or more conflicts against you this phase, select one – 
// take 1 fate or 1 honor from that opponent.
this.action({
    title: 'Take 1 fate or 1 honor',
    phase: 'conflict',
    condition: context => this.game.getConflicts(context.player.opponent).filter(conflict => !conflict.passed).length > 1,
    target: {
        player: 'self',
        mode: 'select',
        choices: {
            'Take 1 fate': AbilityHelper.actions.takeFate(),
            'Take 1 honor': AbilityHelper.actions.takeHonor()
        }
    }
});

Remembering past game events with state watchers

Some cards refer back to events that have happened previously in this phase or round, such as Medal Ceremony or the Cassian leader. To add this kind of game memory to a card, add a state watcher. Here is an example with Medal Ceremony:

export default class MedalCeremony extends EventCard {
    // this watcher records every instance of an attack that happened in the past phase
    private attacksThisPhaseWatcher: AttacksThisPhaseWatcher;

    protected override setupStateWatchers(registrar: StateWatcherRegistrar) {
        this.attacksThisPhaseWatcher = AbilityHelper.stateWatchers.attacksThisPhase(registrar, this);
    }

    public override setupCardAbilities() {
        this.setEventAbility({
            title: 'Give an experience to each of up to three Rebel units that attacked this phase',
            targetResolver: {
                mode: TargetMode.UpTo,
                numCards: 3,
                optional: true,
                immediateEffect: AbilityHelper.immediateEffects.giveExperience(),

                // this condition gets the list of Rebel attackers this phase from the watcher and checks if the specified card is in it
                cardCondition: (card, context) => {
                    const rebelUnitsAttackedThisPhase = this.attacksThisPhaseWatcher.getCurrentValue()
                        .filter((attack) => attack.attacker.hasSomeTrait(Trait.Rebel))
                        .map((attack) => attack.attacker as Card);

                    return rebelUnitsAttackedThisPhase.includes(card);
                }
            }
        });
    }
}

A "state watcher" is a set of event triggers which are used to log events that occur during the game. For example, the AttacksThisPhaseWatcher used above is called on every onAttackDeclared event and adds the event to the list of attacks this phase. The getCurrentValue() method on a watcher will return the state object for that watcher, which varies by watcher type.

For a list of available state watchers, see StateWatcherLibrary.

IT'S A TRAP: reading properties from state watcher results

When using a state watcher, it's important to remember that card properties will have changed since the relevant watched event(s) took place and the current properties of a card may be different than what they were when the event happened.

As an example, consider the Vanguard Ace ability, which creates one experience token for each card played by the controller this phase. It uses a CardsPlayedThisPhaseWatcher, which returns the list of all cards played this phase by either player. Each entry gives the played card and the player who played it:

public override setupCardAbilities() {
    this.addWhenPlayedAbility({
        title: 'Give one experience for each card you played this turn',
        immediateEffect: AbilityHelper.immediateEffects.giveExperience((context) => {
            const cardsPlayedThisPhase = this.cardsPlayedThisWatcher.getCurrentValue();

            const experienceCount = cardsPlayedThisPhase.filter((playedCardEntry) =>
                // playedCardEntry.card.controller === context.source.controller    <-- THIS IS THE WRONG WAY TO CHECK IF WE PLAYED THE CARD
                playedCardEntry.playedBy === context.source.controller &&
                playedCardEntry.card !== context.source
            ).length;

            return { amount: experienceCount };
        })
    });
}

Since Vanguard Ace only counts cards that were played by its controller, we need to filter the results of the CardsPlayedThisPhaseWatcher to only cards that we (the controller) played. However, we can't do this by just checking the controller property of each card that was played, because it is possible that control of the card has changed since the card was played (e.g. with Traitorous). If we just did card.controller === context.source.controller, then a card that we played which was stolen with Traitorous would not be counted by the Vanguard Ace ability.

Therefore, it is imporant that the code checks the provided playedBy property from the watcher, which recorded the acting player at the time the card was played. Otherwise, the card's behavior will be incorrect in some cases.

Using GameSystems for building ability effects

In general, the effects of an ability should be implemented using game systems represented by the GameSystem class, which is turn wrapped by helper methods under the AbilityHelper import.

Game Systems

All ability types rely on GameSystems for making changes to game state. Available game systems can be found in GameSystemLibrary.ts, along with any parameters and their defaults. The cost and immediateEffect fields of AbilityHelper provide access to the GameSystem classes for use in changing the game state as either the cost or the immediate effect of an ability, respectively. For example, the Grogu action ability uses the exhaust both as a cost (via AbilityHelper.costs.exhaustSelf()) and as an effect (via AbilityHelper.immediateEffects.exhaust()).

this.addActionAbility({
    title: 'Exhaust an enemy unit',
    cost: AbilityHelper.costs.exhaustSelf(),
    targetResolver: {
        controller: RelativePlayer.Opponent,
        immediateEffect: AbilityHelper.immediateEffects.exhaust()
    }
});

Game systems as an immediate effect default to targeting the card generating the ability (for cards) or the opponent (for players).

Game systems included in targetResolver (or in one of targetResolvers) will default to the target chosen by the targetResolver's resolution. You can change the target of a game system or the parameters by passing either an object with the properties you want, or a function which takes context and returns those properties.

this.addActionAbility({
    title: 'Defeat this upgrade to give the attached unit a shield',
    cost: AbilityHelper.costs.defeatSelf(),
    // we don't need a target resolver, can just provide the target directly here
    immediateEffect: AbilityHelper.immediateEffects.giveShield(context => ({ target: context.source.parentCard }))
});

Limiting the number of uses

Some actions have text limiting the number of times they may be used in a given period. You can pass an optional limit property using one of the duration-specific ability limiters. See /server/game/abilitylimit.js for more details.

this.addActionAbility({
    title: 'Damage an opponent\'s base',
    limit: AbilityHelper.limit.perPhase(1),
    // ...
});

Effect messages EXAMPLES NEED UPDATING

Once costs have been paid and targets chosen (but before the ability resolves), the game automatically displays a message in the chat box which tells both players the ability, costs and targets of the effect. Game actions will automatically generate their own effect message, although this will only work for a single game action. If the effects of the ability involve two or more game actions, or the effect is a lasting effect or uses a handler, then an effect property is required. The effect property will be passed the target (card(s) or ring) of the effect (or the source if there are no targets) as its first parameter (and so can be referenced using '{0}' in the effect property string). If other references are required, this can be done using curly bracket references in the effect string('{1}', '{2', etc) and supplying an effectArgs property (which generally will be a function taking the context object):

this.action({
    // Action: Return this attachment to your hand and dishonor attached character.
    title: 'Return court mask to hand',
    effect: 'return {0} to hand, dishonoring {1}',
    effectArgs: context => context.source.parent,
    gameAction: [AbilityHelper.actions.returnToHand(), AbilityHelper.actions.dishonor(context => ({ target: context.source.parent }))]
});
this.action({
    // Action: While this character is participating in a conflict, choose another participating character – until the end of the conflict, that character gets +2/+2 for each holding you control.
    title: 'Give a character a bonus for each holding',
    condition: context => context.source.isParticipating(),
    target: {
        cardType: 'character',
        cardCondition: (card, context) => card.isParticipating() && card !== context.source,
        gameAction: AbilityHelper.actions.cardLastingEffect(context => ({
            effect: AbilityHelper.effects.modifyBothSkills(2 * context.player.getNumberOfHoldingsInPlay())
        }))
    },
    effect: 'give {0} +{1}{2}/+{1}{3}',
    effectArgs: context => [2 * context.player.getNumberOfHoldingsInPlay(), 'military', 'political']
});

Lasting effects ("for this phase", "for this attack")

In some cases, a triggered or constant ability will create an ongoing effect with a specific time duration. This is called a "lasting" effect. The two most common examples in SWU are:

  • For this phase: e.g., Disarm: Give an enemy unit -4/-0 for this phase.
  • For this attack: e.g., Surprise Strike: Attack with a unit. It gets +3/+0 for this attack.

Lasting effects use the same properties as constant abilities, above. How they are created depends on which type you are using (phase-lasting effects or attack-lasting effects).

"For this phase" effects

Effects that last for the remainder of the phase are created using AbilityHelper.immediateEffects.forThisPhaseCardEffect(). Here is an example with Disarm:

public override setupCardAbilities() {
    this.setEventAbility({
        title: 'Give an enemy unit -4/-0 for the phase',
        targetResolver: {
            cardTypeFilter: WildcardCardType.Unit,
            controller: RelativePlayer.Opponent,
            immediateEffect: AbilityHelper.immediateEffects.forThisPhaseCardEffect({
                effect: AbilityHelper.ongoingEffects.modifyStats({ power: -4, hp: 0 })
            })
        }
    });
}

"For this attack" effects

Any lasting effects applied to the attacker or the defender for the duration of the attack can be added via the attackerLastingEffects and defenderLastingEffects properties of an attack. There is also a condition property which can be used to control whether the effect is applied. See Fleet Lieutenant for an example:

// When Played: You may attack with a unit. If it's a Rebel unit, it gets +2/0 for this attack.
this.addWhenPlayedAbility({
    title: 'Attack with a unit',
    optional: true,
    initiateAttack: {
        attackerLastingEffects: {
            effect: AbilityHelper.ongoingEffects.modifyStats({ power: 2, hp: 0 }),
            condition: (attack: Attack) => attack.attacker.hasSomeTrait(Trait.Rebel)
        }
    }
});

IGNORE THIS, STILL WIP: Actions outside of play

Certain actions, such as that of Ancestral Guidance, can only be activated while the character is in the discard pile. Such actions should be defined by specifying the location property with the location from which the ability may be activated. The player can then activate the ability by simply clicking the card. If there is a conflict (e.g. both the ability and playing the card normally can occur), then the player will be prompted.

this.action({
    title: 'Play from discard pile',
    location: 'conflict discard pile',
    // ...
})

Language

Game messages should begin with the player doing the action

Game messages should begin with the name of the player to ensure a uniform format and make it easy to see who triggered an ability.

  • Bad: Kaiu Shuichi triggers to gain 1 fate for Player1
  • Good: Player1 uses Kaiu Shuichi to gain 1 fate

Game messages should not end in punctuation

No game messages should end in a period, exclaimation point or question mark.

  • Bad: Player1 draws 2 cards.
  • Good: Player1 draws 2 cards

Game messages should use present tense.

All game messages should use present tense.

  • Bad: Player1 has used Isawa Masahiro to discard Miya Mystic
  • Bad: Player1 chose to discard Miya Mystic
  • Good: Player1 uses Isawa Masahiro to discard Miya Mystic
  • Good: Player1 chooses to discard Miya Mystic

Targeting prompts should use the format "Choose a <card type>" where possible.

Targeting prompts should ask the player to choose a card or a card of particular type to keep prompt titles relatively short, without specifying the final goal of card selection.

  • Bad: Choose a character to return to hand
  • Good: Choose a character

Exception: If a card requires the player to choose multiple cards (e.g. Rebuild), or if a card requires the player's opponent to choose a card (e.g. Endless Plains) you can add context about which one they should be selecting. Just keep it as short as reasonably possible.

As valid selections are already presented to the user via visual clues, targeting prompts should not repeat selection rules in excessive details. Specifying nothing more and nothing less than the eligible card type (if any) is the good middle ground (this is what most prompts will default to).

  • Bad: Choose a Bushi

  • Good: Choose a character

  • Bad: Choose a defending Crab character

  • Good: Choose a character

  • Bad: Choose a card from your discard pile

  • Good: Choose a card

  • Good: Choose an attachment or location