-
Notifications
You must be signed in to change notification settings - Fork 34
Card Implementation Quickstart
To implement a card, follow the steps in this guide
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.
We have a policy of creating at least one unit test with each new card. Please see the Card Testing Guide (WIP) and Test Cheat Sheet for details.
If you are having issues with your card implementation, see the Debugging Guide.
If you plan to contribute, please reach out in our Discord server so we can communicate about PRs and say thanks!
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.
IMPORTANT: there are still some missing engine features so that some card abilities can't be implemented yet. Check the "Can be Implemented" column before choosing a card to work on.
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.
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).
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.
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.
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;
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.
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.
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:
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.
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 })
});
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 })
});
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)
});
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 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:
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()
}
});
}
}
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(),
// ...
});
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 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:
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)
});
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 |
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 }
)
}
});
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()
}
});
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()
})
Some helper methods are available to make it easier to declare constant abilities on upgrades, since these are extremely common.
Static upgrade stat bonuses from the printed upgrade values are automatically included in combat calculations for the attached unit.
Some cards can only attach to cards that meet certain requirements. These requirements can be set with the setAttachCondition()
method, which accepts a handler method accepting a potential card to attach to and returns true if the card is a legal attach target for this upgrade. See Vambrace Grappleshot, which can only attach to non-vehicles:
public override setupCardAbilities() {
this.setAttachCondition((card: Card) => !card.hasSomeTrait(Trait.Vehicle));
// ...set abilities here ...
}
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, for which we have the methods addGainTriggeredAbilityTargetingAttached
and addGainOnAttackAbilityTargetingAttached
. See Vambrace Grappleshot:
// Attached character gains ability 'On Attack: Exhaust the defender'
this.addGainOnAttackAbilityTargetingAttached({
title: 'Exhaust the defender on attack',
immediateEffect: AbilityHelper.immediateEffects.exhaust((context) => {
return { target: context.source.activeAttack?.target };
})
});
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 - meaning that the effect is only active if certain conditions are met - it can be set using the gainCondition
property. 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.addGainOnAttackAbilityTargetingAttached({
title: 'Deal 1 damage to each ground unit the defending player controls',
immediateEffect: AbilityHelper.immediateEffects.damage((context) => {
return { target: context.source.controller.opponent.getUnitsInPlay(Location.GroundArena), amount: 1 };
}),
gainCondition: (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(),
});
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 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 })
}
});
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 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:
- The class must extend
LeaderUnitCard
, notLeaderCard
. Using the latter will cause the card to not work correctly. - Instead of the typical
setupCardAbilities
method, there are two methods - one for each side of the leader card:setupLeaderSideAbilities
andsetupLeaderUnitSideAbilities
. Both of these must be implemented for the card to function correctly.
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.