| name | swusim-implement-card |
| description | Use when implementing SWU card abilities — looks up card text, writes all DSL tests first, then self peer-reviews against the CR + game logic + card data and implements; only stops for the user on a card it can't verify to 98% alone. Supports single cards and batches. |
SWUSim Implement Card
Overview
TDD workflow for card ability implementation. Works for a single card or a batch of cards.
Three phases. After all tests are written, do a self peer-review rather than a blanket stop-and-wait.
Gate after Step 2 — self peer-review, then proceed (policy updated 2026-06-22):
There is no tier-based hard stop. After writing tests and RED-checking, review your own work for flow correctness against three sources before implementing — then proceed straight through implementation:
- The CR (
.claude/SWUSim/refs/comprehensive-rules.md) — confirm the timing window, interaction order, and any rules-keyword semantics (e.g. "When this unit is attacked" = the On Defense window, CR 15.c).
- Game logic — trace the actual code path the card will take (the combat-pause, the disclose flow, the trigger collection point) and confirm the tests drive the real execution path, not just a fixture stand-in.
- Card data — every stat/cost/aspect/trait in the tests derived from the dictionary arrays (not memory or a prose doc), for every fixture including the incidental ones.
If that self-review leaves you at ≥98% confidence the tests are correct and the implementation is clear, just implement it (show the tests + design in the eventual batch summary so the user can course-correct after).
Only STOP and ask when a card is too complex to verify confidently on your own — i.e. the self-review can't get you to 98%:
- an ambiguous ruling / interaction the dictionary + CR don't settle;
- new shared infrastructure with a real design choice the user alone should make (a mechanical mirror of an existing seam is NOT a real choice — verify and proceed);
- a scope/realism decision (which scenarios matter); or you simply can't reach confident correctness.
Flag that specific card/fork — don't gate the whole batch. Rationale: the mechanical test-writing (correct DSL, RED-by-construction, vetted fixtures/stats) is reliable, and a disciplined CR + game-logic + card-data self-review catches the flow errors a human review would; the residual human value is genuine design/ruling intent, which is rare and surfaced as a targeted question, not a blanket gate.
Confidence bar — 98% minimum on every new card (policy set 2026-06-15; 94% 2026-06-22; raised to 98% 2026-08-02)
No card is "Done" until you'd honestly rate correctness ≥ 98%. This is a per-card bar, not a batch average — one shaky card at 80% is not offset by four solid ones. Before marking a card Done, ask yourself: "If the user manually playtested this right now, what's the chance they find a wrong number or a broken interaction?" If that chance is over ~2%, the card is not done.
Why the bar is this high. A later independent validation pass over an already-"finished" set routinely finds a dozen behaviors nobody tested — and every one of those is a bug that shipped, or a bug that could ship on the next refactor with nothing to catch it. The target is that such a pass finds ZERO new behaviors. So the standard is not "my tests pass"; it is "an independent test-writer, working only from the printed card text, could not think of a scenario I haven't written." Write the tests you would want to find if you were auditing someone else's work.
The coverage matrix — derive it from the CARD TEXT, mechanically
Decompose the printed text into clauses first (see the clause-decomposition gate below), then walk both lists. Every cell is either a section or a written, specific reason it is N/A ("no cost, so no payment axis") — never a silent omission.
Per CLAUSE (do this for each clause independently, then once for all clauses firing together):
- Positive — the clause does its thing.
- Negative — prove the gate is load-bearing. Every
if / while / "you control a X" / "that costs N or less" / "while defending" needs its FALSE case asserting the clause does NOT fire. This is the single most commonly missing test, and it is missing even when the code is right (Gold Leader JTL_054's aura was correct but neither "when IT attacks" nor "when another friendly unit defends" was tested).
- Optional branch — take AND decline (
AnswerDecision:- / PASS). A decline that silently does the wrong thing is a classic latent bug.
- No valid target — must no-op cleanly (no crash, no dangling decision) — and decide explicitly whether the sibling clauses still resolve. "Defeat X and do Y" is NOT gated by Y being possible; only an explicit "If you do," gates. Getting this backwards fizzles the whole card (Lightspeed Assault).
- Quantity discrimination — pick a value that separates the intended formula from a plausible wrong one ("distinct aspects" vs "card count" needs a same-aspect case that heals 1, not a 2-distinct case that heals 2), and include the zero case.
- Boundary — exactly-N vs N±1 for any threshold ("2 or less", "prevent all but 4", "6 or more power").
Per CARD (cross-cutting — these are where the deep bugs live):
7. Dispatch-path matrix — every way the ability can be REACHED is a different code path. Played from hand · played as an upgrade via Piloting · created as a token · put into play / played for free by another card · relocated or moved · leader FRONT side vs DEPLOYED side. Cross this with the trigger halves: a card with When Played and On Attack needs the condition tested on both halves (Fett's Firespray read "control Boba" correctly on When Played but On Attack was untested; Iden Versio's attach trigger fired on play but not on relocation).
8. Value-CLASS variants, not just different numbers. A cost-0 token upgrade is a different case from a cheap real upgrade; a token unit from a real unit; a leader unit from a normal unit; a deployed leader from an undeployed one. If the text says "an upgrade that costs 2 or less", a Shield/Experience token IS a legal target — test it.
9. Persistence across state transitions. Whatever the card writes must survive every transition it can experience: an arena move, a control change (owner ≠ controller), a host change, leaving and re-entering play, and the request boundary (see the transient-globals shape below). Assert the effect still applies after the transition, not just before.
10. Duration edges. A "for this phase" restriction must expire — test the next phase, where the restricted thing now works. A "once per round/game" must not re-fire on the second attempt. A delayed "at the start of the regroup phase" must still find its target after the unit has moved arenas.
11. Interaction with the standard modifiers. Shields absorbing damage (does the rider still fire?), "can't be defeated/damaged/captured by enemy card abilities", indirect/unpreventable damage, prevention caps — and for ANY cost, that Credit tokens / SEC_122 Droids can pay it (gate offers on SWUTotalPaymentCapacity, never a bare ready-resource count).
12. Scope exclusions — what the effect must NOT touch. An effect naming zones or sets must leave the adjacent ones alone: "search their deck and hand" must not hit units in play or a same-named deployed leader; "another" excludes self; "friendly" excludes enemy (and an unqualified "a unit" includes enemies); "a base" with no qualifier means EITHER base.
Before marking Done — the adversarial audit (mandatory)
Re-read the printed text as if you had never seen the implementation, list every scenario an independent auditor would write from that text alone, and diff that list against your sections. Anything unmatched becomes a section or a stated N/A. Two failure modes this catches, both seen repeatedly:
- A test NAME that doesn't match what it asserts — audit by reading assertions, never titles.
- "The code obviously does this" — that reasoning is exactly what leaves a correct behavior untested until a refactor breaks it silently.
- The REAL execution path, not just a fixture stand-in. A deployed-leader ability dropped into the arena via
WithP*GroundArena tests the handler but NOT the deploy→attack dispatch; add one test that actually DeployLeaders and acts. (And note CommonSetup's leader codes map to a fixed leader per aspect-combo — bw is Luke SOR_005, not every Vigilance+Heroism leader; either override it with the myLeader:CARDID opt or use explicit P1LeaderBase: <CARDID>/<BASE>:<dmg> when you need a specific leader. For a pre-deployed leader, myLeaderDeployed:true (as a unit) / myLeaderDeployedPilot:true (as a Pilot on the first friendly unit) set it up without a DeployLeader step. _parseBaseSpec accepts BASEID:damage to pre-damage a base for heal assertions.)
When in doubt, ASK — don't guess and don't silently ship at 70%. If a card's ruling is ambiguous, an interaction is unclear, or you can't get a scenario to a confident green, stop and ask the user a specific question (ruling? intended scenario? acceptable to defer this edge?), OR propose the extra tests you'd write to close the gap and let them confirm. Surfacing "I'm at ~80% on card X because edge Y is untested — want me to add tests A/B or is that out of scope?" is always correct; quietly marking it Done is not. A confidence self-review at the end of a batch (per-card %, with anything <98% flagged for the user) is a good habit — the user may opt to manually playtest the flagged ones.
Leaders are two-sided — the 98% bar applies to EACH side independently, never averaged. A leader card has a leader (front) side (its Epic deploy + any "Action:" / "When you take the initiative:" ability it has while undeployed) AND a leader unit (deployed) side (deployTextData[CID] — On Attack, When Deployed, attack-end / "completes an attack", passives, a deployed Action [...]:, and granted keywords). These are separate ability sets dispatched by different code, so a rock-solid front side tells you nothing about the deployed side. Treat them as two cards: a leader is not Done until you'd honestly rate both sides ≥98% on their own — a 99% front side does not offset an unimplemented deployed side (that's two verdicts, and the deployed one fails). Before marking any leader Done:
- Read
deployTextData[CID] separately from the front text and enumerate every deployed-side ability.
- Confirm a real handler is registered for each. A generated
Has<Trigger>Ability(CID) detector returning true with no matching $*Abilities["CID:0"] handler is a silent in-game no-op, not a false positive — this is the ASH_011 / SWUSim/docs/leader-gaps.md class. Mapping: On Attack → $onAttackAbilities["CID:0"]; When Deployed → $whenPlayedAbilities["CID:0"] (NOT leaderAbilities[CID] — that's the front Action); attack-end/completes → $onAttackEndAbilities["CID:0"]; deployed Action [...]: → $unitAbilities[CID] + $unitActionCostKind/$unitActionResourceCosts (SWUUnitAction does not fall back to leaderAbilities); passive → ObjectCurrentPower/ObjectCurrentHP field-presence or keyword-grant code.
- Add at least one test that actually
DeployLeaders and exercises the deployed ability (cf. Tests/Cases/ash/CadBane_PingLeaders.md). WithP*GroundArena placement tests the handler closure but NOT the deploy→dispatch wiring — see the REAL-execution-path axis above.
- Force-action exhaust nuance: a deployed
Action [use the Force] (no [Exhaust]) must NOT exhaust the leader unit and must stay usable while exhausted — wire it with a non-exhaust costKind + a Force-token payment, never the default 'exhaust'. (The front side of the same leader is often [Exhaust, use the Force]; the deployed side drops the exhaust.)
Step 0 — Triage: is there anything to implement at all?
"Implement a card" ≠ "write code for a card." Two whole classes of card resolve to verification only — no tests, no code, just confirm the generator already handled them and mark them Done. Run this triage on every card before any research, and drop the no-ops out of the batch up front (it prevents an unnecessary research + test cycle):
-
Vanilla (blank text box). If $textData has no entry / empty text, the card is fully implemented by the dictionaries (a vanilla upgrade's +power/+HP flows through the existing ObjectCurrentPower/ObjectCurrentHP upgrade loop). No tests ever. Mark Done.
-
Keyword-only text, keyword(s) already implemented. If $textData is nothing but keyword(s) + their reminder text — one OR more keywords, e.g. just Grit (…), or Ambush (…) Overwhelm (…), or Saboteur (…) Raid 2 (…) — confirm all three and then mark Done — write nothing:
$textData is keyword-only (no other sentence/ability; multiple keyword lines are fine),
- each keyword's card ID is in the matching registry in
SWUSim/GeneratedCode/GeneratedKeywordCode.php — $Grit_Cards, $Sentinel_Cards, $Shielded_Cards, $Restore_Cards (value), $Saboteur_Cards, $Ambush_Cards, $Overwhelm_Cards, $Raid_Cards (value), etc.,
- each keyword already has a generic behavior test under
SWUSim/Tests/Cases/keywords/ (or sor/).
A per-card test here would be GREEN on first RED-check — the Step 2 scope rule says drop it. Membership is auto-derived from card text by the generator, so it's guaranteed correct.
⚠ "Keyword + rider" is NOT a no-op. The keyword-only fast-path applies only when the text is exclusively keyword reminder lines. A keyword plus any other sentence — "Ambush. When Played: return a unit from your discard" (SOR_101), "Raid 1. If you control a Trooper, this costs 1 less" (SOR_248) — has a real ability the keyword wiring does not cover. The keyword half is free, but the rider is genuine work (often Medium): continue to Step 1 for the rider. A card is only fully Simple/no-op when its entire text reduces to already-built primitives.
DICT=SWUSim/GeneratedCode/GeneratedCardDictionaries.php
KW=SWUSim/GeneratedCode/GeneratedKeywordCode.php
awk '/\$textData = array \(/,/^\);/' "$DICT" | grep "'CARD_ID'"
awk '/Sentinel_Cards = \[/,/\];/' "$KW" | grep 'CARD_ID'
grep -rilE 'sentinel|grit|shielded|restore|saboteur|ambush|overwhelm|raid' SWUSim/Tests/Cases/keywords/
awk delimiter gotcha: the dictionary arrays are $foo = array ( … ); (match = array \( … ^\);). The keyword registries in GeneratedKeywordCode.php are $Foo_Cards = [ … ]; (match _Cards = \[ … \];). Don't reuse the dictionary pattern on the registries. If $VAR-in-awk ever misbehaves, use the literal path.
Keyword-granting cards (an upgrade/passive that gives a keyword to another unit — "Attached unit gains Sentinel", "each other friendly unit gains Raid 1") are NOT auto-wired by the registries, but they're often already implemented in SWUSim/Custom/KeywordEffects.php's HasConditionalKeyword_* switches. Before treating one as new work: grep that file for the card ID, and check whether the grant mechanism already has generic coverage (e.g. core/UpgradeSaboteur_Grant.md covers Saboteur-via-upgrade). If the case exists AND the mechanism is tested for that keyword → mark Done. If the case exists but that grant path has no test (e.g. Sentinel-/Restore-via-upgrade) → add ONE behavioral guard test (it'll be GREEN since implemented — that's fine, it's a regression guard for hand-maintained switch code, not a redundant test). Only if no case exists is it genuine new implementation → Step 1.
3. Already implemented but unmarked. Cards are frequently already coded yet missing from the Done list. Always grep the card ID across the whole Custom/ tree before treating it as new work:
grep -rn "CARD_ID" SWUSim/Custom/
⚠ Card code layout (since the session-95 split). A card's ability/DQ registrations now live in its own file SWUSim/Custom/cards/<set>/<TitleSubtitle>.php (reprints consolidated into the earliest printing's file), loaded by cards/_loader.php. The monoliths (CardDQHandlers.php, LeaderAbilities.php, BaseAbilities.php) keep only shared helper families, generic utilities, engine glue, and a few load-order-coupled cards; CardEffects.php was deleted (its OnPlayEvent event-play logic was inlined into ActivateCard in GameLogic.php). Shared helpers SWUOfferUnitTarget/SWUOfferBaseTarget/SWUOfferDiscard/GiveTokenUpgrade live in CardHelpers.php; the object-aware trait check is TraitContains($obj,$trait) (_SWUUnitHasTrait was deleted — don't re-add it). Because file names are TitleSubtitle (not derivable from the CardID), resolve a card by grepping its registration key (grep -rln "'<CID>'" SWUSim/Custom/cards/) or via cards/_index.generated.php (regen with php SWUSim/DevTools/regen-card-index.php if stale). Always grep/scan recursively (SWUSim/Custom/ or …/**/*.php), never a bare Custom/*.php — the latter misses every split card.
If the effect already exists (e.g. SOR_172 Open Fire was already complete in cards/sor/OpenFire.php), just add a test (if none) and mark Done — don't re-implement. Note the dead-code caveat from the passive row: a case in the GA-fallback ObjectCurrentPower/HP (~line 10555 of GameLogic.php) is NOT live.
⚠ The INVERSE trap — a GeneratedAbilityStubs.php entry is NOT evidence the card is implemented. The stub only declares that the card has a WhenPlayed/OnAttack/WhenDefeated/etc. trigger (the generator detected trigger text); the actual effect lives in a $whenPlayedAbilities/$onAttackAbilities/$whenDefeatedAbilities/$customDQHandlers / OnPlayEvent handler that may never have been written. A card with a stub but no matching Custom handler silently no-ops in-game — the trigger fires and dispatches to nothing. So HasWhenPlayedAbility(CARD) returning true means "wired to fire," not "implemented." Always confirm via the four-ability-file grep above; an empty result with a non-empty stub = genuine unimplemented work, not a done card. (This is how a whole band of SOR cards — tier-classified but never batched — was found silently broken.)
After triage: mark every no-op / already-done card per Step 4, and carry only the cards with genuine unimplemented behavior into Step 1.
Modifying an ALREADY-implemented card (behavior/UX change, not net-new). Some tasks aren't "implement a blank card" — they tweak a card that already works (e.g. "show the opponent's hand only when the discard auto-resolves"). The triage above still applies (grep the four ability files to find the existing handler), but two extra habits matter:
- Capture a baseline regression BEFORE writing or editing any test (
curl …/zzRegressionSWUSim.php, note pass/fail counts and any already-red tests). A modification routinely touches existing passing tests and can sit next to a pre-existing failure — the baseline is what lets you tell your new RED from breakage that was already there. (Real case: a sibling test was already failing for an unrelated reason; without the baseline it'd have looked like collateral from the change.)
- The card usually already has tests — read them first; your change may need to edit their WHEN/EXPECT, which trips the "ask before modifying confirmed tests" rule. Surface those diffs at the Step 2 review gate.
Step 1 — Research the Cards
For each card in the batch, look up its data:
grep "'CARD_ID'" SWUSim/GeneratedCode/GeneratedCardDictionaries.php | grep -v "[0-9][0-9]*$\|titleData\|cardUUID\|costData\|powerData\|hpData\|rarityData\|setData\|uniqueData\|arenaData"
For cost, power, and HP, query each array section separately — never rely on ordering of bare numbers in combined output:
awk '/\$costData = array \(/,/^\);/' SWUSim/GeneratedCode/GeneratedCardDictionaries.php | grep "'CARD_ID'"
awk '/\$powerData = array \(/,/^\);/' SWUSim/GeneratedCode/GeneratedCardDictionaries.php | grep "'CARD_ID'"
awk '/\$hpData = array \(/,/^\);/' SWUSim/GeneratedCode/GeneratedCardDictionaries.php | grep "'CARD_ID'"
awk '/\$upgradePowerData = array \(/,/^\);/' SWUSim/GeneratedCode/GeneratedCardDictionaries.php | grep "'CARD_ID'"
awk '/\$upgradeHpData = array \(/,/^\);/' SWUSim/GeneratedCode/GeneratedCardDictionaries.php | grep "'CARD_ID'"
⚠ PILOTS contribute their upgradePower/upgradeHp to the host, NOT their unit power/hp (JTL Phase 17 gotcha). A Piloting card has BOTH unit stats (powerData/hpData, used when played as a unit) AND upgrade stats (upgradePowerData/upgradeHpData, used when attached as a pilot) — and the two usually differ (JTL_046 is a 3/2 unit but a +2/+0 pilot). When a test attaches a pilot to a Vehicle, the host's expected power = host base + pilot's upgradePower + any Experience/Grit, never the pilot's unit power. Seeding the pilot's unit power into a POWER expectation reddens the test (real case: JTL_046 host expected 6, actual 5 — pilot upgradePower 2 not unit power 3).
More JTL Phase 11-21 test gotchas (folded at the autonomous→pair-programmed retro):
- Indirect damage to a player auto-resolves to the base ONLY when that player controls no units (1 spec → no popup) — give the damaged player NO units and assert
P{n}BASEDMG:X. If they DO control units it's a cross-player MZSPLITASSIGN: use WithActivePlayer: 1 (not P1OnlyActions) and answer as the damaged player P2>AnswerDecision:myBase-0:N (assigner's own frame, comma-sep mz:amt). whenPlayed/whenDefeated AND mid-combat onAttack indirect-splits all work cleanly now (the old JTL_227 "onAttack indirect-split mis-resolves" bug was fixed by the session-50 indirect-funnel rework — the assignment rides the decision PARAM so it survives the request boundary; guard: SuperheavyIonCannon227_OnAttack_ExhaustIndirect).
- The ability-stub generator misses some dual-mode / Piloting cards (
HasOnAttackAbility/HasWhenPlayedAbility absent → the trigger silently no-ops even with a handler registered). Confirm the stub exists; if not, hand-add the case 'CARD': to the right Has…Ability switch in GeneratedAbilityStubs.php and note it (JTL_187 Bossk unit-side On Attack, JTL_210 Mandalorian as-unit WhenPlayed).
- A Piloting card used as a generic "played a card" fixture prompts a Unit/Pilot OPTIONCHOOSE when a friendly Vehicle host is present (or even no host in some flows) — it desyncs your WHEN. Use a NON-Piloting card (e.g. a vanilla keyword unit like SHD_147) when you just need "played a card of trait X" (JTL_186 test fix).
- Off-aspect cost stacks per pip: a double-same-pip card (e.g. Cunning/Cunning) is +4 off-aspect — give ≥ printed cost + 4 resources, or match the leader/base aspect, or the play silently fails and the rest of the WHEN misaligns (JTL_210).
- Per-card regroup-phase effects (when-regroup-starts / ready-step taxes) go in
RegroupPhaseStart (drain-loop on a marker, modeled on SWU_SNEAK_DEFEAT) or via the SWUQueueFalconRegroupTriggers pattern; tests reach the ready step with P1>Pass, P1>ResourcePass, P2>ResourcePass and need 6-card decks.
JTL Phase 22-24 gotchas (folded at the end-of-run retro):
- ⚠ Inside the ATTACKER's
OnAttack, a mandatory multi-target MZCHOOSE (SWUQueueChooseTarget) auto-resolves to nothing and presents NO decision. OnAttackTrigger restores $playerID to its pre-trigger value before MZCountChoices runs, so the count comes back 0 and the choice is silently skipped (the answer never lands — your WHEN's AnswerDecision then mis-feeds the next decision). Use SWUQueueMayChooseTarget (MZMAYCHOOSE) instead — it's the proven in-combat OnAttack choose (JTL_151 Red Five). MZMULTICHOOSE also works in OnAttack (JTL_018 Kazuda's "any number"). Single-target SWUQueueChooseTarget is fine because it emits PASSPARAMETER (auto, no answer). Symptom: a unit-target effect that works fine as a WhenPlayed silently no-ops as an OnAttack, and a P1HASDECISION probe after the attack shows none pending. (Cost the whole JTL_250 Sabine's Masterpiece debug — Vigilance/Command/Aggression branches.) Reference: JTL_250, JTL_151. ⚠ The skip applies ONLY to a decision queued DIRECTLY in the OnAttack closure (because OnAttackTrigger restores $playerID right after the closure returns, before MZCountChoices). A relative-mzID MZCHOOSE queued from a later CONTINUATION (a CUSTOM handler reached mid-combat — e.g. step 2 of a multi-pick flow) is SAFE: ExecuteStaticMethods does NOT restore $playerID around a CUSTOM, so as long as that handler leaves $playerID = the decider, the count is correct. Pattern (JTL_056 Hondo "move an upgrade" On Attack): the FIRST pick is a MZMAYCHOOSE in the closure (fine), and the destination MZCHOOSE is queued from the MOVE_UPGRADE continuation (fine, mandatory MZCHOOSE and all). So you don't have to force every mid-combat pick to MAY/MULTI — only the closure-level one.
- "Take an extra action" (JTL_018 Kazuda) = finish the action via
SWUAfterActionExtra($player) (cleanup + SetSWUVar('PASS','0'), no SWUSwapTurnPlayer) so the same player acts again — vs SWUAfterAction which swaps. Test it by having the player take a SECOND action right after (e.g. attack) and asserting it landed (P2BASEDMG>0); if the turn had swapped, the second action would be illegal.
LOF Phase 12-14 gotchas (folded at the autonomous→pair-programmed retro):
- ⚠ Some units are hard-coded "can't attack" in
BeginSWUAttack — LOF_044 (Loth-Wolf, never), LOF_063 (Oggdo Bogdo, only while damaged), JTL_059 (never). When you need a unit as an attacker fixture (esp. a Creature for "attack with a Creature"), check it isn't one of these — a no-op attack reads as "my handler is broken" (cost a Pounce/LOF_224 iteration). LOF_044's dictionary text only shows "Sentinel" — the can't-attack rule is engine-side, not in the text.
- Event/leader-driven attacks (
BeginSWUAttack from a DQ handler) with noBases=false queue an unanswered target MZCHOOSE whenever both an enemy unit AND the base are legal targets (2+ targets → no auto-resolve). The test must supply AnswerDecision:theirGroundArena-N (or set up exactly one legal target — e.g. noBases=true + one enemy unit — to auto-resolve, like LOF_124). Symptom: handler provably runs (probe shows it) but zero combat happens.
- Leader-action after-action convention: the leader closure exhausts the leader; the
#0 CUSTOM continuation must call SWUAfterAction itself on BOTH the decline and effect paths — EXCEPT when it delegates to something that already owns the after-action: BeginSWUAttack (owns it once it actually attacks → call SWUAfterAction only on the decline branch, mirror JTL_017#0) and DISCOUNT_PLAY_FROM_HAND (owns it via ActivateCard/decline — queue it with NO trailing SWU_AFTER_ACTION). To reuse a universal handler (DEAL_UNIT_DAMAGE etc.) in an action, append a trailing CUSTOM "SWU_AFTER_ACTION" decision (LOF_134/LOF_178 pattern). For a nested play-from-hand inside an action (LOF_016/LOF_018), wrap ActivateCard with $gTurnPlayer/PASS save-restore then call SWUAfterAction once (the inner play's own swap is neutralised — LOF_076 pattern).
$gPlayGrant{TurnEffect,Shield,Exp} entry seams (GameLogic.php, consumed once at unit entry right after $newCardMzID): set before ActivateCard to give the entering unit a phase keyword / Shield / Experience. Composes — LOF_225 "play a unit; Hidden + Exp + Shield" sets all three at once. Add a new seam there for any other "play X and give it Y" grant.
- Leader/Force-cost gates: a leader Action whose cost includes "use the Force" needs an entry in
$leaderActionForceCost (gated in SWULeaderActionAffordable) so it's unavailable without the Force token; the closure then calls UseTheForce(). Resource-cost leaders go in $leaderActionResourceCosts. UseTheForce now bumps a per-phase SWU_FORCE_USED_THIS_PHASE counter (LOF_007 Epic deploy reads it); per-phase attack/play flags follow the SWU_ATTACKED_<TRAIT> / SWU_PLAYED_<X> family (add one in BeginSWUAttack / ActivateCard + clear at RegroupPhaseStart).
- Defer these card classes (need pair-programmed seams, not Hard-tier grind): "an opponent chooses…" cross-player input mid-action (LOF_177, LOF_015), continuous-prevention "can't be defeated / prevent N damage" passives (LOF_043, LOF_220), interactive non-active-player decisions during combat (OnDefense combat-pause — LOF_067/047/252), and on-attack multi-bounce/grant combat seams (LOF_205). Note them with a
⚠ DEFERRED one-liner naming the missing seam; don't burn a long session forcing them.
- DSL/fixture notes:
discardCardIds/deckCardIds/WithP1Force:true are CommonSetup opts; WithP1Deck: lines are top-first (assert P1DECKTOPCARD:); leader tests use explicit P1LeaderBase: LEADER/BASE + SkipPreGame: true and assert P1LEADER:EXHAUSTED/:EPICUSED, P1NOFORCE/P1HASFORCE; a played event sits in its caster's discard so discard-count includes it (don't expect 0 after "return a card from discard"); MZMULTICHOOSE param is min|max|list (2|2|… = exactly two, answer mz0&mz1).
LOF Phase 15-21 gotchas (new-mechanic seams; folded at the end-of-run retro):
- Reactive windows = hook the core helper + queue a YESNO/continuation. "When you use the Force" reactions hook
UseTheForce (_SWUQueueUseForceReactions); "repeat the next When-Played" hooks OnWhenPlayed (mirror of JTL Thrawn's When-Defeated reuse). Guard recursion: the reaction must not re-enter the same hook — LOF_260 re-creates the Force with TheForceIsWithYou (NOT UseTheForce); LOF_197 RemoveGlobalEffects its flag BEFORE re-dispatching; LOF_105's keyword-mirror EXCLUDES other copies of itself.
- Variable-count selects without new client UI = a self-re-queuing continuation. "Exhaust any number with combined power/cost ≤ N" (
_SWUCombinedBudgetOffer + SWU_BUDGET_EXHAUST) and "pay up to N for a per-resource effect" (LOF_255) both loop: do one pick/payment, subtract from the budget, re-SWUQueueMayChooseTarget/YESNO with the reduced budget, stop on decline / empty / budget<0. No MZSPLITASSIGN, no new decision type. Carry the running budget + UID through the handler token (HANDLER|budget|metric|…); re-resolve the unit by UID (SWUFindMzByUID) each round since mzIDs shift.
- Always grep for an existing marker/helper before building a "new" seam. Temporary take-control (LOF_189) reuses
SWUTakeControlOfUnit + the TEMPORARY_STEAL turn-effect marker (SOR_224) — RegroupPhaseStart already returns those to their owner; do NOT reuse JTL_235's SWU_JTL235_RETURN_ (that bounces to HAND). Look-at-opponent-hand + discard (LOF_226) = SWULookAtOpponentHand($p, $filter) + DISCARD_FROM_OPP_HAND + SWUQueueShowOpponentHand (SOR_201). Name-a-card (LOF_204) = the NAMECARD decision type (SOR_185); the answer is the card TITLE string (P1>AnswerDecision:Zeb Orrelios), read it in a CUSTOM continuation (safe vs the OnAttack $playerID-restore gotcha).
- Cross-player "opponent chooses/decides" DOES work now — stop blanket-deferring it. Queue the decision for the opponent (
AddDecision($opp, "YESNO"/"MZCHOOSE", …)) from a CUSTOM continuation (NOT inline from a trigger closure — DispatchTrigger/OnAttackTrigger restore $playerID, the CUSTOM path doesn't); encode the caster in the handler token, set $playerID to whichever player owns the next step. The test answers as that player (P2>AnswerDecision:NO works even under P1OnlyActions). SWUOpponentChoosesOwnUnit($caster, $nonLeader, $tooltip, $handler) is the ready-made "opponent picks one of THEIR units" seam (GameLogic.php). LOF_222 proves the YESNO form; this un-blocks the old LOF_177/LOF_015 "opponent chooses" deferrals.
- A non-pilot upgrade's When-Played ability receives the HOST as
$mzID. CollectWhenPlayedAsUpgradeTriggers routes a HasWhenPlayedAbility upgrade through the WhenPlayed window with mzID = the host unit, so register $whenPlayedAbilities["UP:0"] and read the host via GetZoneObject($mzID) (e.g. CardTitle(...) === 'Qui-Gon Jinn' for LOF_201's "if attached unit is X").
- Doubling a VALUE keyword (LOF_186 "Raid is doubled") must double the GRAND total, not the conditional slice.
GetConditionalKeyword_Raid_Value only contributes the conditional part; the generated GetKeyword_Raid_Value = base_max + conditional. To make the final 2×, have the conditional function add (base_max + amount) — recomputing base_max with the same max-branch logic (max(printed, TurnEffectValue, granted?1)). Keeps it in hand-editable KeywordEffects.php (no generated-file/generator edit).
- "Loses all abilities for this round" reuses the LostAbilities token system: register the source CardID in
$turnEffectRegistry as ['kind'=>'LOSE_ABILITIES'], add it to the LostAbilities() check in KeywordEffects.php, and AddTurnEffect($mz, 'CARD'). Test via an innate-keyword fixture (SOR_063 Sentinel) + P{n}{...}ARENAUNIT:idx:NOTKEYWORD:Sentinel.
- Starting-hand-size bases (JTL_021/028) live in
CreateGame.php::QueuePregameSetup via shared helpers (SWUStartingHandModifier/SWUBaseSuppressesMulligan); the harness bypasses pregame (Option B) so extend its _buildInitialState drew-count to call the SAME helper, then test through the non-SkipPreGame flow (P{n}HANDCOUNT:X). Mulligan-suppression isn't exercisable there (Option B doesn't simulate the mulligan DQ) — verify by inspection.
- Generator quote-guard for dual own+granted triggers: a card whose text has BOTH an own and a granted (quoted)
"On Attack:" (e.g. JTL_018's deploy side gains: "On Attack:...") was wrongly excluded by strpos($combined,'"On Attack:')===false. The generator now uses preg_match('/(?<!")On Attack:/', $combined) (an UNQUOTED occurrence). Same fix would apply to the When-Defeated/On-Defense quote-guards if a dual card surfaces there; hand-add the case to the stub file too (the generator isn't re-run mid-session).
Set-validation gap-fix lessons (folded card-by-card while clearing the LOF deferral backlog):
- A leader's DEPLOYED side has its own abilities in
$deployTextData — Method-B catches these as "stub w/o handler" even when the leader-side Action is done. The deployed unit's On Attack just needs $onAttackAbilities["LEADER:0"] (same registry as any unit; the deployed leader's CardID is the key). Use MZMAYCHOOSE for multi-target picks (the OnAttack mandatory-MZCHOOSE skip), and combat owns the after-action (no SWUAfterAction). Test by P1>DeployLeader (free — threshold only, resources persist) then P1>AttackGroundArena, with WithInitiativePlayer:2 + WithInitiativeClaimed:true so P1 acts freely. ⚠ A deployed "defender gets −X/−0" (LOF_014) must add SWU_DEF_DEBUFF_N synchronously in ExecuteSWUAttack (like SOR_212), NOT via the deferred OnAttack trigger — SWUCombatDamage reads/consumes the marker before the trigger fires. ⚠ A deployed leader with Shielded masks its own counter-damage observably (the shield absorbs the whole counter regardless of size — SWUConsumeShieldToken has no >0 guard), so a −X/−0 debuff is unobservable on it; verify via the leader-side/SOR_212 test instead.
- "Play those from your discard for FREE this phase" is NOT Hard — it already exists. Discard the card with the
TPF modifier: SWUAddToDiscard($p, $cid, 'DECK'|'PLAY', 'TPF'). TPF = "this-phase free play-from-discard" (cleared by SWUClearDiscardModifiers at the phase turn; TPP = play-at-cost; OTPF/OTPP = from an opponent's discard). The player then uses the existing PlayFromDiscard action (DSL: P{n}>PlayFromDiscard:liveIdx). Combined-cost search = _topDeckSearchBegin($p, $n, $filter, "cost:N", $finalize); the finalize can server-side-validate the budget (greedy keep-while-runningCost ≤ N) before discarding the kept and _topDeckPutRemainingToBottom for the rest. (LOF_117 Sifo-Dyas: was deferred "Hard"; the whole thing is ~15 lines.) Lesson: before deferring a card as Hard, grep for the affordance — TPF/Modifier, PlayFromDiscard, SWUPlayDiscardUnitDiscounted, _topDeckSearchBegin — the seam is often already built.
- Per-source/per-target continuous aura (LOF_191 "chosen unit gets +1/+0 + Saboteur while this in play"): link source→target with a global effect
SWU_<CARD>_{srcUID}_{tgtUID} (mirror JTL_047's SWU_YULAREN_{uid}_{kw}). A _SWU…HasBuff($obj) helper loops the controller's in-play SOURCE cards and checks the link to $obj's UID — so the buff ends automatically when the source leaves play (loop finds no source); no leave-play cleanup needed (UIDs never repeat). Hook it in ObjectCurrentPower (stat) and the relevant HasConditionalKeyword_X (keyword).
- ⚠ A unit's When-Played that offers HAND cards must
DecisionQueueController::CleanupRemovedCards() BEFORE building the myHand-N list. The just-played unit is still in the hand array (removed flag) when its When-Played fires, and it's cleaned up before the player answers — so an offered myHand-N index shifts and the chosen mz resolves to NULL at handler time (symptom: the effect silently no-ops; probe shows up=NULL). Compact first, then index. (LOF_150 Cin Drallig.)
IBH Phase 1-9 lessons (autonomous set; folded at the end-of-run retro):
- ⚠ An EVENT is still physically in the caster's hand during
OnPlayEvent — it isn't discarded until FINISH_PLAY_CARD (block 10), which runs AFTER the block-1 effect. So a "discard / put / choose a card from your hand" event (IBH_074 I Want Proof) sees ITSELF in ZoneSearch("myHand") and would wrongly offer it. Exclude one instance of the playing $cardID from the targets (foreach $hand … if (!$excluded && CardID===$cardID) {$excluded=true; continue;}). (Contrast LOF_150: a unit's When-Played hand list needs CleanupRemovedCards first; an event's does not — the event lingers by design.)
- "On Attack: deal N to a base" → deal to the ENEMY base directly (
SWUDealDamageToBase(N, OtherPlayer($p))), NOT a 2-base MZCHOOSE. A DEAL_BASE_DAMAGE choice queued from an OnAttack continuation survives ONLY when there's a combat pause (the attacker hit a unit); when the attacker hits the base directly there's no pause, so the OnAttack $playerID-restore drops the mandatory pick and the rider silently no-ops (base takes combat only). Enemy-base is the only meaningful target for an attacker anyway (IBH_006 Y-Wing, IBH_053 Vader deployed; mirror LOF_163).
- Test a unit's WhenDefeated by having IT attack into lethal (attacker self-defeat), NOT by an enemy killing it. A DEFENDER defeated in cross-player combat leaves its
RESOLVE_TRIGGER|WhenDefeated|… pending in the regression (the active player is the attacker, so the defender's queue isn't flushed before EXPECT) → the effect reads as not-firing. Drive it as P1OnlyActions + the IBH unit AttackGroundArena:0:… into a bigger body so it dies to the counter; P1 is active and the WhenDefeated resolves inline (IBH_015 Tauntaun, IBH_082 Ozzel). Pair with the existing skill #9 note (WhenDefeated collects after cleanup → survivors reindex).
- To force a base attack with enemy units on the board, the WHEN target token is
BASE — AttackGroundArena:0:BASE — NOT :theirBase-0 (which, with enemy units present, is ignored and the unit attacks an enemy instead). (Reconfirmed across IBH OnAttack tests; already in the GIVEN/DSL notes.) ⚠ Likewise the UNIT target is a bare INDEX, not a full mzID: AttackGroundArena:0:1 attacks the defender at idx 1. Writing :theirGroundArena-1 intval()s to 0 → it silently hits idx 0 (the WRONG unit), and the attack still "works" so only a value mismatch reveals it. The parser only special-cases BASE / S<n> (cross-arena space) / G<n> (cross-arena ground); everything else is intval'd to a same-arena index. Assert …UNIT:<idx>:CARDID:<id> at the attacked index so a mis-resolved target reds the test loudly. (Cost 2 debug cycles on ASH_062 + the Grogu tests — the -N suffix looked like an index but parsed to 0.)
- Heavy intra-set reprints wire identically: group duplicate CardIDs (same name/effect) into one batch, register them on the same closure (
$X["IBH_006:0"] = $X["IBH_024:0"] = $X["IBH_032:0"] = fn), and write one full behavioral test for the canonical + a one-line reprint guard per duplicate. ~30 IBH needs-work IDs collapsed to ~27 unique effects this way.
- Force an upgrade onto a SPECIFIC host for free (LOF_150 "play a Lightsaber on this unit for free"):
_SWUFinalizeUpgradeAttach($p, $upgradeCardID, $upgradeHandMz, $hostMz, 0, ignoreCost:true, isPilot:false) — host is forced (no choice), cost ignored, and it still fires the upgrade's own whenPlayedAsUpgrade. To honor a host restriction anyway, filter the offer with in_array($hostMz, SWUGetUpgradeValidTargets($p, $upgradeCardID), true).
- "Deals combat damage = X instead of its power" for one attack (LOF_206 "damage equal to its remaining HP instead of power"): register an attack-duration MARKER in
$turnEffectRegistry ('SWU_HP_AS_DAMAGE' => ['kind'=>'MARKER','duration'=>SWU_DUR_ATTACK,…]), AddTurnEffect($attackerMz, 'SWU_HP_AS_DAMAGE') in the handler right before BeginSWUAttack, then in SWUCombatDamage just after $attackPower = ObjectCurrentPower(...) + Raid, override $attackPower when in_array('…', $attacker->TurnEffects). Marker auto-expires at attack-end via SWUExpireTurnEffects(SWU_DUR_ATTACK) — no cleanup. "Remaining HP" = ObjectCurrentHP - Damage, measured at damage-deal (pre-counter; combat damage is simultaneous). For a "granted attack with a friendly X unit" action, clone JTL_146 (scan both arenas for ready trait-X units → SWUQueueChooseTarget → continuation does the marker + BeginSWUAttack; combat owns SWUAfterAction).
- Per-instance trait suppression — "each enemy unit loses the trait this phase" (LOF_033 Nameless Terror On Attack):
HasTrait is CardID-keyed (static dictionary) so it CANNOT do per-instance. Use the existing object-aware TraitContains($obj, $trait) (in GameLogic, next to HasTrait — it already returns false when in_array('NO_TRAIT_'.strtoupper($trait), $obj->TurnEffects), and otherwise honors upgrade grants / HasTrait($obj->CardID, $trait); _SWUUnitHasTrait was the old name for this and is deleted — don't re-add it). Register NO_TRAIT_FORCE as a phase-duration MARKER (['kind'=>'MARKER','label'=>…] — phase is the registry default). The handler snapshots the affected units in play now (ZoneSearch('theirGroundArena'/'theirSpaceArena')) and AddTurnEffect($mz, 'NO_TRAIT_FORCE') each — units entering later this phase are NOT marked (it's a per-instance marker, not a continuous aura). General rule the user gave: any "units lose/gain X this phase" effect counts only the units in play when it resolves. Then route the trait CONSUMERS: because the fallback is identical until a marker exists (and the marker only ever lands on specific units), you can safely replace_all every object read HasTrait($obj->CardID…, 'Force') → TraitContains($obj, 'Force') across files (~25 sites). ⚠ NEVER route a bare-CardID read (HasTrait($cardID/$c/$cid, …)) — the helper would GetZoneObject(a CardID string) → null → wrong; those are hand/deck/play-time reads that correctly stay HasTrait (trait-loss is in-play-only).
- "Return a unit to its owner's hand; then its owner may play it for free" — cross-player bounce + free-replay (LOF_185 Baylan Skoll):
SWUBounceUnit($player, $mz) returns the unit to $obj->Owner's hand (APPENDED — defeats its upgrades, rescues captives, returns bool). The replayed card is therefore the LAST in the owner's hand: $idx = count(GetHand($owner)) - 1. Hand the OWNER the optional free play (LOF_015 cross-player pattern): set $playerID = $owner, AddDecision($owner,'YESNO','-',1) + AddDecision($owner,'CUSTOM',"H|myHand-{$idx}",1) — myHand-{idx} resolves to the owner because the continuation runs with $player=$owner (works even when owner == opponent). The free play itself is ActivateCard($owner, $handMz, true) wrapped in the JTL_089#1 turn/PASS save-restore ($savedTP=$gTurnPlayer; $savedPass=GetSWUVar('PASS','0'); …; $gTurnPlayer=$savedTP; SetSWUVar('PASS',$savedPass)) so the nested play doesn't double-advance the outer action; When-Played fires on this path. Assert a fresh-copy tell (e.g. pre-damage the unit, expect DAMAGE:0 after replay). ⚠ Test gotcha: a double-pip OFF-aspect card (LOF_185 = Cunning,Villainy) costs +2 per off-aspect pip — if the test's myResources can't cover the penalty the play silently fails and the When-Played never fires (probe shows nothing). Bump resources or match the CommonSetup aspects.
- Source-conditional damage prevention — "if a friendly would deal damage to a friendly unit, prevent it" (LOF_108 Malakili, the Bendu combo): the ability-damage funnel
SWUDealDamageToUnit($unitMz, $amount, $player) does NOT know the source card, so it gained an optional 4th param ?string $sourceMzID = null. AoE/multi-unit damage handlers pass their own mz as the source (e.g. Bendu LOF_170: SWUDealDamageToUnit($mz, 3, $player, $mzID)); the funnel then runs _SWULof108PreventsCreatureDamage($sourceMzID, $unit) and returns early (no Damage/anim/defeat) when the source has the gating trait (Creature), source.Controller == target.Controller (friendly→friendly), and that controller has the source card (LOF_108) in GetField. Only callers that PASS source get checked — fine here because combat damage is a separate path and friendly units can't attack friendly units (user-confirmed), so the ability funnel is the only relevant case. Default-null keeps all other callers unchanged.
- Combat-pause: a DEFENDER's On Defense reaction that must resolve BEFORE combat damage (LOF_047 give-Exp, LOF_067 Force→attacker -2/-0 — "when this unit is attacked, before damage is dealt"). The On Defense seam already exists (
HasOnDefenseAbility stub → $onDefenseAbilities["X:0"], dispatched under the defender's controller). BUT historically the defender's reaction decision (a non-active-player YESNO) raced and lost: ExecuteStaticMethods drains ONE player's queue fully, so the active player's block-20 SWU_TRIGGER_RESUME committed SWUCombatDamage before the defender's block-1 YESNO was ever processed. The fix (in GameLogic, generic for the whole cluster): (1) OnDefenseTrigger sets SWU_PENDING_DEF_REACTION='1' after dispatching (only true On Defense triggers — On-Attack opponent decisions like indirect-damage/Watto do NOT set it, so their old timing is preserved); (2) the SWU_TRIGGER_RESUME empty-stack COMBAT branch, when that flag is set AND _SWUPlayerHasBlockingDecision($other) (the non-active player still has a non-static/input decision), hops the resume onto the DEFENDER's queue instead of committing damage — so combat waits until the reaction resolves; (3) the commit queues SWUCombatDamage|aMz|tMz|uid|{activePlayer} onto the CURRENT drain's $player (not always the active player) and the handler re-derives the attacker frame from parts[3], so damage runs in whichever drain commits it (the defender's, when paused) without stranding. Flag is cleared at attack start (ExecuteSWUAttack) and on commit. To ADD an On Defense card now, just register $onDefenseAbilities["X:0"] (+ hand-add the case to HasOnDefenseAbility if the generator hasn't been re-run) — the pause is automatic. Test: defender reacts (P2>AnswerDecision:YES) and the effect (Exp/+HP, attacker debuff) is reflected in the SAME attack's damage/counter numbers.
- Base-damage reaction — "when damage is dealt to your base: …" (LOF_252 The Daughter, may-use-Force → heal 2). Hook a collector in
SWUDealDamageToBase (CombatLogic) right after the base's Damage is incremented — this is the ONE central base-damage point (combat line ~890, Overwhelm overflow ~1041, and OnDamageBase for effect damage all route through it). The reaction is owned by the BASE OWNER ($targetPlayer) — often the non-active player in combat — but it is POST-damage, so NO combat-pause is needed: it sits on their queue and resolves after the damage event (contrast the pre-damage On Defense cluster). Guard with $damage > 0 && base.Damage < CardHp(base) (skip if the base was just defeated). SWUQueueMayUseTheForce($targetPlayer, …) no-ops when they don't hold the Force; the handler heals via OnHealBase($p, $p, 2). Test: attack the base (P1>AttackGroundArena:0:BASE), then P2>AnswerDecision:YES — assert net P2BASEDMG = dealt − healed.
- "When you draw THIS card during the action phase: …" (LOF_148). Hook in
DoDrawCard after it builds $drawn (the drawn cards' hand mzIDs). _SWUOnPlayerDrew($p, $count) already exists but only gets a COUNT (for "any draw" reactions like JTL_111) — for "draw this specific card" you need the identities, so add a parallel _SWUOnDrawLof148($p, $drawn) that scans $drawn for the CardID. Gate on GetCurrentPhase() === 'MAIN' (the action phase; the regroup draw is a different phase) plus the card's condition. Leader/base aspect condition ("control an Aggression leader or base"): iterate [GetLeader($p), GetBase($p)] and test strpos(CardAspect($c->CardID), 'Aggression') !== false. "deal 2 to a unit and 2 to a base" = the JTL_010#0 chain: SWUQueueChooseTarget($p, units, …, "H#1") → handler deals via SWUDealDamageToUnit($lastDecision, 2, $p) then SWUQueueChooseTarget($p, ['myBase-0','theirBase-0'], …, "DEAL_BASE_DAMAGE|2"). Test it by playing a "When Played: Draw a card" unit (SOR_111) with the card seeded on top via WithP1Deck: LOF_148 (first deck entry = top) while on an Aggression CommonSetup (rrk/… gives P1 an Aggression base+leader).
ASH Phase 7-8 lessons (deck/hand + bounce/control/targeted-defeat; folded at the autonomous→pair-programmed retro):
- ⚠ Multi-card
WithP1Deck/WithP2Deck use BRACKET-space syntax, NOT comma: WithP1Deck: [SOR_095 SOR_046] (space-separated inside [ ]). A comma list SOR_095,SOR_046 is parsed as ONE invalid CardID → deck size 1 → a "draw 2" lands only 1 and the test is silently off by a card (cost a cycle on ASH_185). Single card stays bare (WithP1Deck: SOR_095).
- ⚠
SWUDiscardCards($player, N) makes the OPPONENT discard, and with >1 card in their hand it queues an OPPONENT choice that does NOT auto-resolve under P1OnlyActions — the discard never completes and the test reads as "the effect didn't fire" (ASH_162 opp-discards-on-base-hit). Seed the opponent's hand to exactly 1 card (theirHandCardIds:SOR_095) so the discard auto-resolves, or drive the opponent's pick explicitly. Same shape as IBH_082 ("auto when they hold exactly 1").
- ⚠
SWUQueueDefeatUpgrade(..., may:true, min:0) (the "may" path) stages a SECOND myTempZone-N pick even when the host has a single matching upgrade — it does NOT auto-defeat. Tests must answer BOTH the host pick AND myTempZone-0 (ASH_165 cost a cycle). Only min:1 auto-defeats a lone upgrade (one answer). Friendly-scoped defeat ("defeat a FRIENDLY upgrade" — ASH_171/ASH_246): SWUQueueDefeatUpgrade/SWUGetUnitsWithUpgrades span BOTH sides (the filter is upgrade-property only), so collect friendly hosts yourself, StoreVariable("DefeatUpgParams","1|1|") + StoreVariable("DefeatUpgThen", "<then>"), queue the host pick (PASSPARAMETER if 1, else MZCHOOSE/MZMAYCHOOSE) + AddDecision(CUSTOM,"DEFEAT_UPGRADE"). min=1 → a lone upgrade auto-defeats (no temp-zone answer) and the DefeatUpgThen continuation fires after the defeat (gets the host mzID; pass extra state via a separate DQ variable, e.g. ASH_171 stores ASH171SelfUID to ready the just-played unit).
- An UPGRADE's "When Played" fires via the
CollectWhenPlayedAsUpgradeTriggers FALLBACK (when the card has only HasWhenPlayedAbility, no WhenPlayedAsUpgrade): the closure $whenPlayedAbilities["X:0"]($player, $mzID) receives $mzID = the HOST unit's mz, not the upgrade. Use it to read the host's other upgrades (ASH_199 "return any number of OTHER upgrades on attached unit" — stage them in TempZone for an MZMULTICHOOSE, exclude the card's own CardID + tokens, SWUReturnUpgradeToHand($hostMz, $cid, $player) each). In a test, playing an upgrade from hand auto-attaches when there is exactly ONE valid host (no host-choice decision).
- Debug trick — surfacing a computed value through the regression:
AddGameLogEntry($type, $text, $visibility) — the message must go in arg 2 ($text); LOGCONTAINS:/LASTLOGCONTAINS: match the log entry's TEXT (parts[2]), so a message put in arg 1 ($type) is never found. To PRINT a computed value, log it as text then add LASTLOGCONTAINS:ZZ_NOMATCH — the failure message echoes the actual last-log text (e.g. 'ASH163DBG cost=2 tgcount=0 ld=myHand-1', which revealed a wrong fixture, not a code bug).
- A "deal N to / affect a unit costing MORE/LESS than X" filter that silently finds no target → suspect the FIXTURE's cost FIRST, not the handler (ASH_163: SEC_080 is cost 2, equal-not-greater, so "costs more than the discarded 2-cost card" correctly excluded it — I'd assumed cost 3). Re-confirms the per-fixture stat rule, sharpened for cost-threshold filters: verify the candidate's
costData before concluding the comparison logic is broken. Note _SWUIsUpgraded($obj) is the canonical "is this unit upgraded?" predicate; played units enter exhausted (Status 0), so "ready this unit" on play is meaningful.
- Zone-gated unit Action with a "use the Force" cost + arena move + can't-ready (LOF_098 — while in the SPACE arena: "Action [use the Force]: move to the ground arena and give each friendly Heroism unit +2/+2 this phase"). (1) Cost: register
$unitAbilities["X"] (auto-detected as a provider by SWUGetUnitActionProvider) and set $unitActionCostKind["X"] = 'none' — no exhaust, no ready requirement (right when the unit is meant to act while exhausted). The handler pays with UseTheForce($p). (2) Availability/zone-gating goes in SWUUnitActionAffordable (case 'X': if (!PlayerHasTheForce($p) || strpos($mzID,'SpaceArena')===false) $ok=false;). (3) Arena move: SWUMoveUnitBetweenArenas($mz, 'GroundArena') preserves damage/upgrades/UID and returns the new mz. (4) AoE aspect buff: loop friendly units, strpos(CardAspect($c), 'Heroism'), AddTurnEffect($mz, SWUMakeTurnEffect('SWUBUFF',[2,2],SWU_DUR_PHASE)) — "friendly" (not "another friendly") INCLUDES self, so move first then buff. (5) "While in the space arena, can't ready" is continuous (not a consumed SWU_CANT_READY flag): block BOTH the ReadyPhase SPACE loop (regroup, sets Status directly) AND OnReadyCard (explicit "Ready a unit" effects) with a CardID === 'X' && in-space check. Per the user: this blocks effects that say "ready a unit" but NOT "enters play ready" (a separate entry path that never calls these) — so hooking the ready FUNCTIONS is exactly the right granularity.
SHD Phase 3-12 lessons (upgrades/passives/deck-search + two-sided leaders; folded at the autonomous→pair-programmed retro):
- ⚠ A "while attacking a UNIT" combat conditional must exclude the base explicitly —
$target is NON-null for base attacks. GetZoneObject("theirBase-0") returns the base object, so a check like $target !== null && empty($target->removed) is TRUE when attacking a base too. Gate combat-time "vs unit" buffs/keyword-grants with strpos((string)$targetMzID, 'Base') === false as well (cost the SHD_007 Moff Gideon "+1 while attacking a unit" a wrong base-attack buff). The existing SHD_138 $shd138VsBounty only avoids this by accident (a base has no Bounty).
- ⚠ Leader-front "play a unit from your hand" tests: an UNDEPLOYED leader's aspects do NOT reduce the played unit's aspect penalty. The player's aspects for cost = the BASE (+ a deployed leader unit), NOT the undeployed leader card. So a unit that's on-aspect to the leader but off-aspect to the base gets the full +2/+4 penalty → the leader-action's affordability filter silently finds 0 valid units and the action reads as "nothing happened." Pick a fixture on-aspect to the CommonSetup base letter, or pad resources to cover the penalty (and note the discount is proven by "affordable only because of the −1"). This is invisible because the schema runner's
useLeaderAbility wraps the call in ob_start()/ob_end_clean() — it SWALLOWS all output AND PHP warnings, so a silent no-op inside a leader Action shows nothing. Debug by dropping an error_log("...") in the offer/handler and running with php -d display_errors=stderr … 2>&1 | grep PROBE.
- Epic deploy is fully GENERIC — no per-leader wiring.
SWUDeployLeader gates on SWUResourceCount($player) < intval(CardCost($cardID)), i.e. the deploy threshold IS the leader's printed cost (SHD_001 cost 6 = "6+ resources", etc.). Just implement the front + deployed abilities; the "Epic Action: if you control N resources, deploy" needs zero code. Deployed-side dispatch map: On Attack → $onAttackAbilities["CID:0"] (combat keys on the attacker's CardID = the leader's); When Deployed → $whenPlayedAbilities["CID:0"]; deployed Action: → $unitAbilities["CID"] + $unitActionCostKind["CID"] (+$leaderActionResourceCosts for the FRONT resource cost); deployed passive → ObjectCurrentPower/HP (leader-presence buffs mirror SOR_001: scan GetLeader($controller) — works undeployed AND deployed since the leader entry persists) or a keyword registry (deployed Restore/Overwhelm/Saboteur/Grit auto-fire from the generated *_Cards lists). Test a leader with myLeader:CARDID + SkipPreGame:true; front = UseLeaderAbility; deployed = DeployLeader then AttackGroundArena:<deployed-idx> / UseUnitAbility:myGroundArena-<deployed-idx> (the deployed leader lands at the NEXT arena index after any pre-placed units).
- "Play a unit from hand then act on THE PLAYED unit" (deal damage / capture / grant Ambush) = the
$gPlayGrantTurnEffect findable-marker pattern (SEC_018). Set global $gPlayGrantTurnEffect; $gPlayGrantTurnEffect = 'MARKER'; then ActivateCard($player, $handMz, false, $discount) (save/restore $gTurnPlayer + the PASS SWUVar around it), null the marker, then scan all arenas for the unit whose TurnEffects contains 'MARKER' — that's the just-played unit. Reuse 'SEC_007' as the marker to grant the played unit Ambush this phase.
- ⚠ Reactive "When you play a [Smuggle/Underworld/keyword/upgrade] card" LEADER fronts belong to the reactive-trigger subsystem, not Phase 12.
SWUCollectOwnPlayReactions scans deployed UNIT observers only (GetUnitsInPlay), NOT undeployed leaders — so an undeployed-leader "when you play X" reaction has no hook. Defer these leaders (SHD_005/008/010/014/018) to the reactive-trigger phase rather than half-wiring them.
- Test-assertion gotchas: (1)
UPGRADECOUNT counts shield tokens (SOR_T02 are Subcards) — after granting a shield, UPGRADECOUNT = upgrades + shields; assert SHIELDCOUNT for the shield and don't expect UPGRADECOUNT:1. (2) A top-deck search "choose none" is an EMPTY AnswerDecision: (blank), NOT PASS — answering TOPDECKSEARCH with PASS drains the peeked cards OUT of the deck (they're lost, deck shrinks); the no-pick convention is a blank answer (cf. PrepareForTakeoff_SearchTop8_ChooseNoneof1). (3) myBaseDamage:N / theirBaseDamage:N are the CommonSetup opts to pre-damage a base (for "15+ damage on base" gates etc.). (4) "Put into play as a resource" enters EXHAUSTED (SWURampResourceExhausted / AddResources(...,Status:0)); only explicit "…and ready it" wording uses SWURampResourceReady.
SHD Phase 13-14 lessons (the reactive-trigger subsystem + control/modal/alt-win; folded at the end-of-run retro):
- Reactive "when X happens" observers already have hooks — extend the collector, don't build new plumbing.
SWUCollectOwnPlayReactions ("when YOU play a card/unit/event/upgrade" — add a $cid === 'X' case in the deployed-unit-observer loop), SWUCollectOpponentPlayReactions ("when an OPPONENT plays a card" — but its early return was TWI_210-specific; move any cost-condition INSIDE its block so an unconditional observer isn't skipped), CollectWhenPlayedAsUpgradeTriggers (field observer "when you play an upgrade on a unit" — check _SWUCountActiveUnitsWithCardID and carry the host UID), SWUCollectLeavePlayReactions ("when an [enemy/friendly] unit is defeated/leaves play" — add a $d-loop case; needs $d['upgraded'] etc. captured at the defeat-entry sites BEFORE SWUDiscardHostSubcards), SWUCollectCombatHitTriggers ("this unit deals combat damage to a base/unit" — switch on the attacker CardID gated on $combatCtx['dealtToBase']/['dealtToUnit']), _SWUOnUnitDamaged($obj,$amount,$isCombat) ("a unit is dealt [combat] damage and SURVIVES" — self-observer in its switch, or a _SWUShdXXXCheckObserve field observer). The base-attack observer is inline at the strpos($targetMzID,'Base') point in ExecuteSWUAttack (next to ASH_160). Control exchange = LAW_170's SWUTakeControlOfUnit (twice); modal "an opponent chooses one" = LAW_080 (OPTIONCHOOSE queued for OtherPlayer, branch in the #0 CUSTOM).
- ⚠ TWO subsystem gaps in the reactive flush — interactive decisions don't always drain. (1) An interactive YESNO/choose queued in the LEAVE-PLAY flush (defeat observer) does NOT drain on the defender-defeat/opponent-observer path — mandatory auto-resolving choices work, but a "may" that needs an answer is orphaned. For a benefit-only "may" (e.g. SHD_137 "you may ready this unit") use the SOR_015 auto-resolve precedent: resolve it inline as always-yes, but only when there IS a benefit (e.g. only ready an EXHAUSTED unit) so the once/round isn't wasted. (2)
A cross-player interactive target-choose over the ENEMY's board does NOT resolve — THIS WAS A MISDIAGNOSIS (corrected session 70). It works exactly like TWI_210 (CunningOpponentPlayedReaction), which queues an interactive OPTIONCHOOSE→unit-choose over ALL units (both boards) and drains fine via FlushEntryTriggerBag. The recipe (SHD_172 Krayt): SWUCollectOpponentPlayReactions AddTrigger($opp, …) → DispatchTrigger → a reaction fn that queues an intermediate CUSTOM (CardID#0) → that continuation (which ExecuteStaticMethods does NOT $playerID-restore) builds the target list under $playerID=reactor + queues the MZMAYCHOOSE. ⚠ Test-drive note: a cross-player single reaction needs an extra AnswerDecision:EffectStack-0 step (the RESOLVE_NEXT_TRIGGER orchestration) BEFORE the target answer — cf. TWI_210's arbitrary-answer first step. ⚠⚠ Real pre-existing engine bug this exposed: two IDENTICAL reactive triggers (a NON-UNIQUE reactive card, 2 copies) hang the EffectStack flush — never hit before because all prior reactive cards were unique. Workaround: add ONE trigger carrying a cost~count payload and LOOP the effect once per copy (CR-equivalent for identical triggers), instead of N identical AddTriggers (SHD_172). A proper EffectStack duplicate-handling fix is still owed. So: an opponent-turn enemy-target interactive reaction is BUILDABLE — do NOT defer it.
- Two central-function extensions were needed and are safe (guard tightly, full-regression after):
_SWUOnUnitDamaged gained a bool $isCombat param (combat call sites pass true, the effect-damage site stays false) so "when dealt COMBAT damage" observers (SHD_084/250) don't fire on effect damage; defeat-entry arrays gained 'upgraded' => _SWUIsUpgraded($obj) at all three sites (two combat + SWUDefeatUnit) for "when an UPGRADED enemy is defeated" (SHD_137). A "when a player discards from hand" observer must hook BOTH DoDiscardCard (self-chosen discards, via MZMove) AND SWUAddToDiscard when $from==='HAND' (forced discards — SWUDiscardCards/DISCARD_FROM_OWN_HAND bypass DoDiscardCard); they're disjoint so no double-fire, and a once/round guard in the dispatch case dedupes multi-card discards.
- Once/round "may" reactions: consume the marker on USE (in the reaction handler when the effect resolves), NOT on trigger — so declining doesn't waste the round's use, and a later qualifying event can re-trigger. The observer's
GlobalEffectCount(...'_USED') <= 0 gate then reflects actual use. (Clear every SWU_SHDxxx_USED at RegroupPhaseStart next to the ASH_128/ASH_032 clears.)
DoCaptureUnit($player, $captorMz, $targetMz) takes the captor as a mzID STRING, not the object (cf. SHD_124/232 — SWUFindMzByUID returns a mzID string; don't pass GetZoneObject(...)). Opponent-play-reaction tests need the TWI_210 turn setup: WithActivePlayer:1 + WithInitiativePlayer:1, then P1>Pass BEFORE P2>PlayHand:0, then P1>AnswerDecision:…; and remember the played card's aspect penalty for the OPPONENT (an off-aspect card needs printed cost + penalty in theirResources, or it silently fails to play and the reaction never fires).
Use the dictionary's $nameData name verbatim in comments and test filenames — the CardID is the source of truth, the printed name comes from $nameData, never from memory or an inherited comment. A wrong name propagates fast and silently: SOR_139 is Force Choke, but had been labeled the non-existent "Swift Strike" across its comments, three code files, two test filenames, a doc, and memory. Registry/handler keys are CardID-keyed so a misnomer is only cosmetic — but it misleads every future reader, so get the name right once, here.
The dictionary is authoritative for stats over any prose doc (e.g. sor-implement.md). A doc once listed Protector (SOR_057) as +0/+2 while upgradePowerData/upgradeHpData said +1/+1 — trusting the doc produced two wrong test expectations. SOR_049 Obi-Wan is 4/6 in the dictionary but "3/6" in the prose doc — seeding a POWER expectation from the doc reddened two tests. Always derive expected combat/heal numbers from the array lookups — for EVERY unit in the test, including the ability's own subject/chosen unit, not just the targets. It's easy to rigorously look up the targets while eyeballing the "incidental" fixture (the unit being buffed, the leader, the attacker); that incidental unit is exactly where a doc-vs-dictionary stat drift slips a wrong expected number through. (Reminder, see 3c-stats: combat lethality uses ObjectCurrentHP, so an upgrade's/buff's +HP DOES keep a unit alive in combat; and a Sentinel unit force-redirects an attack onto itself rather than rejecting it as a no-op.)
The dictionary uses PHP $name = array ( ... ); syntax with a leading-space indent — so the anchored /^\$costData /,/^\];/ form matches nothing and returns empty. Match = array \( … ^\); as above. If a section ever comes back empty, first confirm the array headers with grep -nE "= array \(" … and slice by line range (awk "NR>=START && NR<=END").
This is critical — power and HP are easy to swap when reading combined grep output. Always use the array-specific lookup. When extracting the bare number, strip the SET_NNN digits first (e.g. grep -oE '=> [0-9]+') — a naive grep -oE '[0-9]+' also captures the card number ('SOR_049' => 4 → 049 and 4).
Leaders are double-sided — read BOTH ability arrays. A Leader's textData holds only the leader-side text (the action ability + the "Epic Action: deploy" line). The deployed Leader Unit's abilities (On Attack, etc.) live in a separate array, deployTextData. Implementing only the textData side silently misses half the card (this bit SOR_017 Han Solo, whose ramp-from-deck On Attack is deploy-side only). The leader's unit-side power/HP share the normal powerData/hpData arrays.
awk '/\$deployTextData = array \(/,/^\);/' SWUSim/GeneratedCode/GeneratedCardDictionaries.php | grep "'CARD_ID'"
Collect for each card: name, ability text ($textData, plus $deployTextData for leaders), type, cost, power, HP, arena, aspects, traits, unique flag.
Before deciding a card "needs new infrastructure" (or splitting it out as harder-than-Simple), verify the engine doesn't already support the mechanism. Twice, cards were over-tiered for missing infra the engine already had: bases ARE valid MZCHOOSE targets via myBase-0/theirBase-0 (MZZoneCount→GetZone accepts any zone name — units, bases, resources, hand, deck), and DQ variables persist across the request boundary (StoreVariable writes the DecisionQueueVariables gamestate zone). Grep the actual primitive (MZZoneCount, GetZone, GetKeyword_*_Value which reads granted keyword values via SWUTurnEffectKeywordValue, SWUApplyPhaseBuff/Debuff, the $turnEffectRegistry CardID-token convention) before assuming it's absent.
Then for each card identify:
- Trigger type: Epic Action / WhenPlayed / OnAttack / WhenDefeated / passive / etc.
- Effect: what it does (damage, shield, draw, exhaust, discard, etc.)
- Target restriction: "non-leader unit", "friendly", "enemy base", "Vehicle", etc.
- Whether it requires player input (choice = MZCHOOSE; automatic = no choice)
- Dependencies: does this card's test rely on another card in the batch being implemented first?
Step 2 — Survey DSL Capabilities & Write All Tests (RED)
Before writing any tests, check what commands and assertions already exist:
grep -n "case '" SWUSim/Tests/Framework/SchemaTestRunner.php | grep -v "//"
grep -n "P1LEADER\|P1BASE\|P[12]GROUND\|SHIELDCOUNT\|EPICUSED" SWUSim/Tests/Framework/SchemaTestRunner.php
Identify all new DSL commands and assertions the batch will need. List them explicitly — these are what make the tests RED by design.
⚠ Clause decomposition — ONE test per clause/branch (do this FIRST)
Before writing any test, decompose the card text into every INDEPENDENT clause and conditional branch, and enumerate them explicitly. Then write at least one test per enumerated clause. A card is not "done" until every clause it prints has a passing test that OBSERVES it. This is the #1 source of shipped bugs: a multi-clause card gets some clauses implemented + tested and one silently absent — invisible to a happy-path test that only exercises the present clause (real examples: LOF_073 Mythosaur — protection clause wired, "friendly leaders gain Mandalorian" clause missing; LOF_261 Constructed Lightsaber — Villainy→Raid 2 and Heroism→Restore 2 wired, the third branch "neutral host → Sentinel" missing).
What counts as a separate clause/branch to enumerate:
- Each sentence / each trigger — When Played, When Defeated, On Attack, Action, Epic Action, and every standalone passive sentence is its own clause. A card with "When Played: X. When Defeated: Y." needs a test for X and Y (they're often the same handler wired to two triggers — verify BOTH fire).
- Each
If … / If … branch — a card with "If attached unit is Heroism, gains Restore 2. If Villainy, gains Raid 2. If neither, gains Sentinel." is THREE branches → three tests (one host per branch).
- Each keyword in a granted list — "gains Ambush, Grit, Sentinel, …" — treat as one test that exercises the distinct ones (especially numeric grants like Raid N / Restore N vs boolean keywords).
- Each half of a multi-part passive — "Friendly upgraded units can't be exhausted OR returned to hand … AND friendly leaders gain Mandalorian" = protection (2 verbs) + trait grant = separate tests.
The cross-function grant trap (why branches go missing): conditional keyword/trait grants live in DIFFERENT engine functions per keyword — Raid in HasKeyword_Raid, Restore in the Restore fn, Sentinel in HasConditionalKeyword_Sentinel, a granted trait in TraitContains (the object-aware trait check; _SWUUnitHasTrait was deleted). A multi-branch card is therefore N edits in N different files; it's easy to land N-1 and miss the last. After implementing (Step 3e verify), grep the CardID and confirm the number of grant hits equals the number of grant branches — grep -rn '<CardID>' SWUSim/Custom/ (recursive, so it descends cards/<set>/); 2 hits for a 3-branch card is the tell.
Observability — a GRANT clause is only testable through a CONSUMER that reads it via the object-based path (TraitContains($obj,$trait) for traits, HasKeyword_X / the HASKEYWORD assertion for keywords — NOT bare-CardID HasTrait). For a trait grant, find a card whose behavior depends on the granted trait (e.g. SHD_073 Mandalorian Armor gives a Shield "if attached unit is a Mandalorian" — attach it to a leader that Mythosaur made Mandalorian). Pick a probe host that exercises the SPECIFIC branch, and avoid a host that already has the keyword/trait PRINTED (SOR_049 Obi-Wan has Sentinel printed → a Sentinel test on it false-passes regardless of the grant).
Write all test files before implementing anything.
Recurring bug shapes beyond missing clauses (JTL validate-port, 2026-07-23) — decompose the TARGET SET and the OPTIONALITY, not just the clauses
A clause can be present and still wrong. Four shapes cost real bugs this port — enumerate each explicitly when decomposing:
- "another X unit" with NO "friendly" qualifier → ANY unit, friendly OR enemy. SWU targeting defaults to "any" unless the text says "friendly"/"enemy". Restricting to friendly is a bug (JTL_088 Phasma "+2/+2 to another First Order unit" targets an ENEMY too; JTL_120 Dorsal Turret "Attach to a Vehicle unit" attaches to an enemy Vehicle; JTL_129 Focus Fire; JTL_078 Direct Hit "non-leader Vehicle"). Always add a test that the enemy side IS (or a leader/other-arena is NOT) selectable — build the target list from all four arenas, then filter ONLY by the printed restriction.
- "Heal/deal UP TO N" and "You may" → support doing LESS and DECLINING. A mandatory single-target choose (
SWUQueueChooseTarget, which AUTO-RESOLVES a lone target) is wrong for "you may" / "up to" effects — use SWUQueueMayChooseTarget so even one legal target still offers a decline (canChooseNoTargets), and for "up to N" add an amount pick (OPTIONCHOOSE 1..min(N,cap)) so the player can heal/deal fewer than max. (JTL_071 CR90 "heal up to 3" — heal-less + decline; JTL_003 Lando "Play a unit from hand" — soft-pass decline / hidden info.) Tests: a heal-max case, a heal-LESS case, and a DECLINE case.
- A zero-effect selection must be filtered out. If choosing a target would do nothing (no friendly Vehicle in that arena for Focus Fire; an undamaged/immune target for some heals), it must be UNSELECTABLE. Add the legality condition to the target filter, and a
SELECTABLENOT: guard.
- Restrictions/immunities must apply at SELECTION time, not just resolution. "This unit can't attack" (JTL_059) has to exclude the unit from event-granted "attack with a unit" pickers (Outflank), not merely no-op at resolution — use
_SWUUnitHardCantAttack in the picker. Same for any "can't be targeted / can't ready" restriction: enforce it where the candidate list is built.
Trigger ATTRIBUTION on non-standard damage paths. A "when this unit deals damage to a base / a unit" trigger must fire on EVERY path that unit deals damage, not just the direct-attack path: Overwhelm spillover to a base is damage-to-a-base (set combatCtx['dealtToBase']); divided/split damage must run through the Shield + non-combat-reaction pipeline, not write Damage directly (_SWUApplySplitHits). And a non-standard attach/play path (SWUMoveUnitToUpgrade for a pilot) bypasses the shared _SWUFinalizeUpgradeAttach trigger dispatch → host "when a Pilot attaches" reactions silently don't fire (JTL_213 Sidon). When you add a bespoke attach/damage path, route it through the shared collector or explicitly re-fire the observers.
Test BOTH the On-Attack AND the When-Played/When-Defeated half. Many multi-trigger cards had only one half covered (Rafa JTL_219, Phasma JTL_088 On-Attack; FO Stormtrooper JTL_132 When-Defeated). A card with two trigger windows needs a section per window even when they share a handler.
More recurring bug shapes (SEC validate-port, 2026-07-24) — the "do nothing" and "stacking/stale" families
Seven more shapes cost real bugs this port. Enumerate a test for each whenever the card matches:
- "Name a card, then an opponent reveals their hand …" with an EMPTY opponent hand → skip the whole ability (NO prompt). A naming/reveal ability that can produce zero effect when the opponent's hand is empty must short-circuit before the NAMECARD prompt (
if (count(GetHand(OtherPlayer($p)))===0) return;). Bug family: SEC_186 Garindan, SEC_210 Stolen Starpath, SEC_260 Inspector's Shuttle — all raised a pointless prompt. Always add an OpponentHandEmpty_NoPrompt section (P1NODECISION) for any name-a-card / look-at-hand / reveal-hand ability.
- "An opponent MAY pay N. If they don't, X" with the opponent UNABLE to afford → skip the prompt, auto-resolve the "don't" branch. Don't offer a choice the player can't act on.
if (SWUResourceCount($opp,true) < N) { /* do X */ return; } before queueing the YESNO (SEC_218 Cikatro). Add a CannotPay_AutoResolve section (opponent 0 resources).
- A self-targeting "discard/return from YOUR hand" effect must EXCLUDE the in-flight event. A played event is
Remove()d to discard BEFORE its effect runs, but ZoneSearch("myHand") STILL returns the removed entry — filter empty($o->removed) or the event is selectable / discards itself (SEC_178 Pursue the Lead: self-discard offered the in-flight card + wrongly made a Spy). Add a SelfDiscard_InFlightNotSelectable section.
- A deferred When-Defeated target-choose that reads
$self via the positional mzID is STALE. By dispatch time the defeated unit is cleaned up and a survivor shifts into its slot, so GetZoneObject($mzID) returns the WRONG unit — which a "give ANOTHER friendly" clause then self-excludes → fizzle (SEC_202 Rebel Propagandist). Fix: guard ($self->CardID ?? '')===<CardID> && empty($self->removed) before trusting it as self; on defeat, self has left play so every survivor is "another". Add a WhenDefeatedByCombat_<effect> section (attacker dies to a bigger blocker) for ANY When-Defeated that references "this unit" / "another".
- A "+X/+Y for this phase" buff that can trigger MULTIPLE times must STACK.
SWUApplyPhaseBuff emits an identical SWUBUFF-X-Y string that AddTurnEffect DE-DUPES, so repeated applications collapse to one. For a stacking buff (SEC_081 Major Partagaz "when another Official attacks: +2/+2"), emit a unique-per-trigger token — AddTurnEffect($mz, SWUMakeTurnEffect('SWUBUFF',[X,Y],SWU_DUR_PHASE,'<TAG>_'.$stackIdx)) where $stackIdx counts existing ^<TAG>_ tokens on the unit. Add a BuffStacks section (two triggers → +2X/+2Y).