MANDATORY PREPARATION
Before doing any substantive work, run ./.agents/skills/cyberpunk-tcg-rules (or ./.codex/skills/cyberpunk-tcg-rules) and keep its glossary in context for the full task. Load the matching reference files for every mechanic the card touches.
Implement a Card
CARD_TYPE= one oflegends,units,programs,gearCARD_FILE= the card's file stem (e.g.v-corporate-exile,jackie-welles-pour-one-out-for-me)
Step 1 — Load context
- Run the
cyberpunk-tcg-rulesskill and keep the full glossary in context. - Read the relevant reference files for every mechanic the card uses:
references/turn-structure.md— setup, phases, overtimereferences/combat-and-gigs.md— Gigs, Street Cred, attacking, blockingreferences/cards-and-keywords.md— card types, keywords, triggers
- Then read:
packages/cards/src/alpha/{CARD_TYPE}/{CARD_FILE}.ts— abilities, keywords, costs, powerpackages/engine/src/alpha/{CARD_TYPE}/{CARD_FILE}.test.ts— scaffold to fill in- A reference implementation of the same card type (see table below)
| CARD_TYPE | Canonical reference test |
| ---------- | ----------------------------------------------------------------------- |
| legends | packages/engine/src/alpha/legends/goro-takemura-hands-unclean.test.ts |
| units | Nearest existing unit test with a similar ability shape |
| programs | Nearest existing program test with a similar ability shape |
| gear | Nearest existing gear test with a similar ability shape |
Step 2 — Understand the card
From the definition file, identify every ability and classify it:
| Type | What to look for |
| --------------------------------- | ------------------------------------------------------------------------------ |
| Card type | legend, unit, program, gear — governs zones, timing, and fixture setup |
| Power | Present on Legends and Units; absent on Programs; on Gear it buffs the host |
| Keywords | GO SOLO, BLOCKER → rules from the skill (Legends and Units only) |
| Triggered abilities | trigger event, firstTimeEachTurn limits, effects |
| Static abilities | kind: "static", duration, conditions, targets, value; no trigger |
| Classifications | e.g. Arasaka, Merc → may affect other cards' abilities |
| Attachment target (Gear only) | Which units or legends this Gear can attach to |
Let card text override base rules wherever they conflict.
Step 3 — Identify what engine support exists
Before writing a single test, grep for each ability's trigger/effect type in the engine to confirm it is already implemented. If it is not, implement the engine behaviour first, then write tests.
Places to check:
packages/engine/src/moves/— move-level hookspackages/engine/src/operations/— effect primitivespackages/engine/src/types/game-events.ts— existing event typespackages/engine/src/ability-executor.ts— generic event-trigger pipeline (handlesfirstTimeEachTurnlimits, binding resolution, optional effects, conditional effects); hooked intoplay-card.tsafter thecardPlayedevent
Known engine patterns and gaps
- Event-typed triggers (
cardPlayed,gigStolen, etc.) are processed byability-executor.ts. TheprocessEventTriggers(...)call must exist in the relevant move handler — it is currently hooked intoplay-card.ts,call-legend.ts,attack-unit.ts,attack-rival.ts, andresolve-attack.ts. If a new event type needs triggering from a different move, add the call there. fliptriggers requirelegendFlippedprocessing:call-legend.tscallsprocessEventTriggersfor bothlegendFlipped(firesfliptriggers) andlegendCalled(firescalltriggers) events, in that order. If a legend has afliptrigger, thelegendFlippedevent processing is what activates it — notlegendCalled.- DSL event name vs. engine event type: Card definitions use DSL event names (e.g.
cardAttacks) that differ from engineGameEventtypes (e.g.attackDeclared). The mapping lives inEVENT_TYPE_TO_DSLintriggers/index.ts. Direct triggers (call,flip,defeated) are matched directly bymatchTriggersintriggers/index.tsvia the targeted pass (switch cases). When adding new event-based triggers, add entries to these maps. defeatedtrigger (cardDefeatedevent):resolve-attack.tsemitscardDefeatedAND callsprocessEventTriggersimmediately after.passesEventFilterreturnstruefor non-"event" triggers (play, attack, etc.) so they pass through aftermatchTriggerspre-selects them.- Bindings must be satisfied before costs are paid:
processEventTriggersskips any ability whose required bindings return no targets. Effects are also pre-checked for targets before costs are paid. This prevents unwanted spending when there is no valid target. ability.costsis processed byprocessEventTriggers: Spend costs are validated (card not already spent) and paid before executing effects.firstTimeEachTurnlimit tracking lives inTurnMetadata.abilityFiredThisTurn[]; it is cleared byresetTurnFlagsat turn end.evaluateConditionfortargetValue: { value: "max" }compares the die's face value againstDIE_MAX_VALUES[die.dieType](the die type's structural maximum, not the current game-wide max).handleModifyGigclamps the result to[1, DIE_MAX_VALUES[dieType]].playCardeffect from triggered abilities: Creates achooseCardToPlaypending choice. Resolve it withengine.resolveCardToPlay(gear). The boundattachTotarget is resolved eagerly and stored asresolvedAttachToIdin the pending choice payload. New moveresolveCardToPlayhandles the resolution.hasAttachedCards: falsetarget filter: The engine correctly handles this to find cards with NO attached gear. Used in bindings for "equip to unit with no gear" abilities.activatedtrigger (spend abilities): Cards withtrigger: { trigger: "activated" }andcosts: [{ cost: "spend" }]are spend-activated abilities. These are NOT handled byprocessEventTriggers— they require theactivateAbilitymove (packages/engine/src/moves/activate-ability.ts). The move validates the card is face-up (for legends), can pay costs, then executes the ability's effects. Test withengine.activateAbility(card, abilityIndex).searchDeckeffect: Creates asearchDeckpending choice. Resolve it withengine.resolveSearchDeck(selectedCards)(pass an array of card refs to add to hand, or[]to select nothing). The move validates selected cards match the target filter (card type, classifications,maxCost) and are within the look window. Non-selected cards are bottom-decked. TheresolveSearchDeckmove lives inpackages/engine/src/moves/resolve-search-deck.ts.- Deck fixture for search tests: Use
deck: 20to ensure enough cards remain after the 6-card opening draw. Useengine.patchState()orengine.judgeMoveCardToTopOfDeck()to position specific cards at the top of the deck before activating search abilities. getLastActionLog()may not return the expected log when a flip trigger fires searchDeck: ThecallLegend→fliptrigger →searchDeckpipeline emits multiple action logs (move.callLegend, thenmove.searchDeck.reveal). Useengine.getEvents("actionLog")and filter bymessageKeyinstead of relying ongetLastActionLog()for the call log.gigStolenevent triggers:resolve-attack.tsnow callsprocessEventTriggersfor eachgigStolenevent after gig stealing. The event carriessourceCardId(the attacker).passesEventFilterhandlesgigStolenwithplayer(friendly/rival based ontoPlayerId) andsource: { selector: "self" }(matchesevent.sourceCardIdagainst the ability card's instanceId). ThemoveGigoperation accepts an optionalsourceCardIdparameter.ifYouDo+removeFromGamewithoptional: true: WhenremoveFromGameis thedoEffectinsideifYouDoand hasoptional: true, the engine auto-executes it (removes the card). Theoptionalflag does not create a player confirmation choice — it auto-resolves. TheifEffectsthen fire (e.g.playCardfrom trash).- Direct attack resolution requires 3
resolveAttackcalls: The flow is: (1)resolveAttack()(P1, offensive → defensive), (2)resolveAttack({ as: P2, pass: true })(P2, defensive → resolve), (3)resolveAttack()(P1, resolve: execute steal/fight). cardAttackstriggertargetfilter (classifications, cardTypes):passesEventFilterchecks thetargetproperty oncardAttackstriggers, filtering bytarget.cardTypesandtarget.classificationsagainst the attacking card's definition. This ensures triggers like "when a friendly Arasaka unit attacks" only fire for matching units.discardFromHandeffect: Creates achooseTargetpending choice (withpayload.type: "discardFromHand") whenamount > 1or hand has more cards than the amount. In the current engine behavior, this means a pending choice is still created whenhand.length === amountandamount > 1; it only auto-resolves when there is no actual selection to make (for example, a single-card discard with exactly one card in hand). Resolve the pending choice withengine.resolveDiscardFromHand(cards). The move lives inpackages/engine/src/moves/resolve-discard-from-hand.ts.- Conditional effects (effect.conditions):
executeAbilityEffectsevaluateseffect.conditionsbefore each effect. If any condition fails, the effect is skipped. For example,discardFromHandwith astreetCredcondition only fires when the player's Street Cred meets the threshold.
Static modifyPower continuous abilities (kind: "static", no trigger)
These are evaluated dynamically — they are NOT stored as continuousEffects.
- Check
packages/engine/src/static-effects/index.tsforcomputeStaticPowerModifier. If the function or the relevant branch is absent, implement it before writing tests. - Check
packages/engine/src/moves/resolve-attack.ts— it historically had a localgetEffectivePowercopy. If it still does, remove it and import fromstatic-effects/index.tsso attack resolution picks up the new modifier. - Static abilities do not emit events; skip the "Action log" scenario for pure static modifiers.
Per-target attacking condition in static abilities
The generic evaluateCondition("attacking") checks ids[0] (first resolved card), which is wrong for per-unit buffs like "Your Arasaka units have +1 power when attacking."
In computeStaticPowerModifier, special-case attacking: check attack.attackerId === cardId (the card whose power you're computing) instead of using the generic evaluator.
Do NOT change evaluateCondition itself — triggered abilities rely on the existing ids[0] semantics.
Step 4 — Write the tests
Replace the scaffold body. Structure the file as one describe block per keyword/ability, with the ability's full rules text as the describe label. Inside each block, cover:
| Scenario | What to assert |
| ------------------- | ------------------------------------------------------------------------------------------------------------- |
| Happy path | The effect fired, state changed correctly |
| Side effects | Cards spent, counts changed, zones updated |
| Negative path | expectFailure with the right errorCode |
| Action log | getLastActionLog() has the right messageKey and params; formatActionLog produces readable output |
| Power during attack | For static power modifiers: use toHaveEffectivePower to assert effective power when attacking and when idle |
Add at least 8 meaningful tests beyond the scaffold. Note the baseline test count before starting.
Fixture rules — by card type
Legend
- Default (triggered / flip abilities):
legendArea: [legend]— card starts face-down; static abilities are INACTIVE; enters play viacallLegend. - To test a static ability without calling the legend first, use:
OmittinglegendArea: [{ card: legend, faceDown: false }];faceDown: falsesilently disables the ability — you will see base power. - GO SOLO legends entering ready:
field: [{ card: legend, spent: false, faceDown: false }]— nolegendAreaentry needed. ThefaceDown: falseis critical: legends default tofaceDown: trueat creation time (based on initial zone), and the fixture only overridesfaceDownwhen explicitly set. Without it, triggered abilities on a field legend will be silently inactive becausecollectFromAllCardsskips face-down legends. - Filler legends (auto-filled when
legendArea< 3) start face-down and cannot silently activate static abilities — no need to exclude them explicitly. spent: trueis overridden byskipSetupfor legend area cards: the fixture engine resets all legend area cards tospent: falseon setup. To test "legend is already spent" scenarios, useengine.patchState(draft => { ... draft.G.cardIndex[id].meta.spent = true; })after creating the fixture.callLegendalways costs 2 eddies: addeddies: 2(or more) to any fixture that calls a legend.attackRival/attackUnitnow firecardAttackstriggers: legends and field units withtrigger.trigger === "event" && event.event === "cardAttacks"will fire when any unit attacks (after the engine fixes in PR feat/implement-river-ward-detective-on-the-hunt).
Unit
- Play from hand (summoning sickness applies):
hand: [unit], eddies: cost→ callengine.playCard(unit). - Pre-placed on field, ready (no summoning sickness):
field: [{ card: unit, spent: false }]. - Pre-placed on field, spent:
field: [{ card: unit, spent: true }]. - GO SOLO units: play via
playCard(unit); they enter ready and can attack that turn.
Program
- Programs are one-shot. Place in hand, pay cost, play — the effect resolves immediately and the card goes to trash.
hand: [program], eddies: cost→engine.playCard(program)→ assert state change, then check card is in trash.- Programs have no power; skip all power/attack assertions.
Gear
- Gear must be attached to a valid host.
hand: [gear], eddies: cost+ a target unit on the field →engine.attachGear(gear, targetUnit). - When the host unit leaves the field, Gear moves to the same zone (assert this).
- Gear with a static power modifier buffs the host's effective power; test with
toHaveEffectivePoweron the host.
Cross-type fixture rules (apply to all)
- A card definition appears in at most one player's fixture; the engine deduplicates filler picks, but explicit placements must not overlap.
gigAreaentries:[{ dieType: "d6", faceValue: 3 }]. Standard fixer dice are one each:d4, d6, d8, d10, d12, d20. To create a sided-pair (two dice with the same face value), use two different die types with the samefaceValue— e.g.[{ dieType: "d6", faceValue: 3 }, { dieType: "d8", faceValue: 3 }]. Using the samedieTypetwice will silently fail because only one die of each type exists in the fixer area.- Filler legends are drawn from the full card catalog. Never assume a specific card is absent from the legend area unless you control all three slots.
- Eddies do not regenerate between turns. Set
eddieshigh enough for all plays from the start (e.g.eddies: cost * 4). deck: Nfixture draws N cards into the deck list, butcreateMatchStatedraws 6 cards to the opening hand. If you usedeck: Nto test a "trash from deck" or "count deck cards" effect, use at leastdeck: 20(or larger) so that 14+ cards remain after the opening-hand draw. Usingdeck: 5or fewer will leave the deck empty and the effect will silently no-op.- To put a card in hand for a second turn, declare two copies at fixture time:
hand: [card, card]; the second remains after the first is played. completeTurn()cannot be called while an attack is in progress. Resolve the attack first (viaresolveAttacksteps), then callpassPhase()once (already in attack phase) instead ofcompleteTurn()(which callspassPhasetwice and fails because the second call is the opponent's turn).- Attacking a ready rival unit returns error code
TARGET_READY, notDEFENDER_NOT_SPENT. Useexpect(failure.errorCode).toBe("TARGET_READY")in negative-path tests.
TestEngine conventions
| Convention | Detail |
| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| engine.passToAttackPhase() | Wraps passPhase(); advances from play to attack phase |
| engine.getAttackState() | Safe accessor; never read engine.getState().G.attackState directly |
| engine.getCard(card) | No zone/player args when the card is unique on the board. If the card catalog includes the same definition for deck filler, always pass zone and player — e.g. engine.getCard(card, "field", P1) — to avoid resolving to a different instance in the deck/hand |
| engine.getLastActionLog() | Never write a local lastLog helper (N/A for static-only abilities) |
| engine.getGigValue(playerId, dieIndex?) | Returns faceValue of the nth gig die (0-indexed) |
| engine.getHandCount(playerId) | Returns number of cards in hand |
| { as: P1 } | Omit — active player defaults to P1; only pass { as: P2 } when acting as P2 |
| Reusable assertions | Belong on CyberpunkTestEngine as a method, not as a test-local function |
| registerMatchers() | Must be called once at the top of any test file that uses toHaveEffectivePower or other custom matchers. Import from "../../testing/index.ts" |
| toHaveEffectivePower | expect(engine.getState()).toHaveEffectivePower({ card: instance.instanceId as string, value: n }) — verify power with/without static modifier, before and during attack |
| engine.resolveCardToPlay(gear) | Resolves a chooseCardToPlay pending choice by selecting the given gear card. Required after any ability that uses the playCard effect (e.g. River Ward's equip). |
| engine.activateAbility(card, idx) | Activates a spend-activated ability on a card. idx is the 0-based ability index in the card's abilities array. Pays costs and executes effects. |
| engine.resolveSearchDeck(cards) | Resolves a searchDeck pending choice. Pass an array of card refs to select (or [] to pick nothing). Selected cards go to hand; rest are bottom-decked. |
| engine.resolveDiscardFromHand(cards) | Resolves a discardFromHand pending choice. Pass an array of card refs to discard from hand to trash. The number of cards must match the required discard amount. |
For static power modifiers, add a test proving idle units on the same field are NOT buffed (only the qualifying unit benefits).
When asserting "no effects" on a unit, filter by origin: "imperative" or specific kind/rule values. Units on the field carry static cantAttack (summoning sickness) active effects with origin: "static" — a raw activeEffects.some(e => e.targetCardId === id) check will always be true and the negative assertion will fail.
Step 5 — Run and iterate
source ~/.zshrc && vp test packages/engine --run
Fix failures. Do not move on until all tests pass. Confirm the new test count exceeds the pre-implementation baseline by at least 8.
Step 6 — Retrospective (self-update this skill)
Answer each question below. For any answer that reveals a new pattern, edge case, or constraint, edit this SKILL.md file directly to incorporate it. That is what creates the virtuous loop — each card either confirms existing patterns (prompt stays tight) or reveals a new constraint (prompt absorbs it so the next card starts with that knowledge already baked in).
Questions to answer:
- Was any engine operation missing? → document the pattern for detecting this earlier (add to Step 3)
- Did any fixture constraint cause a failing test that was surprising? → add a note to the matching card-type fixture section in Step 4
- Was a new
TestEnginehelper needed? → add it to the conventions table - Did any rules question arise that the skill didn't answer? → note it so the
cyberpunk-tcg-rulesskill can be updated - Were any
ActionLogMessageKeyentries missing? → note the pattern for adding them - Was there a per-target condition (
attacking,playedThisTurn, etc.) that needed special handling incomputeStaticPowerModifierrather than the genericevaluateCondition? → document the pattern - Did a new card type reveal fixture patterns not yet covered in Step 4? → add a new card-type section
After editing, commit the updated SKILL.md with message: docs(skills): update implement-card playbook after {Card Display Name}.
Step 7 — Make a PR
- Branch:
feat/implement-{CARD_FILE} - Commit message:
feat(engine): implement {Card Display Name} - PR description must include:
- Card type and which abilities are covered
- Which engine operations were added or reused