-
Notifications
You must be signed in to change notification settings - Fork 34
Advanced Card Behaviors
This section describes features for handling more complex card card behaviors.
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 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()
})
}
});
Abilities that say "if you do," usually in the form "you may [X]. If you do, then [Y]", are not yet supported.
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)
});
If a card ability has multiple discrete effects, use one of the following "meta-effects" which allow chaining other effects together.
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 })
])
}
});
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
})
});
}
class AbilityContext
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
.
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).
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 })
})
}
});
}
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)
}
}
});
This section describes some of the major components that are used in the definitions of abilities:
- Context objects
- Game systems
- Target resolvers
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
.
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 }
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.
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]
});
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.
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 TargetMode.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 the game system that will be executed when that option is chosen. The selected option is stored in context.select
(or context.selects[targetName].choice
for an ability with multiple targets).
// When Played: Either ready a resource or exhaust a unit.
this.addWhenPlayedAbility({
title: 'Ready a resource or exhaust a unit',
targetResolver: {
mode: TargetMode.Select,
choices: {
['Ready a resource']: AbilityHelper.immediateEffects.readyResources({ amount: 1 }),
['Exhaust a unit']: AbilityHelper.immediateEffects.selectCard({
cardTypeFilter: WildcardCardType.Unit,
innerSystem: AbilityHelper.immediateEffects.exhaust()
})
}
}
});
// Deal 7 damage to an enemy unit unless its controller says "no." If they do, draw 3 cards.
this.setEventAbility({
title: 'Deal 7 damage to an enemy unit unless its controller says "no"',
targetResolvers: {
targetUnit: {
controller: RelativePlayer.Opponent,
cardTypeFilter: WildcardCardType.Unit
},
opponentsChoice: {
mode: TargetMode.Select,
dependsOn: 'targetUnit',
choosingPlayer: RelativePlayer.Opponent,
choices: (context) => ({
[`${context.targets.targetUnit.title} takes 7 damage`]: AbilityHelper.immediateEffects.damage({
target: context.targets.targetUnit,
amount: 7
}),
['Opponent draws 3 cards']: AbilityHelper.immediateEffects.draw({ amount: 3 })
})
}
}
});
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.
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.
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.
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 }))
});
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),
// ...
});
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']
});
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',
// ...
})
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
No game messages should end in a period, exclaimation point or question mark.
- Bad: Player1 draws 2 cards.
- Good: Player1 draws 2 cards
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 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