Implémentation de cartes SWUSim

Workflow TDD pour implémenter les capacités de cartes SWU, avec auto-revue et validation à 98% avant de continuer.

Spar Skills Guide Bot
DeveloppementAvancé
1005/08/2026
Claude Code
#tdd#swusim#card-game#implementation#peer-review

Recommandé pour


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):

  1. Positive — the clause does its thing.
  2. 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).
  3. Optional branch — take AND decline (AnswerDecision:- / PASS). A decline that silently does the wrong thing is a classic latent bug.
  4. 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).
  5. 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.
  6. 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):

  1. 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.

  2. 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
# text (is it keyword-only / empty?)
awk '/\$textData = array \(/,/^\);/' "$DICT" | grep "'CARD_ID'"
# registry membership — note the [ ... ]; delimiter here is DIFFERENT from the dictionary's = array ( … ^);
awk '/Sentinel_Cards = \[/,/\];/' "$KW" | grep 'CARD_ID'   # swap in the right registry name
# generic coverage
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/     # -r descends cards/<set>/ (split cards) AND the monoliths + KeywordEffects.php
# ⚠ Hits in CardMocks.php / CardTraitSupplement.php are DATA, not an implementation — those files
# list CardIDs for preview cards and API-gap traits. They carry a SCAFFOLD-IGNORE marker; ignore
# them when judging "is this card already implemented?" (a mocked card usually is NOT).

⚠ 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:

# Name, text, type, aspects, traits, unique
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'"
# For UPGRADES, the +power/+HP modifier lives in separate arrays — read these, not powerData/hpData:
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 BASEAttackGroundArena: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> 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 <X> 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' => 4049 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 (MZZoneCountGetZone 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 branchesgrep -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).
  • An upgrade printed "attach to a unit" (no friendly/enemy qualifier) must be attachable to ANY unit. Default upgrade targeting is friendly-only in SWUSim; a printed-unrestricted upgrade needs its CardID added to the attach-to-any-unit case list in SWUGetUpgradeValidTargets (GameLogic.php). Bug: SEC_175 Ambition's Reward (+1/+1, no restriction) couldn't attach to an enemy. Confirm the card's printed text carries no friendly/enemy attach restriction, then add an enemy-attach section.
  • "Exhaust N enemy resources" (an EFFECT) is up-to-N, not all-or-nothing. SWUExhaustResources is all-or-nothing (correct only for COST payment). An effect that reads "exhaust 2 enemy resources" must exhaust as many as available up to N — pass the $partial=true flag (SEC_235 The Wrong Ride). Add a FewerThanNReady_ExhaustsAvailable section.

More recurring bug shapes (ASH validate-port, 2026-07-25) — leader-sides, attacker-death triggers, token events, cost-flag scope, unpreventable

Nine shapes each cost real bugs across the ASH port (466→934 sections, ~30 engine bugs). Enumerate a test for each whenever the card matches:

  • A LEADER's DEPLOYED (leader-unit) side is a DIFFERENT ability from the front — and is easy to leave unimplemented. Ezra ASH_013 / Shin ASH_016 / Greef ASH_017 all had the undeployed (front) side wired but NOT the deployed side, which usually drops the self-exhaust cost (front pays "exhaust this leader"; deployed is a free field-observer) or carries a once-per-round limit instead. Wire BOTH the front (undeployed) AND the deployed side (a #1 handler + a _SWULeaderDeployed(...) hook beside the _SWULeaderReadyUndeployed(...) one — mirror ASH_005 Luke), and add a Deployed_<effect> section. The deployed once-per-round rides the leader's NumUses (SWUHasUseAvailable/SWUConsumeUse), NOT a self-exhaust.
  • A "when a friendly unit's attack ends / on-defeat / deal-excess" trigger whose EFFECT targets OTHER units must fire even when the SOURCE unit dies in that same combat. The whole family — Shin ASH_016 (leader observer), WhistlingBirds ASH_183 (upgrade AoE), Rukh ASH_036 (own WhenAttackEnds), WipeThemOut ASH_137 (deal-excess) — sits in SWUCollectCombatHitTriggers below the if ($attacker===null||removed) return; attacker-survival gate, so it's skipped on a trade. Fix: (a) capture the marker into $combatCtx at combat START (before SWUExpireTurnEffects(SWU_DUR_ATTACK)), and (b) place the AddTrigger above that survival gate (like the Boba LAW_007/252 hooks). Add a <effect>_FiresWhenAttackerTrades section (pre-damage the attacker so a small defender trades with it). ⚠ Not everything is fixable this way — WipeThemOut's excess MayChoose still doesn't SURFACE through the combat trigger flush once the attacker is removed (a deeper ordering/index-shift edge — deferred).
  • A TOKEN upgrade/unit event must fire the SAME observers as a real card — and a token CEASES to exist on leaving play (never to hand). Bugs: a Shield token consumed in combat didn't fire the "friendly upgrade defeated" observer (SWUConsumeShieldToken → add _SWUOnUpgradeDefeated) so Baylan ASH_039 / Zeb ASH_161 missed it; an Experience token granted by an effect didn't fire Sabine ASH_208's "when 1+ upgrades attach" (DoGiveExperienceToken → call the observer; a multi-token giver fires it ONCE via a $fireObserver=false param + one explicit call); a created TOKEN unit wasn't buffed by Outcast ASH_041's "when a friendly unit enters play" (add the effect to _SWUCreateOneToken, not just the play-reactions path); Jabba ASH_042 returning a token upgrade wrongly put it in HAND (SWUReturnUpgradeToHand must short-circuit for token cardTypes). When a card's ability keys off "a unit enters play / an upgrade attaches / an upgrade is defeated", grep the token paths (_SWUCreateOneToken, DoGiveShieldToken/DoGiveExperienceToken, SWUConsumeShieldToken) and confirm each fires the observer.
  • A "first/next X you play/do this PHASE" cost-flag must be set on ANY qualifying event, NOT only while controlling the card. PeliMotto ASH_212 + PitDroid ASH_075 gated the "used" flag on _SWUControlsCardInPlay(...), so a play made BEFORE the card entered didn't count → a later play was wrongly treated as the phase's first. Set the flag unconditionally on the qualifying play (mirror JTL_260 Death Star Plans); the DISCOUNT itself separately requires controlling the card. Also enforce the target restriction: a "on ANOTHER FRIENDLY unit" discount must reject the source-card host AND enemy hosts (check $host in the cost delta; best-case at the null-host affordability peek). For an UPGRADE, the flag/charge happens in _SWUFinalizeUpgradeAttach (after the host is chosen), NOT in ActivateCard — set the flag there. Add <X>OnSelf_NoDiscount + <X>BeforeCardEntered_SecondPaysFull sections.
  • A PREVENTION/cap must skip UNPREVENTABLE damage (indirect $isIndirect, or a source whose damage is unpreventable via _SWUDamageUnpreventable). AtAttin ASH_070's "prevent all but 4" capped indirect + ASH_196-Underworld combat damage. Thread the attacker object into the base/unit damage call and guard the cap. ⚠ Threading a unit OBJECT where an int-player was expected breaks downstream intval($damager) (SOR_175 flag) — normalize is_object($damager) ? $damager->Controller : $damager at every int use.
  • A conditional keyword/trait grant that says "the only friendly non-leader unit" (or similar self-referential set) must also require the SUBJECT itself still qualifies. Shin ASH_049 kept Sentinel after The Darksaber ASH_135 made her a LEADER unit — the condition counted OTHER non-leader units but never re-checked !IsLeaderUnit($obj) on herself. Add a <Grant>_LostWhenBecomesLeaderUnit section (attach ASH_135 / a leader-pilot).
  • Support LENDS the supported unit's When-Attack-Ends abilities too, not just On-Attack. The SUPPORT_GRANT marker is stripped by SWUExpireTurnEffects(SWU_DUR_ATTACK) BEFORE the attack-end collection, so the lent WhenAttackEnds (ASH_101/036/033/223) missed it — capture the grant into $combatCtx['supportGrant'] before expiry and read it at the attack-end consumers. Add a Support_LendsAttackEnd<effect> section.
  • A deck-search with NO valid pick must RETURN the peeked cards, not mill them. The shared _topDeckSearchBegin auto-skips a no-target search, which skipped the finalize → the array_spliced cards were lost (ElzarMann ASH_224). The finalize now has dontSkipOnPass. Add a NoMatch_CardsReturnToDeck section (assert deck count unchanged).
  • A base-damage cap / "capped to N" and a "prevent all but N" both need > N, not >= N, and a DAMAGE:N-exactly control (a defender/base at exactly the threshold takes full). Cheap to get wrong; add an Exactly<N>Unaffected section.

More recurring bug shapes (ASH validate-tests re-run, 2026-07-26) — leave-play observers, control-transfer, base-prevention, leader-unit status, reduced-deploy

Five more shapes, each a real engine bug found closing the ASH portable residual (+120 sections):

  • EVERY leave-play path that defeats a unit's upgrades must fire _SWUOnUpgradeDefeated, not just combat/ability-defeat/bounce. CAPTURE was the miss: DoCaptureUnit and the base-capture _SWUBaseCaptureUnit (SEC_195) discarded the captured unit's upgrades via SWUAddToDiscard WITHOUT the observer, so ASH_161 Zeb / ASH_039 / ASH_055 didn't react when their host (or Zeb himself) was captured. Pass the captive's controller as $controller; the $hostObj===ASH_161 OR-clause covers Zeb's OWN upgrades as he's captured. When ANY new leave-play/upgrade-defeat primitive is added, grep that it calls _SWUOnUpgradeDefeated for each non-token upgrade. Guard: Zeb…::ZebCaptured_OwnUpgrade_Trigger.
  • Taking control of a unit transfers control of its NON-PILOT upgrades too (CR); PILOTS keep their own controller. SWUTakeControlOfUnit copied subcards verbatim without updating Controller, so a stolen unit's regular upgrade kept the OLD controller → "friendly upgrade defeated" (Zeb) didn't fire for the new controller on a JTL_043 NGOR take-control-then-defeat. Fix: set Controller = $newController on each non-captive, non-pilot subcard. EXCLUDE pilots — a Pilot's defeat-replacement/return follows the player who played it (verified by the stolen-host pilot test LukeSkywalker_YouStillWithMe::StolenHost_…). Guard: Zeb…::NGORTakeControlThenDefeat_RealUpgrade_ZebFires.
  • A prevention/cap must skip UNPREVENTABLE damage on the BASE path too, not only unit damage. Extends the AtAttin ASH_070 shape: JTL_074 Close the Shield Gate (SWU_SHIELD_GATE in SWUDealDamageToBase) prevented even ASH_196-Underworld combat damage to a base. Hoist the $isIndirect || (is_object($damager) && _SWUDamageUnpreventable($damager)) computation above the shield-gate block and reuse it there (the combat attacker is already threaded). Guard: Gorian…::BypassesCloseTheShieldGate.
  • A card that keys off "a leader unit" (defeated / in play / attacked) must read the LIVE object via IsLeaderUnit(GetZoneObject($d['mzID'])), NOT the printed CardType 'Leader'. A unit made a leader unit by The Darksaber ASH_135 or a deployed Pilot leader has printed type "Unit". Pellaeon ASH_093's SWU_LEADER_DEFEATED_PHASE flag missed those. The live object is intact in the defeat-collection (before upgrade-strip), mirroring the adjacent Jyn Erso live-trait read. Guard: Pellaeon…::RaidWhenDarksaberLeaderUnitDefeated. (⚠ DSL: an exact-lethal fixture seat may NOT defeat — pre-damage to ≤1 HP remaining to guarantee it.)
  • A leader with a REDUCED deploy threshold (Bo-Katan ASH_010 = resources + friendly Mandalorian-unit count ≥ 10; cf. LOF_007 Avar Kriss = resources + Force-uses) needs its OWN elseif ($cardID===…) branch in SWUDeployLeader — the default branch gates on flat SWUResourceCount < CardCost. Guard both a blocked (Deploy_Blocked_BelowThreshold) and an allowed-via-the-reducer (Deploy_Allowed_<Reducer>ReducesThreshold) case.
  • DEFERRED family to recognize (don't rat-hole): a "when another friendly unit is defeated" reaction (The Twins ASH_127) firing per-co-defeated-unit needs a TRUE simultaneous-defeat batch — but "defeat all units" (SOR_043) defeats sequentially, so a co-defeated reactor misses some. Fixing = routing board-wipes through one $leftCards batch (broad/risky). Also the pilot-as-upgrade / play-from-deck dispatch family (Peli ASH_212 first-non-unit waiver, Elzar ASH_224 enters-ready via play-from-deck) and upgrade friendliness by controller-vs-owner (Vane/Pegasus on a stolen or enemy host) are CR-nuanced — flag for a ruling, don't silently override a deliberate design comment.

More recurring bug shapes (JTL re-validation, 2026-08-02) — request boundaries, bespoke paths that skip the ceremony, generated array fields, payment capacity

Seven shapes, each a real engine bug found re-validating an ALREADY-ported set (+23 sections, 7 bugs). The first two are the highest-yield in the whole list: they are invisible to a green suite.

  • ★ State parked in an in-memory global and read AFTER an interactive decision is GONE in production. Every interactive decision ENDS the request; the answer arrives in a fresh process where non-serialized globals are empty. JTL_094 Luke's pilot-replacement rebuild data lived in $gReplaceSnapshots, written when the offer was queued and read when the player answered — so in real games the handler found nothing and returned silently: Luke was neither rebuilt as a unit nor discarded, he vanished from the game. HMW_060's RAMPART_SAVE had the identical shape. Fix by riding the payload on the CUSTOM decision's own Param (serialized with the gamestate, auto-freed with the decision — base64url it, zone fields are space-delimited) or a serialized SWUVar; the $gPlayGrantedExploit "restored from the CUSTOM param" comment is the house precedent. Rule: if a value is written before AddDecision and read in the handler behind it, it MUST be serialized. ⚠ The suite CANNOT see this — it runs one process. Guard every such flow with a SimulateRequestBoundary section, and when you add a new transient continuation global, add it to simulateRequestBoundary() in GameTestAdapter.php or it silently becomes untestable (that omission is exactly why the Luke bug survived).
  • ★ A BESPOKE path that moves a card must re-do everything the normal ceremony does — and "bagging" a trigger is not "firing" it. The relocation helpers (SWURelocatePilotSubcard, SWUMoveUpgradeCrossUnit, SWUMoveUnitToUpgrade) spliced subcards between hosts and fired nothing, so: (a) the moved upgrade's OWN $onAttachedAbilities never ran (JTL_036 Iden didn't re-shield her new host) because CollectOnAttachedTriggers had exactly ONE call site — _SWUFinalizeUpgradeAttach — despite its own doc-comment claiming it fires "for any source"; and (b) _SWUFireHostPilotAttachReactions only AddTriggers into the pending bag, which the play ceremony flushes — a bespoke path has no ceremony, so JTL_223 Razor Crest's reaction was queued and never surfaced. When you add any move/attach/create primitive, mirror the ceremony: fire host reactions + CollectOnAttachedTriggers + FlushEntryTriggerBag($hostController). Same shape as the earlier _SWUCreateOneToken token-flush note. A doc-comment claiming "any source" is a claim to VERIFY, not to trust — grep the call sites.
  • A generated Add<Zone> accessor destroys any field type the generator has no branch for. zzGameCodeGenerator.php joins all fields into one space-delimited constructor line and only special-cased Type == "json". TurnEffects is array[string], so handing it a real array produced the literal string "Array" (+ an Array to string conversion warning) and wiped every TurnEffect on the moved unit — phase buffs/debuffs, granted keywords, and delayed-effect markers alike (Sneak Attack's regroup-defeat marker vanished, so the unit survived). Fix in the GENERATOR (an array branch emitting the same implode('~') the zone class parses with) then regenerate — never hand-edit ZoneAccessors.php. An Array to string conversion warning is never cosmetic; it is silent data loss.
  • An affordability gate must count TOTAL payment capacity, not ready resources. Blue Leader JTL_096 gated its "you may pay 2" on SWUResourceCount($p, true), so a player with 1 ready resource + 1 Credit token — who CAN pay — was never even offered the ability. Use the shared SWUTotalPaymentCapacity($player) (ready resources + defeatable Credits + SEC_122 Droids) for the offer, and route the payment through SWUOfferAltPayment so the player picks the mix. ⚠ ~65 card sites and ~38 shared sites still call SWUResourceCount; many are legitimate ("X = your ready resources", opponent comparisons), but any that gate a PAYABLE COST are this bug.
  • A deferred/parked queue drained from exactly ONE place will be missed by every other path. SWUFlushDeferredReplacements() was called only from SWUAfterAction, so a defeat resolving inside a NON-ACTIVE player's trigger drain (which runs after that) left the would-be-defeated unit stranded in play forever — neither defeated nor replaced. Production drains via ProcessGoldfishAutomation() after EVERY gamestate-writing action (Core/EngineActionRunner.php), so that is the honest place to flush. When you add a parked bag, enumerate every path that can fill it, not just the happy one.
  • In "Defeat X and do Y to Z", the first clause is NOT conditional on the second being possible. Lightspeed Assault gated the friendly's defeat on an enemy target existing, so the whole event fizzled; an ability resolves as much of itself as it can, so the friendly dies and nothing else happens (user ruling 2026-08-02). Reserve the gate for an explicit "If you do," rider. Add a <NoSecondTarget>_FirstClauseStillResolves section.
  • A "while this unit is DEFENDING/ATTACKING …" aura needs both self- and bystander-negatives. Gold Leader JTL_054's "-1/-0 to the attacker while defending" must NOT debuff Gold Leader when IT attacks, and must NOT apply when a DIFFERENT friendly unit is the defender. Both were already correct — but neither was tested, and a misleading test NAME hid the omission (only the assertions themselves revealed the cases). Always add Attacking_NoDebuffOnItself + OtherUnitDefending_NoDebuff.

More recurring bug shapes (IC27 build, 2026-08-04) — load-order, generator detection, pre-cleanup collection

Found building a NEW set card-by-card (not a validate-port), so these bite during ORIGINAL implementation:

  • ⚠⚠ A per-card file CANNOT register into a registry that is initialized AFTER cards/_loader.php. The loader runs at GameLogic.php:15; $playCostModifiers = [] is initialized around line 2331, so a $playCostModifiers["X"] = … written in cards/<set>/X.php is silently WIPED and the card no-ops. It passes its own RED check and then still fails — there is no error. Put cost-modifier closures in GameLogic beside SHD_182 / LAW_179 / TS26_71 (which all live there for exactly this reason) and leave a pointer comment in the card file. Before registering into any array from a per-card file, check where that array is initialized relative to line 15.
  • ⚠⚠ The stub generator missed a SPACED multi-trigger header. "When Played / On Attack / When Defeated:" (IC27_024 Thrawn) matched only the LAST window: the detector handled the tight "When Played/" but not the spaced form, so two of three triggers dispatched to nothing — a silent in-game no-op with a handler registered. Fixed in zzCardCodeGenerator.php (/When Played\s*\//i, /On Attack\s*\//i). For any card whose text joins trigger windows with slashes, verify EVERY window has a stub before trusting the dispatch — and remember GeneratedAbilityStubs.php is gitignored, so a hand-patch is local-only and the GENERATOR edit is what ships.
  • A When-Defeated target collection runs BEFORE CleanupRemovedCards, so the dying source is still in its own arena and gets offered as a target for its own "give a friendly unit X" ability. Exclude the source by UniqueID explicitly — do NOT rely on a removed flag, which is not yet set at that point. (This is the other side of the existing "survivors reindex after cleanup" note: the OFFER sees the pre-cleanup array, the RESOLUTION sees the post-cleanup one, so a test's answer index and the target filter are governed by different snapshots.)
  • P1OnlyActions hands P2 the CLAIMED initiative, so after a regroup P2 is the turn player and P1 cannot act until a P2>Pass — but P2 needs no pass before the FIRST action of a phase (its auto-pass only fires in response to a P1 action). A round-crossing test that omits it silently drops every action after the regroup.
  • ★ A leader's two sides can land on OPPOSITE sides of the COST-vs-EFFECT line — and the same sentence reads differently on each. IC27_001 Darth Vader is Action [1 resource, Exhaust, defeat a friendly unit] on the FRONT (the defeat is inside the brackets ⇒ a cost REQUIREMENT: gate it in SWULeaderActionAffordable, the leader must not exhaust without a sacrifice, and there is no decline) but On Attack: You may defeat another friendly unit. If you do, … DEPLOYED (an EFFECT: never gated, freely declinable, and the payoff hangs off "If you do"). SOR_006 Emperor Palpatine is the canonical both-sides template — copy it wholesale for this shape. Decide the classification per SIDE from the printed punctuation (brackets = cost, "you may … If you do" = effect), not per card.
  • ⚠ FIXTURE TRAP: a card that DRAWS needs a seeded deck, or the draw DAMAGES the base instead. An empty deck turned "draw a card and heal 2" into base damage going UP, which reads exactly like "the heal is broken" — three sections failed on a fixture omission, not the implementation. Distinct from the documented empty-deck regroup penalty: this fires on any draw. Seed WithP{n}Deck in every section whose card can draw.
  • ⚠ FIXTURE TRAP: CommonSetup's myResources:N and an explicit WithP{n}Resources: line COMBINE, they do not replace. An "unaffordable cost" section that sets both silently ends up holding a ready resource, so the action succeeds and the test fails for the wrong reason. Use one or the other.
  • ★ On the RED check, every GREEN section needs a STATED REASON. Absence-guards legitimately pass pre-implementation — but so do two failure modes that look identical: a section whose expected value the unimplemented engine already produces (an assertion of 1 where the correct answer is 2), and a section whose two halves cancel out (return 3 resources + resource 3 back leaves the COUNT unchanged, so it passes either way). Both shipped into this run's first drafts and were caught only by asking "why is this one green?" per section. If you cannot name the reason, the section is not discriminating — fix it before implementing.

Test file naming & layout — the Title_Subtitle standard (set 2026-07-15)

One file per card, named by the card's title, holding ALL that card's tests as sections. Path: SWUSim/Tests/Cases/{set}/{Name}.md.

  • Filename = Title_Subtitle.md for unique cards (a unit/leader/etc. with a subtitle) — CamelCase, punctuation stripped: AdmiralAckbar_AssumeAttackCoordinates.md. This disambiguates same-name cards — e.g. the two Moff Gideons become MoffGideon_IndomitableWarlord.md (ASH_008) and MoffGideon_RemnantCommander.md (ASH_097).
  • Filename = Title.md (no subtitle) for cards with no subtitle — most non-unique units, events, upgrades: ANewOrder.md, Vanquish.md.
  • Derive Title/Subtitle from the dictionary's $titleData / $subtitleData (never memory). {set} = the lowercase set dir (ash, sor, …).
  • Do NOT prefix with a category (Act_, Adv_, Leader_) or bury the card id (Act_ASH109_...) — those old schemes were retrofitted away. The card is identified by its title; the behavior goes in the section name, not the filename.

Multiple tests per file = one file, split into sections. A file may hold many tests, separated by a markdown --- rule; each test opens with a single-# header naming the behavior (TitleCase). ## are the section keywords (GIVEN/WHEN/EXPECT); #// is a comment. Example:

# WhenPlayed_DealsTwoIfBountyHunter
#// ASH_042 — comment/description lines use #// so the only # line is the behavior name.

## GIVEN
...
## EXPECT
...

---

# WhenPlayed_NoBountyHunter_NoEffect
## GIVEN
...

A file with no --- is a single test (name comes from the filename). The parser (SchemaTestRunner::splitSegments) splits on --- and reads each # header as the test name; both the direct runner and the regression discover these automatically (SchemaBasedTest.php registers one test_ fn per section). The zzTestSchemaEditor "Test" dropdown lists the sections.

Mechanic / keyword coverage that isn't a single card goes in a shared file, NOT a card file: rules/token mechanics → Tests/Cases/core/ (e.g. Advantage.md, CreditToken.md); keyword mechanics → Tests/Cases/keywords/ (e.g. Support.md, Hidden.md, Pilot.md, Torpedo.md, LeaderPilot.md, BountyUpgrades.md). A test that exercises one specific card's ability belongs in that card's Title_Subtitle.md, even if it also demonstrates a keyword.

Use the standard DSL format:

## GIVEN
P1LeaderBase: LEADER_ID/BASE_ID
P2LeaderBase: LEADER_ID/BASE_ID
SkipPreGame: true
WithP1GroundArena: UNIT_ID:1:0    # status 1=ready, 0=exhausted; last field = damage

## WHEN
- P1>PlayHand:0
- P1>AnswerDecision:myGroundArena-0

## EXPECT
P1GROUNDARENAUNIT:0:SHIELDCOUNT:1
P1BASE:EPICUSED

Status convention (CRITICAL — easy to get backwards): everywhere a unit/resource status appears — GIVEN specs and the engine's Status field — 1 = ready, 0 = exhausted. There is NO "2". _parseUnitSpec reads ready = (intval(status) === 1); OnReadyCard sets Status=1, OnExhaustCard sets Status=0. (The :READY / :EXHAUSTED assertion keywords are unaffected — those are words, not numbers.)

GIVEN directives cheat sheet:

  • WithP1GroundArena: CARD_ID:status:damage[:turnEffects]status: 1=ready, 0=exhausted; optional 4th field = ~-delimited active TurnEffects on the unit (e.g. SEC_098:0:3:LOF_045~SENTINEL^SEC_041 — a granted Restore + Sentinel). Player-facing/registry tokens only; the engine's SWU_* backend flags aren't set this way.
  • WithP1Resources: N or WithP1Resources: N:CARD_ID:status or N:CARD_ID:status,M:CARD_ID:statusstatus 1=ready, 0=exhausted
  • WithP1Hand: CARD_ID — card in hand. MULTI-VALUE directives (WithP1/2Hand, WithP1/2Discard, WithP1/2GroundArena, WithP1/2SpaceArena, WithP1/2*ArenaUpgrade, WithP1/2Deck) take EITHER one spec per repeated line OR a bracketed, whitespace-separated array on one line: WithP1Deck: [SOR_225 SEC_080 SOR_128], WithP1GroundArena: [SOR_095:1:0 SEC_098:0:3:LOF_045]. ⚠ Still NOT comma-listsWithP1Deck: A,B,C creates ONE card whose CardID is the literal "A,B,C" (silently breaks draw/mill tests). Use the bracket form or repeat the line.
  • P1LeaderBase: LEADER_SPEC/BASE_SPEC — LEADER_SPEC can be SOR_014:1:1:1 (ready:deployed:epicUsed bits). ⚠ The :1:1:1 deployed bit sets the leader's flag only — it does NOT create the deployed leader-unit in the arena. It satisfies "if you control a leader unit" condition checks (SWUControlsLeaderUnit reads the flag), but a test where the deployed unit must attack (AttackGroundArena:idx) or be counted by GetUnitsInPlay (a deployed-passive like IG-88's Raid grant) must deploy via DeployLeader in WHEN — that creates the real arena unit (at idx 0 of an empty arena; needs ≥5 resources for the Epic Action). Cross-arena trick to dodge index ambiguity: deploy the leader to one arena, put the attacker/recipient in the other. Symptom of getting this wrong: the attack hits nothing (base 0 damage) / the passive doesn't apply.
  • CommonSetup: {base}{leader}/{base}{leader}/{opts}preferred default over explicit P1LeaderBase/P2LeaderBase unless the test specifically needs particular leaders. 3-char codes: base = bVigilance / gCommand / rAggression / yCunning / nnone; leader = {aspect}{alignment}, e.g. yw=Cunning+Heroism (Han Solo), gw=Command+Heroism (Leia), rk=Aggression+Villainy (Vader), yk=Cunning+Villainy (Thrawn). Opts block {myResources:6;myhandCardIds:SOR_095} — other opt keys: theirResources, myhandCardIds/theirhandCardIds (hand cards; legacy aliases handCardIds/theirHandCardIds still work), discardCardIds / theirDiscardCardIds (seed a discard pile), myBaseDamage / theirBaseDamage, myLeader:CARDID[:ready[:deployed[:epicUsed[:damage[:indexOverride]]]]] / theirLeader:… (override the code's leader with ANY cardID — e.g. a JTL pilot, no need for P1LeaderBase. Optional inline params: ready 1/0 · deployed 1=deploy as a REAL linked ground-arena leader unit, deployMode='unit' · epicUsed 1 · damage on that deployed UNIT · indexOverride (6th) = ground-arena index to insert the deployed unit at, shifting the other WithP{n}GroundArena units up (plain deploy appends it LAST) — so myLeader:ASH_003:1:1:1:2:0 = a ready, epic-used, 2-damage deployed Baylan leader unit at ground index 0, in ONE opt, no separate WithP1GroundArena line. damage/indexOverride only take effect when deployed=1. Bare myLeader:CARDID is unchanged; leader READY is this 2nd inline field, not a standalone opt. WithP{n}GroundArenaUpgrade: N:CARD indices address the FINAL arena layout (after the deployed leader is spliced in), so index N targets whatever unit ends at ground index N — INCLUDING the deployed leader itself: to shield/upgrade a deployed leader placed at indexOverride 0, use WithP{n}GroundArenaUpgrade: 0:SOR_T02 (fixed 2026-07-03; previously indices were bracket-relative / leader-excluded, so a deployed leader could not be fixture-upgraded at all).), myBase:CARDID / theirBase:CARDID (override the code's base with ANY cardID — the code's base letter still picks the color, but the actual base card is swapped; needed for any non-canonical base: the 5 color bases are all 30HP no-ability, so a base with different HP — 25/26/27/28/34/35 — or an ability/Force trigger/starting-hand mod CANNOT be stood in for by a color letter), myLeaderDeployed:true / theirLeaderDeployed:true (deploy leader as a real ground-arena leader unit, DeployedUniqueID-linked), myLeaderDeployedPilot:true / theirLeaderDeployedPilot:true (attach leader as a Pilot upgrade onto the player's FIRST friendly unit — make that unit a Vehicle host), myLeaderEpicUsed (+ their). NOTE: there is no board-less "deployed flag" and no standalone LeaderReady/LeaderIndexOverride opts — a deployed leader is a real, DeployedUniqueID-linked unit placed via the inline myLeader:…:deployed:…:index form (or myLeaderDeployed/myLeaderDeployedPilot). The opts block may be written multiline (one key:val; per indented line, closing } on its own line) — the brace-folding parser treats it identically to inline, and multiline is the preferred style for blocks with several opts. ⚠ Every player needs a VALID leader codenn/nnn ("no leader") is rejected with unknown leader code 'nn'; for an auto-passing opponent just use any real code (e.g. rrk). Implies SkipPreGame — so CommonSetup is incompatible with the pregame draw-6 simulation (SkipPreGame: false + ResourceHand pregame actions that derive hand/resource/deck counts); the 3 such tests (jtl/Colossus_DrawsOneFewer, jtl/NabatVillage_DrawsThreeMore, win_con/SimpleFullGame) stay on explicit P1LeaderBase. Because it picks an aspect-matched leader/base, cards play at their printed cost (no off-aspect penalty) — see fixture-aspect note below. ⚠ CommonSetup with NO myResources opt gives few/zero ready resources, so a test that plays a card from hand silently no-ops (the card stays in hand, no error). Whenever the WHEN includes a PlayHand, set {myResources:N} ≥ the card's cost (Sidon JTL_213 cost real debug — TestSchemaStep showed the card still in hand and pending:[]).
  • P1OnlyActions: true — P1 gets back-to-back actions; P2 auto-passes after each P1 action. Expands to WithInitiativePlayer: 2 + WithInitiativeClaimed: true + WithActivePlayer: 1. Use this when the test only needs P1 to act and P2 should never interject. Do NOT use WithInitiativePlayer: 2 + WithInitiativeClaimed: true without also setting WithActivePlayer: 1 — otherwise P1's first PlayHand is blocked by ActionMap's turn-player check.
  • When the OPPONENT must actually act (events targeting your units, reactive/On-Defense triggers — common in Phase 7), do NOT use P1OnlyActions (it makes P2 auto-pass). Set WithActivePlayer: 1 and leave initiative unclaimed (omit WithInitiativeClaimed), so the turn alternates normally: P1>action → turn swaps to P2 → P2>action → back to P1, etc. The player holding claimed initiative is the one that auto-passes, so claiming defeats genuine alternation. Drive the exact P1>… / P2>… sequence yourself (each action swaps the turn player), and end with consecutive passes to reach regroup. Verify the alternation through TestSchemaStep before trusting the WHEN block — a mis-ordered turn just silently no-ops.

WHEN commands cheat sheet:

  • UseLeaderAbility / UseBaseAbility / DeployLeader
  • PlayHand:idx / AttackGroundArena:idx:target / AttackSpaceArena:idx:target
  • Pass / Claim / ResourceHand:idx / ResourcePass
  • SmuggleResource:idx
  • AnswerDecision:value — feeds $lastDecision to the next pending DQ entry. Multi-select (MZMULTICHOOSE) answers are &-delimited: AnswerDecision:mz-0&mz-1.
    • MZMAYCHOOSE ("you may" target): answer with the explicit target mzID to take it, or AnswerDecision:- to decline (NOT :NO). Unlike a mandatory single-target (which auto-PASSPARAMETERs), a may-choose with one legal target still needs an explicit pick — there is no auto-resolve.
    • OPTIONCHOOSE: answer with the label verbatim, e.g. AnswerDecision:Ground. ⚠ Option labels MUST be single-token (no spaces) — the DecisionQueue constructor does explode(" "), so a multi-word Param is truncated at the first space ("Draw&Discard and heal 3" stores as "Draw&Discard"). Tests still pass (handlers key on the answer, not the param) but the live UI shows truncated labels. Use single words (Draw&Discard, Play&Discard&Leave) and put detail in the tooltip.
  • Two-entry-trigger ordering: a unit with an entry-active keyword (Shielded gives a shield on entry, Ambush attacks) and a WhenPlayed has TWO entry triggers. FlushEntryTriggerBag first queues a "choose trigger to resolve" MZCHOOSE — answer it with AnswerDecision:EffectStack-N (the entries are added WhenPlayed-first, so EffectStack-0 = the WhenPlayed) before the WhenPlayed's own YESNO/target answers. Passive keywords (Sentinel/Overwhelm/Grit/Raid) do NOT add an entry trigger, so they need no ordering step. (Reference: CountDooku_DefeatsLowHpUnit.md.) ⚠ An entry keyword with no valid target adds NO trigger — e.g. Ambush with no enemy unit to attack is skipped, so a card with Ambush + WhenPlayed played into an empty enemy board has only ONE entry trigger and goes STRAIGHT to the WhenPlayed decision (no ordering MZCHOOSE). Verify the real sequence via TestSchemaStep before adding an EffectStack-N answer — feeding EffectStack-0 to the WhenPlayed's own MZMAYCHOOSE silently mis-answers it (treated as a non-matching pick → the effect no-ops). Reference: SOR_183 played into an empty board.

EXPECT assertions cheat sheet (the runner is the source of truth — when in doubt, re-derive with grep -oE "preg_match\('[^']+'" SWUSim/Tests/Framework/SchemaTestRunner.php):

  • P1BASEDMG:N / P2BASEDMG:N
  • P1GROUNDARENACOUNT:N / P1SPACEARENACOUNT:N
  • P1GROUNDARENAUNIT:idx:CARDID:X / :SHIELDCOUNT:N / :DAMAGE:N / :POWER:N / :HP:N / :READY / :EXHAUSTED / :UPGRADECOUNT:N / :UPGRADE:n:CARDID:X / :HASKEYWORD:Kw / :NOTKEYWORD:Kw
    • UPGRADECOUNT counts ALL subcards — Shield (SOR_T02) and Experience (SOR_T01) tokens too, not just "real" upgrades. A unit with a Lightsaber + a Shield is UPGRADECOUNT:2. To assert upgrades on a unit that also has tokens, use SHIELDCOUNT (filters SOR_T02) and/or a specific UPGRADE:idx:CARDID. HASKEYWORD/NOTKEYWORD dispatch to the real HasKeyword_<Kw>($obj) (incl. suppression/grant/conditional layers).
  • P1SPACEARENAUNIT:idx:... — same fields
  • P1LEADER:READY / :EXHAUSTED / :DEPLOYED / :NOTDEPLOYED / :EPICUSED / :EPICAVAILABLE
  • P1BASE:EPICUSED / P1BASE:EPICAVAILABLE
  • P1RESCOUNT:N / P1RESAVAILABLE:N / P1HANDCOUNT:N / P1DISCARDCOUNT:N
  • P1DISCARDUNIT:idx:CARDID:X / :MODIFIER:X / :FROM:X
  • P1DECKCOUNT:N / P1DECKTOPCARD:CARD_ID
  • P1NODECISION / P1HASDECISION
  • PHASE:X / PHASEISNOT:X / INITIATIVECOUNTER:X
  • EFFECTSTACKCOUNT:N / EFFECTSTACKHAS:TriggerType
  • LOGCONTAINS:text / LASTLOGCONTAINS:text — substring match against game log entry text
  • P1WIN / P2WIN

For batches: order tests so that simpler/standalone cards come before tests that depend on other batch cards being implemented.

Scope — test what's NEW, not the engine. Core mechanics are already covered generically by the suite: leader deploy + unit-side stats (GrandAdmiralThrawn_Deploy.md), below-threshold deploy no-op (Palpatine_Deploy_BelowThreshold_NoOp.md), basic attack/damage, resourcing. Don't write a per-card deploy or below-threshold test just because the card has those mechanics — only test the card's distinctive behavior. A standalone-mechanic test that passes RED-check on the first run is a signal the shared infra already covers it; drop it. Exception — negative/absence guards. A test asserting an effect does not happen (e.g. "non-Force host grants no debuff", "friendly unit is unaffected") legitimately passes pre-implementation, because nothing implemented yet = no effect. Keep it: it stays meaningful as a guard once the positive case exists. Distinguish "redundant — engine already does this" (drop) from "asserts an absence" (keep, expect green early). And a behavior-CHANGE guard: when your work changes engine timing/cleanup rather than adding a visible effect (e.g. an effect that used to linger now expires at attack/phase end), the whole suite stays green either way — so add a test that would FAIL under the old behavior to prove the change actually fires. (SOR_217: a base-damage-only test passes the buggy lingering version; only asserting POWER:<base> after the attack proves the attack-end expiry ran.)

Standard fixture library — reach for these before inventing a filler. All are vanilla (blank text box) unless flagged, so none carry a WhenDefeated trigger that could block PlayHand (see #2) — they're safe to drop in, attack with, or kill. Pick by the dimension the test stresses (arena, HP extreme, big body, keyword, upgrade):

| Role | Card | Stats | Traits / notes | |------|------|-------|----------------| | Ground baseline — Heroism | SOR_095 Battlefield Marine | 3/3 | Rebel, Trooper | | Ground baseline — Villainy | SEC_080 Imperial Dark Trooper | 3/3 | Imperial, Droid, Trooper | | Ground glass cannon — Heroism | LAW_180 Inspired Recruit | 3/1 | Rebel, Trooper (dies to any damage) | | Ground glass cannon — Villainy | SOR_128 Death Star Stormtrooper | 3/1 | Imperial, Trooper (dies to any damage) | | Ground HP wall — Heroism | SOR_046 Consular Security Force | 3/7 | Rebel, Trooper (survives big hits) | | Ground big body — neutral | LAW_124 Industrious Team | 4/7 | Underworld, Bounty Hunter. ⚠ Has a WhenPlayed — drop in via WithP*GroundArena only, NEVER PlayHand (safe to kill: no WhenDefeated) | | Space baseline — Heroism | SOR_237 Alliance X-Wing | 2/3 | Rebel, Vehicle, Fighter | | Space baseline — Villainy | SOR_225 TIE/ln Fighter | 2/1 | Imperial, Vehicle, Fighter | | Space big body — neutral | JTL_069 Munificent Frigate | 4/7 | Separatist, Vehicle, Capital Ship (vanilla) | | Keyword — Shielded | SOR_207 Crafty Smuggler | 2/2 | gives itself a Shield on entry | | Keyword — Sentinel | SOR_063 Cloud City Wing Guard | 2/4 | | | Upgrade — symmetric | SOR_120 Academy Training | +2/+2 | | | Upgrade — HP-only | SOR_069 Resilient | +0/+3 | good for "does +HP keep it alive in combat" | | Token upgrades | SOR_T01 Experience / SOR_T02 Shield | +1/+1 / shield | | | Clean event — neutral | SOR_251 Confiscate | cost 1 | "Defeat an upgrade." Neutral aspect (no off-aspect penalty, any leader affords it) and fizzles cleanly with no upgrades in play (no decision). Ideal fixture for testing a "when you play an event" reaction without the event's own effect interfering. |

Notes:

  • The ground vanillas double as trait fixtures: every Heroism filler is a Rebel/Trooper, every Villainy filler is an Imperial/Trooper; the vehicles cover Vehicle/Fighter (for Vehicle / non-Vehicle upgrade-attach restrictions). The neutral big bodies (LAW_124, JTL_069) are Side-agnostic, so they suit either leader without a Side mismatch.
  • Prefer a filler that matches the leader's Side aspect — Light side leaders → Heroism fillers (SOR_095, SOR_237); Dark side leaders → Villainy fillers (SEC_080, SOR_225). Mixing sides adds an off-aspect cost penalty (#7) and makes the board unrealistic. Easiest fix when a Villainy-leader test reuses a Heroism filler is a straight swap (SOR_095 → SEC_080, SOR_237 → SOR_225).
  • The glass cannon now has a Side-aligned pair (LAW_180 Heroism / SOR_128 Villainy, both 3/1); the HP wall (SOR_046) is Heroism-only with no same-stat Villainy vanilla — for a Villainy durable body use the neutral LAW_124 (4/7, GIVEN-only).

Before writing any test, verify these for every unit used as a test fixture:

  1. Stats from the dictionary, not memory. Derive every expected P1BASEDMG / P2BASEDMG from the array-specific awk lookups above. Never eyeball power values — they are easy to swap.

  2. Units that die must not be in HasWhenDefeatedAbility. Even if the card's handler is not implemented, FlushTriggerBag will queue a RESOLVE_TRIGGER DQ entry, making AllQueuesEmpty() return false. That silently blocks PlayHand for both players via ActionMap. Check GeneratedAbilityStubs.php's HasWhenDefeatedAbility switch before choosing a unit to kill. Default to a vanilla filler from the library above (SOR_095 / SEC_080 / SOR_128) — they have no defeated trigger by construction. (SOR_189 Leia is also safe to kill, but she carries a WhenPlayed, so only drop her in via WithP1GroundArena, never PlayHand.)

  3. "You may" — pick the optional-effect shape by what's optional (do NOT default to YESNO). Three shapes:

    • "You may [deal/give/exhaust/defeat/return/heal a unit]" (optional single-target) → MZMAYCHOOSE, ONE pick-or-pass popup — not a YESNO + separate choose. Use SWUQueueMayChooseTarget(...) (Step 3c helpers). A "may" target never auto-resolves even with one legal target (the player must still be able to decline), and the follow-up handler must no-op on a '-' decline.
    • "You may [pay N / discard / draw — no target]" → still YESNO (e.g. SOR_206 "pay 2, then draw"). ⚠ A YESNO's prompt text lives in the tooltip:, NOT the paramAddDecision($p, "YESNO", "-", $block, tooltip:"Attack_with_X?"). The client renders decision.Tooltip (underscores→spaces) and otherwise shows a generic "Please choose Yes or No:". Putting the prompt in the param (AddDecision($p,"YESNO","Attack_with_X?",…)) leaves Tooltip empty → the player sees the useless generic text. Build a unit-specific prompt from the CardID with CardTitle($obj->CardID) + str_replace(' ','_',$title) (e.g. "Attack_with_" . str_replace(' ','_',CardTitle($cid)) . "?" → "Attack with Obi-Wan Kenobi?"). Verify via TestSchemaStep — the pending entry shows the on-the-wire tooltip. (Regression is blind to this — it never renders the popup.)
    • "Either X or Y" (mandatory branch between two effects)OPTIONCHOOSE with two labeled buttons (e.g. SOR_189 "Ready a resource" / "Exhaust a unit") — not a YESNO (it isn't a decline). Never collapse a genuinely optional effect to an automatic one.

3a. A unit is often its own valid target — check the source's statline, not just the board. "Defeat a unit with 4 or less remaining HP" on a 5/4 unit (Count Dooku) means the source always self-qualifies, so the selection is never a single-target PASSPARAMETER auto-resolve — it's a real MZCHOOSE. For any "[verb] a unit with [stat/trait condition]" where the text doesn't say "another unit" / "enemy unit", verify whether the playing unit itself meets the condition; if so, the test must answer the MZCHOOSE (don't assume auto-resolve). Same for "a friendly unit" effects (the source counts).

  1. Leader deploy is free. Leaders deploy as their epic action — no resource cost. Never add SWUExhaustResources to the deploy path. P1RESAVAILABLE after a solo deploy should equal the pre-deploy resource count.

  2. Any test where a unit leaves play must assert arena COUNTs for both players, not just indexed fields. Per-index assertions (P1GROUNDARENAUNIT:0:CARDID etc.) structurally cannot detect a phantom extra entry sitting at a higher index — a real bug once survived every indexed assertion and was only caught by P1GROUNDARENACOUNT:1. Count assertions are one line each; add them whenever a defeat, sacrifice, or bounce is involved.

  3. Any ability with a cost needs an unaffordable-cost test asserting a full no-op. Costs include printed additional costs (e.g. Palpatine's "defeat a friendly unit"), epic-action conditions ("if you control N or more resources"), and resource-free conditions like "a card in hand to resource" (SOR_017 Han Solo). Assert that nothing happened: source stays READY (or EPICAVAILABLE), resources unchanged, P1NODECISION — the player keeps their action. Reference: GrandAdmiralThrawn_LeaderAction_Unaffordable.md, Palpatine_Deploy_BelowThreshold_NoOp.md.

  4. A fixture that plays a card from hand must cover that card's aspects — or the play silently fails. Each unmatched aspect adds +2 to the ready-resource cost (SWUAspectPenalty); if the fixture can't pay, PlayHand is a silent no-op (arena count stays 0, no error message). Prefer CommonSetup with a base/leader whose aspect codes match the card (e.g. a Cunning+Heroism card → yw Han Solo leader), so the cost stays printed. Only reach for explicit P1LeaderBase when the test genuinely needs specific leaders; if you do use a mismatched leader, budget the +2-per-off-aspect into WithP1Resources. ⚠ DOUBLE-PIP aspect cards ($aspectData = "Aggression,Aggression", "Vigilance,Vigilance", etc. — the SOR aspect events) have two pips of the same aspect, so a fully off-aspect leader/base pays +4 (two unmatched × 2), not +2. Cover both pips (e.g. an Aggression leader and Aggression base) or budget +4 into WithP1Resources. Symptom of underpaying: PlayHand silently no-ops and your modal/effect never starts (the first decision never appears).

  5. Audit EVERY fixture against the trigger's target filter, not just the intended target. A unit added for another purpose — a defender/blocker, a stat-buff bystander — can itself satisfy a "you may target a [trait/aspect] unit" filter and become a second valid target, breaking a single-target assumption (hit when SOR_066, the unit killing Distant Patroller, was also [Vigilance] → had to answer the choose explicitly). Check each fixture's trait/aspect/HP against the predicate.

  6. A WhenDefeated trigger collects targets AFTER the dying unit is already cleaned up — survivors reindex. When the source defeats itself (e.g. attacks into lethal and triggers its own WhenDefeated), the unit is gone from its arena by the time the ability collects, so a survivor that was at a higher index shifts down. Answer the choose with the post-defeat index (Admiral Motti dies at ground idx 0 → the Villainy unit it readies is myGroundArena-0, not -1). For attacker-side OnAttack triggers the attacker is still present, so no shift there.

  7. Test a "random" effect deterministically by collapsing the random space to one outcome. A "discard a random card" / "deal damage to a random enemy" / etc. can't be asserted while multiple choices exist — set the board so exactly ONE is eligible. E.g. "each opponent draws a card then discards a random card from their hand": give the opponent an EMPTY hand + a 1-card deck, so the drawn card is the only one to discard (SOR_190 Lothal Insurgent). Then array_rand over a single index is fixed.

  8. Test leave-play / defeat behavior by PRE-PLACING the end-state board, not by driving the setup actions in the same WHEN. A test that does DeployLeader → AnswerDecision → P2 defeats it couples three subsystems and is fragile (esp. the turn-structure traps below); the deploy/attach is already covered by its own test. Instead seed the exact pre-defeat state and drive only the defeat: e.g. P1LeaderBase: JTL_001:1:1/... (leader ready + deployed flag) + WithP1SpaceArena: JTL_T01:1:0 + WithP1SpaceArenaUpgrade: 0:JTL_001 (the leader-pilot already attached) + WithActivePlayer: 2, then P2>AttackSpaceArena:0:0. Assert the leave-play result (host gone, leader NOTDEPLOYED, counts). Reference: LeaderPilot_Asajj_HostDefeatedInCombat_ReturnsToZone.md. ⚠ Fixture subcards are NOT pilots by default: WithP*ArenaUpgrade builds subcards via GameStateBuilder::Upgrade, which hard-sets IsPilot => false. So any leave-play / capacity logic gated on IsPilot won't see a fixture-placed pilot — recognize a leader-pilot subcard by its leader CardType (a leader can only be a subcard via Piloting), not the flag. (Caught 2026-06-18 building the leader-pilot leave-play tests.)

  9. In an opponent-must-act test, the OPPONENT must afford its own card too. Rule #7's aspect/cost check applies to P2 when P2 plays the card that drives the test (e.g. P2's Takedown needs Vigilance from P2's leader/base; P2 needs enough ready resources). A mis-aspected or under-resourced opponent card silently no-ops, so the defeat/effect never happens and the test asserts initial state — looks like an engine bug but is a fixture bug. Symptom: P2RESAVAILABLE shows P2's resources unspent.


Gate after Step 2 — self peer-review, then proceed (see the policy at the top)

Do NOT blanket-stop here. Run the self peer-review against the CR + game logic + card data (top-of-file policy), confirming for each card:

  • The full list of new DSL commands and assertions needed is correct and wired.
  • Each test drives the real execution path (trace it in the engine code), with stats/costs/aspects derived from the dictionary.
  • The implementation plan is clear (shared infra first, then which card depends on which), and any "new" seam is a verified mechanical mirror of an existing one — not a real design choice.

If that gets you to ≥98% confidence, proceed straight into Step 3 and surface the tests + design in the eventual batch summary so the user can course-correct after.

STOP and present only the specific card you can't verify to 98% on your own (ambiguous ruling, a real design choice on new shared infra, or you can't reach confident correctness) — show its tests, the new DSL, and the open question; keep going on the rest of the batch.


Step 3 — Implement

3a. Shared infrastructure first

If the batch introduces new shared mechanics (e.g. a new discard field, a new zone interaction, a new decision type), implement those before any card-specific logic. These are the foundation the card tests build on.

Only add what the tests actually require.

3b. New DSL commands / assertions

  • New WHEN command → add case 'CommandName': to SchemaTestRunner.php switch + public function commandName() to GameTestAdapter.php
  • New EXPECT assertion → add elseif (preg_match(...)) block to SchemaTestRunner.php assertion section + expose the property on the relevant Accessor in GameTestAdapter.php

3c. Card game logic (one card at a time)

⚠ Where a card's closures actually go (since the session-95 split). A card's own registrations — the $baseAbilities / $leaderAbilities / $whenPlayedAbilities / $onAttackAbilities / $customDQHandlers / … closures for THIS card — now live in the card's own file SWUSim/Custom/cards/<set>/<TitleSubtitle>.php (create it if the card has no file yet; add to the reprint's earliest-printing file for a reprint), NOT in the monolith. The monolith filenames in the "Where to add it" column below name the registry / shared mechanism — treat them as "register into $baseAbilities", "register into $customDQHandlers", etc., and place that registration in the per-card file. What genuinely still lives in shared files (edit those directly): passives in GameLogic.php's ObjectCurrentPower/HP, combat hooks in CombatLogic.php, keyword-grant switches in KeywordEffects.php, cost math in SWUComputePlayCost, new Do*/SWUOffer* helpers, and the AddTrigger/DispatchTrigger reactive plumbing. The registries are appended-to (order-independent), so a per-card file behaves identically to the old monolith entry. Shared-helper offer families (SWUOfferUnitTarget/SWUOfferBaseTarget/SWUOfferDiscard/GiveTokenUpgrade) live in CardHelpers.php; use TraitContains($obj,$trait) for object-aware trait checks (_SWUUnitHasTrait is deleted).

| Ability type | Where to add it | |---|---| | Base Epic Action | $baseAbilities["CARD_ID"] closure (in the card's cards/<set>/<Title>.php; shared BaseAbilities.php holds only families/glue) | | Leader ability | SWUSim/Custom/LeaderAbilities.php$leaderAbilities["CARD_ID"] closure. For the leader (undeployed, front) side ability, prefer the bare base CardID as the key$leaderAbilities["JTL_011"], no :0 suffix. The deployed (unit) side's abilities register in the normal whenPlayed/onAttack/whenDefeated registries under CardID:0 — so the same base CardID cleanly distinguishes the two sides across namespaces (front side = $leaderAbilities["X"], unit side = $onAttackAbilities["X:0"]). | | Timed-ability window — When Played / When Deployed, On Attack, On Defense, When Defeated, On Attack-End, etc. | SWUSim/Custom/CardDQHandlers.php — the matching registry ($whenPlayedAbilities / $onAttackAbilities / $onDefenseAbilities / $whenDefeatedAbilities / …) keyed CardID:0. Every one of these windows uses the :N window-index suffix (:0 for the card's first/only such window, :1 for a second window of the same kind). This is the one exception to "bare CardID" — the trigger windows are the : namespace; leader-front-side abilities and continuation/reactive handlers are bare-CardID/#N. | | Custom DQ handler (post-choice continuation step) | SWUSim/Custom/CardDQHandlers.php$customDQHandlers["HANDLER_NAME"]. Naming (convention v2, #0-based, session-51): a card-specific continuation handler's key is CardID#0 for the first step, then CardID#1, #2, … — continuations are ALWAYS #N-suffixed, never bare ($customDQHandlers["SOR_006#0"]/["SOR_006#1"]/…). The #N suffix is the type-marker: #N = "I'm a continuation step inside an ability's resolution", which is exactly what distinguishes it from a bare-CardID reactive trigger type (AddTrigger row below) and a CardID:N ability-window entry. Number continuations 0,1,2… in order of first encounter, walking the card's abilities leader front-side → when-deployed → on-attack (a continuation shared by several abilities takes the number of the earliest ability that reaches it). Multiple entry points route to the right handler and SHARE a number when the continuation logic is identical even if cost/parameter differ (Dooku TWI_005's leader + deployed sides both queue TWI_005#0; SHD_013). Keep each ability's entry next to its own continuations and order the registrations #0→#1→#2 top-to-bottom (see SOR_006). Cross-card universal handlers keep ALL-CAPS descriptive names (DEAL_UNIT_DAMAGE, HEAL_TARGET, …) — never a CardID. Three separators, one meaning each: : = ability/trigger window index (entry registries, whenPlayedAbilities["X:0"], ["X:1"] for a 2nd window); # = continuation step (here, #0-based) — and an extra reactive trigger type on one card (AddTrigger row); - = dynamic TurnEffect value ("SOR_051-3-3"). All three namespaces are CardID-keyed and don't collide at runtime. Because continuations are always #N and never bare, a #N handler key is now textually distinct from the bare-CardID trigger token / case / TurnEffect token, so handler-key references are safe to blind-replace in a rename. ⚠ When wiring a continuation, every channel that queues it must carry the #N: the 'CUSTOM' AddDecision param, the trailing handler arg of SWUQueueChooseTarget/SWUQueueMayChooseTarget/SWUOpponentChoosesOwnUnit/SWUQueueDefeatUpgrade(thenHandler:)/_topDeckSearchBegin(finalizeHandler), the indirect "KEY#0~args" string, and any $cardID-variable handler (SWUQueueChooseTarget(…, $cardID . '#0')). A missed channel silently no-ops the step — caught by regression only if the card has a test, so cross-check defined-vs-referenced keys after bulk work. | | Reactive trigger (AddTrigger / DispatchTrigger) — a card that arms a delayed/conditional response (on-defeat, on-play-of-X, on-heal, excess-damage, etc.) | SWUSim/Custom/{GameLogic,CombatLogic}.php. The trigger-TYPE string is a third CardID-keyed namespace: bare CardID for a card's first trigger type, then CardID#1, #2 if one card arms multiple distinct trigger types (a reactive trigger is an entry — armed and later fired by an event — so it uses the bare CardID, unlike a continuation step which is always #N-suffixed) — never a PascalCase/card-name token (BlizzardExcess, CassianDraw) or a malformed CardID (JTL156Attack). AddTrigger($player, $type, $cardID, …)$type must equal $cardID (or $cardID#N); the matching case '<CardID>': in the DispatchTrigger switch routes it. ⚠ The dispatcher functions keep descriptive names (CassianDrawTrigger, ShadowCasterReuseTrigger) — only the key strings are CardID-keyed: case 'SOR_013': CassianDrawTrigger(...). When the same string doubles as a customDQHandlers key (Cassian/Kallus), rename both in lockstep. SWU_* GlobalEffect FLAG names keep card-NAMES (SWU_KRENNIC_USED) — separate, intentional, leave them. Bulk-rename safety net (a mis-map here is invisible to any card lacking a test): after a sweep, run the two structural checks that don't depend on coverage — (1) no duplicate case labels in the DispatchTrigger switch (grep -oE "case '[^']+':" \| sort \| uniq -d); (2) every AddTrigger type == its cardID arg (extract the 2nd+3rd args, assert equal). These caught Blizzard's SOR_088JTL_169 mis-map (a sed cascade) that the regression also caught only because Blizzard happens to have a test. The full set→CardID sweep + this safety net = session-50; convention recorded in [[swusim-project]]. | | Upgrade-granted TIMED / REACTIVE ability ("Attached unit gains: 'On Attack…/When attacked…/completes an attack…'") | Mirror the OnAttackFromUpgrade scan — it's a cheap, uniform recipe. (1) declare a new global registry $onXFromUpgradeAbilities (add it to the global …; line at the top of CardDQHandlers.php); (2) at the matching combat collection point, scan the unit's upgrades and AddTrigger($actor, 'OnXFromUpgrade', $upgrade->CardID, $hostMzID) for each upgrade in the registry — CollectCombatStep1Triggers for on-attack/on-attacked (the latter fires for the ATTACKER, passing the defender mzID), CollectAfterAttackTriggers for on-attack-END (the surviving-attacker null-check there IS the "and survives" gate); (3) a DispatchTrigger case → an OnXFromUpgradeTrigger($player,$cardID,$mzID) fn that calls the registry closure; (4) the ability closure keyed by the UPGRADE's CardID. ⚠ For an on-attacked reactive that queues a relative-mzID pick for the actor, route it through an intermediate CUSTOM so the pick's MZCountChoices runs under the actor (DispatchTrigger restores $playerID after the closure). "When this upgrade detaches → owner takes control": don't build a new hook — add the CardID to the SOR_122 Traitorous return-control condition in SWUDefeatUpgrade (one line). Built this session: OnAttackEndFromUpgrade (JTL_197 return-to-hand), OnAttackedFromUpgrade (JTL_260 attacker steals it), JTL_083 detach-return. | | "Can't be [defeated / returned to hand / captured / damaged / exhausted / taken control of] by enemy card abilities" (SHD_187, TWI_220, JTL_103, LOF_040, LOF_073, SEC_012, LAW_149) | One SWUAvoidsX($obj) helper per verb (GameLogic.php), each true if $obj has the immunity via its own CardID, an attached-upgrade grant (_SWUUnitHasUpgrade), or a controller field-passive (_SWUCountUnitsWithCardID + _SWUIsUpgraded). Gate at the single ability chokepoint for each verb, applied ONLY for an ENEMY actor ($actor !== $obj->Controller, or !== Owner for take-control so returning to owner is allowed): SWUBounceUnit / DoCaptureUnit / SWUTakeControlOfUnit / OnExhaustCard (self-exhaust from attacking always passes — actor==controller) / SWUDealDamageToUnit (prevents the instance). Defeat is the subtle one: gate inside SWUDefeatUnit but add a $fromDamage param — the 3 HP-based SBA call sites (damage-lethal in SWUDealDamageToUnit/SWUDealSplitDamage/shrink sweep) pass true so "defeated by no remaining HP" (governed by the SEPARATE SWUImmuneToHpDefeat) and combat (which never calls SWUDefeatUnit) are NOT blocked — only direct "defeat" effects are. ALWAYS add a "combat still kills it" negative guard test. Reference: the 6 SWUAvoids* helpers + SuperheavyIonCannon-adjacent immunity tests. | | "Choose up to N" / "each of up to N" multi-select | Use MZMULTICHOOSE (do NOT simplify to a single MZCHOOSE). AddDecision($player, "MZMULTICHOOSE", "{min}\|{max}\|{specs}", $block, tooltip:"...") where specs is the &-delimited mzID string; the follow-up CUSTOM handler reads the result with explode("&", $lastDecision) (skip "-"/""/"PASS" = chose none). Empty target list → return (fizzle), don't queue. Tests answer it with AnswerDecision:mz-0&mz-1 (the & is preserved). Renders in the real UI as green field-selectable units (verify via UI smoke test). Reference: SOR_080 General Tagge (Experience to up to 3 Troopers). ⚠ The modal's "Select All" button only renders when {max} === candidate count (Core/MZMultiChooseUI.js) — for an "any number" effect (max 99) cap it: effectiveMax = min($max, count($specs)), else Select All is silently hidden. ⚠ A scripted AnswerDecision does NOT enforce the decision's {max} or its offered target set — the harness feeds your answer straight to the handler. So a test that answers N mzIDs and asserts the outcome can pass GREEN even when the engine actually offered only 1 (a live UI would show "up to 1") — a false positive that masks an enumeration/cap bug. Two consequences: (1) the resolver must validate the answer itself — re-derive the offered set + cap inside the CUSTOM handler and ignore picks outside it / beyond {max} (then "answer + assert outcome" tests genuinely guard the offer; reference: EXPLOIT_RESOLVE validates against SWUExploitFodder and caps at the effective Exploit X); and/or (2) assert the offered max in the test rather than only the result. When a "select up to N" card relies on the UI to enforce the cap, you have NOT tested the cap — verify the real offered max via TestSchemaStep or harden the handler. (Caught 2026-06-16: a Dooku Exploit-5 test passed by answering 5 token mzIDs while the engine offered only 1, because token units were excluded from the fodder enumeration and the handler defeated whatever it was handed.) | | "Deal N damage divided as you choose among units" (split / divided damage) | MZSPLITASSIGN → the universal SPLIT_DAMAGE handler → SWUDealSplitDamage($player, $lastDecision) (GameLogic.php). AddDecision($player, "MZSPLITASSIGN", "{amount}\|{mz1&mz2&…}", $block, tooltip:"...") + CUSTOM SPLIT_DAMAGE; answer/$lastDecision is comma-separated mzID:amount. Collect targets, if (empty) return (fizzle). Do NOT use the GA ProcessSplitDamage — it deals-and-cleans-up per hit, so a unit killed by its share stales a co-target's mzID (index-shift bug). SWUDealSplitDamage is SWU-correct: snapshots target UIDs, applies ALL damage, then resolves defeats in one sweep. Rules: divided damage is simultaneous (all applied, then defeats resolve) and the full pool must be assigned (overkill onto one unit is legal — only fizzles with zero targets); the UI gates confirm on remaining == 0, tests submit a full assignment (AnswerDecision:theirGroundArena-0:4,theirSpaceArena-0:2). MZSplitAssignUI.js is already <script>-included in NextTurn.php. References: SOR_135 Palpatine (deal-split), SOR_092 Overwhelming Barrage (buff a dealer, then split its buffed ObjectCurrentPower). | | "Heal/distribute up to N" (partial-OK split) | Same MZSPLITASSIGN, with the "up to" mode: param "{amount}\|{mz1:cap1&mz2:cap2&…}\|UPTO" — per-target caps (:cap) + the trailing \|UPTO flag let the player submit with points unassigned (heal less than N) and cap each target (e.g. at its current Damage, so you can't over-heal). Backward-compatible: no :cap → cap = pool; no \|UPTO → must assign all (the damage-split default). For a split-HEAL (SOR_052 Redemption: "heal up to 8 across units/bases, then deal that much to itself"), the handler heals each via OnHealUnit/OnHealBase (they clamp at 0 and fire the heal animation), and sums the ACTUAL healed (read Damage/base damage before→after) — "deal that much" reads actual-healed, NOT the assigned amount. Bases are targets via myBase-0/theirBase-0. Test the over-assign-clamps case (assign 6 to a 2-damage unit → heals 2, self-damage 2). Reference: SOR_052. | | "You may [target a unit]" (optional single-target) | MZMAYCHOOSE — one pick-or-pass popup, via SWUQueueMayChooseTarget(...) (see the choose-target helper note below). NOT a YESNO + separate choose. No auto-resolve even for one target; the follow-up handler must no-op on a '-' decline. Reference: SOR_010 Vader, SOR_050 The Ghost. | | "Choose a player / arena / direction" (named options), or a mandatory "Either X or Y" branch | OPTIONCHOOSE. AddDecision($player, "OPTIONCHOOSE", "Ground&Space", $block, tooltip:"...") + a CUSTOM handler reading the chosen label from $lastDecision. A decision's Type is opaque server-side (answerDecision just feeds $lastDecision regardless), so a new named-choice flavor costs ~nothing server-side — the work is the client UI (a Core/<X>UI.js modeled on NumberChooseUI.js + a branch in the UILibraries…js decision dispatcher + a <script> include in NextTurn.php). Option labels render verbatim (no underscore→space conversion) BUT must be single-token — no spaces: DecisionQueue's explode(" ") truncates a multi-word Param at the first space, so "Ready a resource&Exhaust a unit" is stored as just "Ready" (SOR_189's labels are a latent display bug). Use single words and carry detail in the tooltip. Optional card image: prefix the param with "@CardID&" (e.g. "@{$topID}&Play&Discard&Leave") to show the card being acted on above the buttons — OptionChooseUI renders leading @-segments as images and excludes them from the options (SOR_119 / SOR_192 "look at the top card"). Tests answer via AnswerDecision:Ground (bypass the UI). Reference: SOR_221 Outmaneuver ("Ground&Space"), SOR_171 Mission Briefing ("You&Opponent"), SOR_189 Leia (either/or), SOR_192 Ezra ("@CardID&Play&Discard&Leave"). | | "Choose two (different modes), in any order" modal (the aspect events SOR_058/107/155/203) | NO new decision type / UI — reuse sequential OPTIONCHOOSE: pick 1-of-N → resolve that mode → pick 1-of-(N−1) → resolve. Generic driver SWUQueueModalChoose($player,$cardID,$labels,$picksLeft,$block=1) (GameLogic.php) emits an OPTIONCHOOSE of single-token mode labels + MODAL_CHOOSE|{cardID}|{picksLeft}|{block}|{labels}; MODAL_CHOOSE calls per-card _SWUModalResolveMode($player,$cardID,$label) (a switch mapping each label to an existing primitive — GIVE_SHIELD/DEAL_UNIT_DAMAGE/HEAL_TARGET/BOUNCE_UNIT/READY_UNIT/SWUMillTopCard/SWURampResourceReady/etc.) then queues the next picker at $block+1. ⚠ The increasing block is load-bearing for MULTI-STEP modes (e.g. "a friendly unit deals its power to a non-unique enemy" = dealer→target, 2 sub-decisions): a mode's chained sub-decisions run at block 1, so the next picker MUST be at a higher block or it interleaves between them. (Single-decision modes work at any block.) Labels single-token (Discard6/PowerStrike/DefeatUpgrades), detail in the tooltip. Each mode collects its own targets (filter by remaining-HP / power / unique as the text says) and fizzles cleanly with no target. ⚠ "Defeat up to N upgrades" must span DIFFERENT unitsSWUQueueDefeatUpgrade(max:N) is HOST-SCOPED (one host, up to N of ITS upgrades). For cross-unit, chain N "may defeat 1" flows via the thenHandler param (SWUQueueDefeatUpgrade(max:1,min:0,may:true,thenHandler:'NEXT'); the next link fires at the normal end of DEFEAT_UPGRADE#1, re-reading the board so picks span units — SOR_155). The modal + chained TempZone DOES drive in regression (single-pick MZMAYCHOOSE per link). ⚠ Defeated NON-token upgrades go to their owner's discard — a "defeat 2 upgrades" test's P1DISCARDCOUNT = event + 2 (a classic bad-EXPECT trap; tokens are set aside, not discarded). Reference: SOR_058/107/155/203. | | "Search top N of deck for X, draw/play it" | One-liner on the proven primitives in GameLogic.php: DoTopDeckSearch($player, $n, $filterPredicate, $maxPicks) (reveal+draw the picks) or DoTopDeckPlay($player, $n, $filterPredicate, $costBudget [, $maxCount]) (play units free within a cost budget, optional count cap → frontend cost:N:M). Predicate is fn($c) => HasTrait($c,'Rebel') / fn($c) => CardType($c)==='Unit'. Test AnswerDecision:CARDID (single) or CARD1,CARD2 (multi) or empty (none); fillers must NOT match the filter so the match set is exactly the intended pick(s). Reference: SOR_084/087/096/104/123/125. "Search your ENTIRE deck" (SOR_042 Search Your Feelings) is just DoTopDeckSearch($p, count(GetDeck($p)), fn=>true, 1) — peeking the whole deck IS a full search (private to the searcher), and _topDeckPutRemainingToBottom reshuffles the rest (a full reshuffle since the whole deck was peeked). No new UI. | | Mass-defeat / mass-op over many units ("Defeat all units", AOE) (SOR_043 Superlaser Blast) | Snapshot every UniqueID first, then act by UID (SWUFindMzByUIDSWUDefeatUnit) so the index shift from each defeat can't stale the loop — same discipline as SWUDealSplitDamage. AnyUnitFilter across all 4 arenas includes deployed leaders (returned to zone) + tokens; each defeat fires WhenDefeated via the SWUDefeatUnit collector. ⚠ Test discard-count gotcha: a player's OWN defeated units go to THEIR discard, so an event that defeats both sides puts (event + own units) in the caster's discard and (their units) in the opponent's. | | Cross-arena attack ("This unit can attack units in the [other] arena") (SOR_212 Strafing Gunship) | NO FSM changes needed — combat is fully mzID-driven (ExecuteSWUAttack/SWUCombatDamage resolve theirGroundArena-N regardless of the attacker's arena). The ONLY arena-restricted spot is SWUGetValidAttackTargets — append the cross-arena enemy mzIDs there for the specific card (in the non-Sentinel-restricted path; cross-arena Sentinel/Sabine is an unhandled edge). For a "while attacking a [arena] unit, the defender gets −X/−0" rider: ⚠ the SWU_DEF_DEBUFF_N marker lives on the ATTACKER (SWUCombatDamage reads it from the attacker to cut the defender's counter-power, ~line 558; Jyn SOR_018 precedent), NOT the defender — AddTurnEffect($attackerMzID, 'SWU_DEF_DEBUFF_2') when the target is in the cross arena (consumed in combat, one-shot, −0 HP is a no-op). DSL: extend AttackSpaceArena:idx:G<n> (the harness feeds the target mzID as the MZCHOOSE answer — no re-validation, so isolate to ≥2 valid targets so the MZCHOOSE is pending). Reference: SOR_212. | | Iterative "reveal/do one at a time until you stop or hit N" (accumulator loop across requests) (SOR_223 Don't Get Cocky) | Carry the running state in the CUSTOM handler param (no SWUVar): each step re-queues HANDLER|{targetUID}|{csv-accumulated-state} + a YESNO. A step does one unit of work (array_shift the deck top, log a public REVEAL, append its CardID), then resolves if the hard cap (≥N) OR the resource is exhausted (deck empty), else queues the next YESNO. Resolve reads the accumulated CSV (sum CardCost, apply the effect, _topDeckPutRemainingToBottom to return cards shuffled). Test every stop-condition: stop-early, "bust" (over the threshold → no effect), resource-empties-mid-loop auto-stop, and the hard-N cap (answer YES (N−1)× then assert the Nth auto-stops with P1NODECISION). Reference: SOR_223. | | Passive hook on the OPPONENT playing an event (surcharge / blank-first-event) (SOR_153 Saw Gerrera: opponent's event costs 2 base damage; SOR_089 Relentless: opponent's first event each round loses all abilities) | Hook ActivateCard's event branch (right after the event moves to discard, before OnPlayEvent), gated on the EVENT-PLAYER's opponent controlling the source: _SWUCountUnitsWithCardID(OtherPlayer($player), $cid) > 0. Surcharge = a side-effect at play time (e.g. SWUDealDamageToBase(2, $player)) — always payable, NOT a $playCostFieldModifier (those are resource-cost only, so no double-charge). Blank an event = set $eventBlanked and SKIP OnPlayEvent (and the alt-cost YESNO); the event still discards. "first … each round" = a per-player GlobalEffects flag (SWU_EVENT_PLAYED_ROUND) set on every event play, checked-before-set, cleared at RegroupPhaseStart (the round boundary). Test the "first only" rule with a second event in the same round (P2 event → P1 pass → P2 event) that DOES resolve. Reference: SOR_153, SOR_089. | | "Put this card into play as a resource" | SWURampResourceReady($player, $mzID) (GameLogic.php) — zone-agnostic, moves a card from hand/deck/discard into the resource zone READY. The only per-card work is finding the source mzID: for "put this unit/event into play as a resource" (SOR_083 on defeat / SOR_126 event), the card is already in DISCARD when the ability resolves — locate it with _SWUFindDiscardMzID($player, $cardID) (returns any non-removed copy; that's correct for simultaneous defeats). | | Deck mill ("discard a card from a deck", "discard N from the defending player's deck") (SOR_047 Kanan, SOR_204 Greedo, SOR_188 Chopper) | SWUMillTopCard($player) discards the top of $player's deck (to discard, From:DECK) and RETURNS the milled CardID (null on empty deck) — call it N times for "discard N". "Defending player's deck" = the opponent in 2-player (same as IG-88's "defending player"). React on the milled card's properties via the returned CardID: CardType($milled)strpos(…, 'Unit') === false = "if it's not a unit" (SOR_204 → deal 2 to a ground unit), strpos(…, 'Event') !== false = "if it's an event" (SOR_188 → SWUExhaustResources($defender, 1)). "per DIFFERENT aspect among the discarded cards" = collect CardAspect($milled) (comma-split into individual icons) into a SET keyed by aspect string, then count() the DISTINCT keys (NOT total mentions — 2 cards each Aggression → 1) and OnHealBase($p,$p,$distinct). Conditional keyword by TRAIT (Chopper "Raid 1 while you control another Spectre") → a case in GetConditionalKeyword_Raid_Value with a HasTrait loop over GetUnitsInPlay($obj->Controller) excluding self UniqueID (the existing cases use the aspect helper PlayerHasUnitWithAspectInPlay — trait ≠ aspect). Seed/assert decks with WithP1Deck/WithP2Deck + P*DECKCOUNT/P*DISCARDCOUNT. References: SOR_047, SOR_204, SOR_188. | | Return a card from a discard pile / the resource zone to its OWNER's hand (SOR_183 Bounty Hunter Crew, SOR_197 Lando) | NOT BOUNCE_UNIT (that's arena-only). Discard → hand: SWUReturnFromDiscardToHand($player, $discardMzID) moves to $player's OWN hand (relative MZMove(... "myHand")). For "an event from ANY discard pile to its owner's hand", route by the pile via SWUReturnDiscardCardToOwnerHand($player, $mzID)theirDiscard-N re-resolves as the opponent's myDiscard-N so the card lands in the OPPONENT's hand (the discard pile's player owns its cards, CR 7.5). Collect events with ZoneSearch('myDiscard', ['Event']) + ZoneSearch('theirDiscard', ['Event']). Resource → hand: SWUReturnResourceToHand, or inline AddHand($owner, CardID:$o->CardID) per chosen resource. ⚠ Resource/zone Owner is often 0 (unset) — default to the controller ($owner = intval($o->Owner ?? 0); if ($owner <= 0) $owner = $player;), else AddHand(0) silently drops the card (the unit's RESCOUNT/COUNT still drops, but HANDCOUNT stays 0 — the symptom). "Return up to N": MZMULTICHOOSE "0|N|{specs}" → snapshot the chosen objects BEFORE any removal (each removal shifts indices), then mark removed + AddHand for all, then ONE CleanupRemovedCards. References: SOR_183, SOR_197. | | Pick among a unit's upgrades / any subcard ("defeat an upgrade", "defeat any number of upgrades", future captive/subcard picks) | Subcards have NO mzID, so stage them into the TempZone zone (a per-player Mode=None Value zone) to give them real myTempZone-N mzIDs — then reuse the normal decision UIs with zero new client code (mirrors GA's Exorcise Curses). The generic entry point is SWUQueueDefeatUpgrade($player, $tooltip, may:, max:, filter:, min:) (GameLogic.php) → host pick (MZMAYCHOOSE/MZCHOOSE/PASSPARAMETER) → DEFEAT_UPGRADE resolves the host via _SWUResolveDefeatUpgradeHost, which stages the filter-matching upgrades and queues the pick by $min/$max: max<=1MZCHOOSE (min≥1) or MZMAYCHOOSE (min 0) — these render myTempZone-N in the card-image popup because CategorizeMZChooseSpecs routes DisplayMode None/Single specific-card specs to ShowMZChoosePopup; max>1MZMULTICHOOSE (modal with Select All / Clear). Then DEFEAT_UPGRADE#1 defeats the picks. Three rules that make this robust: (1) positional map, not CardID re-matching — stage in GetUpgradesOnUnit-index order, stash that index list (matchIdx) in a DQ variable, map myTempZone-N → matchIdx[N], sort the chosen real indices DESCENDING before SWUDefeatUpgrade (index-shift safe; duplicate-safe — identical upgrades never ambiguous); (2) window.myTempZoneData is auto-emitted once TempZone is in Module: Versions= (NextTurnRender re-strides itself + the timestamped GeneratedUI_*.js gets a GetZoneData case; loader auto-picks the newest via glob(...)[0]) — no transport hand-patch, but you MUST regen (and a regen can surface a latent schema gap, see the debugging section); (3) drain TempZone at every exit, and leave $playerID set when queuing the relative-mzID pick (the MZCountChoices gotcha). SWUUpgradeMatchesFilter($cardID, $filter) is the upgrade predicate (unique/leader/cost, comma-AND'd; empty = any). Reference: SOR_162/SHD_166 (may, single), SOR_251/SHD_262 (mandatory, single), SOR_170 (any number, min 0). | | New zone input (e.g. myBase) | SWUSim/Custom/CustomInput.php — new case in CustomWidgetInput | | Missing Do* helper | SWUSim/Custom/GameLogic.php — near other Do* helpers | | "A unit loses <keyword> for this phase" (keyword suppression) | Tag the target with a TurnEffect equal to the suppressor's CardID (AddTurnEffect($mz, "SOR_140")), and register it in $keywordSuppressors in KeywordEffects.php ('SOR_140' => ['SENTINEL']). The generated HasKeyword_* calls SWUKeywordSuppressed($obj, 'KW') first, so suppression overrides innate/granted/conditional. The bare CardID doubles as the Active Effects UI source. Adding a new "loses X" card = one registry line, no regen. | | "A unit loses ALL abilities" / "can't gain abilities" | Use the LostAbilities($obj) gate in KeywordEffects.php — do NOT rely on HasNoAbilities, which exists but is unwired for SWU (only the play-cost loop + unrelated GA cards read it; grep the consumers before trusting it). LostAbilities recognizes the sources (extend the list): a "this phase" TurnEffect marker = the suppressor's CardID (e.g. 'SOR_138', registered in $turnEffectRegistry as ['kind'=>'LOSE_ABILITIES'] and expired centrally by SWUExpireTurnEffects — see "Turn-effect registry & durations") and an attached lose-abilities upgrade (e.g. SHD_072, via GetUpgradesOnUnit). It's wired at every ability surface so the unit has none AND can't gain: SWUKeywordSuppressed returns true for it (one line → ALL keywords off, beating innate/granted/conditional/upgrade), the OnAttack fire point (CombatLogic.php HasOnAttackAbility check), SWUUnitActionAffordable (activated "Action [Exhaust]:" abilities), and the commander/field passive providers (SWUTraitCommanderBonus, SWUEnemySnokeCount — a source that lost abilities stops granting; stat buffs to the recipient are NOT abilities, so a unit that lost abilities still receives +X/+Y). A lose-abilities upgrade (SHD_072) is enemy-targetable — add its CardID to the SOR_122 case in SWUGetUpgradeValidTargets (any non-leader unit, any arena). Reference: SOR_138 Force Lightning, SHD_072. | | Passive (continuous while-in-play effect) | SWUSim/Custom/GameLogic.php — in ObjectCurrentPower / ObjectCurrentHP overrides, or in CollectEntryTriggers for keyword grants. ⚠ Two definitions exist: the LIVE SWU ObjectCurrentPower/ObjectCurrentHP are at the TOP of the file (~line 157/203); a DEAD GA fallback (if(!function_exists(...)), never declared) sits at ~line 10555. A case in the 10555 block is dead code — confirm grep -n "function ObjectCurrentPower" and edit/verify only the FIRST (live) one. A card appearing as a case only in the 10555 block is NOT implemented (this gave a false positive on SOR_230/SOR_161). For "other friendly {trait} units get +X/+Y", use one shared helper called from BOTH live functions, self-excluded by UniqueID (see SWUTraitCommanderBonus — SOR_230 Imperial, SOR_242 Rebel). | | Trigger at a phase boundary ("when you ready cards during regroup", "at the start of the action phase") | The phase handler in GameLogic.phpReadyPhase, ActionPhaseStart, RegroupPhaseStart, DrawPhase, ResourcePhase. Queue the decisions there; auto-advance pauses while any queue is non-empty, so the phase cycle waits for the player. (SOR_193 Millennium Falcon hooks ReadyPhase.) | | Delayed "at the start of the next phase" effect | Arm a GlobalEffects flag when the source resolves; consume it in the target phase handler (SOR_017 Han Solo arms SWU_HAN_DEFEAT_RESOURCE, consumed in ActionPhaseStart). See the delayed-trigger gotcha in 3d. | | Combat-hit triggers: "When this unit deals combat damage…" / "…attacks and defeats a unit" (SOR_085 Rukh, SOR_149 Mace, SOR_133 Seventh Sister, SOR_088) | SWUCombatDamage captures a $combatCtx = {dealtToBase, dealtToUnit, defenderDefeated, defenderIsLeader, excess} at the damage-application points (dealtToUnit is set ONLY when the hit is NOT shield-absorbed and $attackPower > 0 — a shield-prevented hit deals no combat damage, so it must NOT trigger; set it in BOTH the Shoot First and normal branches; defenderDefeated = $defenderHP <= 0 && !SWUImmuneToHpDefeat($target); excess = max(0, -$defenderHP) = the Overwhelm-style overkill). Thread $combatCtx through CollectCombatStep3TriggersCollectAfterAttackTriggersSWUCollectCombatHitTriggers, which AddTriggers the attacker's combat-hit ability into the SAME bag as OnAttackEnd (so it rides the EffectStack flush — decision-based ones like "you may deal 3/excess to an enemy ground unit" defer SWUAfterAction). Add a DispatchTrigger case per card → a handler (RukhDefeatTrigger defeats the still-alive non-leader defender via SWUDefeatUnit — note a raw SWUDefeatUnit does NOT cascade the defeated unit's own WhenDefeated, fine for these; MaceReadyTrigger OnReadyCards the attacker; the "deal N to an enemy ground unit" ones use SWUQueueMayChooseTarget(..., 'DEAL_UNIT_DAMAGE|N') over theirGroundArena). Ride extra params (excess) through AddTrigger's extraParamsDispatchTrigger's $extra. ⚠ A GRANTED (non-CardID-keyed) combat-hit trigger — e.g. SOR_150 Heroic Sacrifice's "this attack, the chosen unit gains 'when it deals combat damage: defeat it'" — can't ride the switch ($attacker->CardID) (it's granted to any unit via a per-attack TurnEffect marker). Add a marker check AFTER the switch. But the marker must be a SWU_DUR_ATTACK token captured into $combatCtx at combat START, NOT read live in SWUCollectCombatHitTriggers: SWUCombatDamage runs SWUExpireTurnEffects('attack') (line ~638) BEFORE CollectCombatStep3Triggers (line ~643), so by the time the collection runs the attack token is already stripped — reading $attacker->TurnEffects there finds nothing. Snapshot it where $combatCtx is initialized (e.g. $combatCtx['attackerSelfDefeat'] = (attacker has the SOR_150 marker)), exactly like $hasShootFirst is read into a local early; then the collection checks $combatCtx[...]. The granted trigger fires on combat damage to a unit OR base (dealtToBase || dealtToUnit); the DispatchTrigger handler defeats the attacker ($attackerMzID) — no leader exclusion, since "defeat it" defeats the source itself. Reference: SOR_150. Integration-test a granted keyword via a base epic — ECL (SOR_022, already implemented) plays a ≤6-cost unit with Ambush; a unit that ALSO has an entry keyword (Rukh = Shielded) then has TWO entry triggers → answer the trigger-order MZCHOOSE with EffectStack-0 first, then the Ambush YESNO (the shield resolves and absorbs the ambush counter-damage → attacker ends undamaged). References: SOR_085, SOR_149, SOR_133, SOR_088, Rukh_ECL_AmbushAttack_Defeats.md. | | Reactive "when an enemy unit is defeated / leaves play" (SOR_036 Gideon, SOR_015 Boba) and granted "When Defeated" (SOR_105 Krell) | All defeat sources funnel through ONE collection point — CollectWhenDefeatedTriggers($activePlayer, $defeatedCards), where each $d['player'] is the defeated unit's controller and the unit is still in-array (removed=true, pre-cleanup). The single funnel is SWUDefeatUnit itself (it collects right after the null-check, before cleanup) — so combat step 3 AND every effect defeat (the DEFEAT_UNIT DQ handler → Takedown SOR_077 / Vanquish SOR_078, sacrifices, shrink-sweeps) all fire WhenDefeated + leave-play reactions automatically. ⚠ Do NOT also pre-collect in the damage paths (SWUDealDamageToUnit / SWUDealSplitDamage once did — removed 2026-06-15); they now just call SWUDefeatUnit, which is the only collector, so a direct-defeat effect (no combat, no damage event) correctly triggers Gideon/Boba/Krell. Before this centralization, DEFEAT_UNIT bypassed collection and those reactions silently missed on effect-kills. Guard test: play Takedown on an enemy unit and assert the reaction fires (BobaFett_EnemyDefeatedByEffect_ReadyResource.md, GideonHask_EnemyDefeatedWithEvent_GivesExp.md). Add reactions there via SWUCollectLeavePlayReactions($leftCards, $defeated) before the flush: for each leaver it sets the SWU_ENEMY_LEFT_PLAY phase flag on the opponent (cleared at RegroupPhaseStart; set here in step 3 before CollectAfterAttackTriggers, so a unit the attacker just defeated counts for an OnAttackEnd "if an enemy left play this phase" check), and AddTriggers the observers — a Gideon (SOR_036) controlled by the opponent → GideonExp; Krell's grant (the leaver's controller has SOR_105 in play AND the leaver isn't Krell — "each OTHER friendly unit") → KrellDraw. Each reaction gets a DispatchTrigger case → a function that queues its decision (MZCHOOSE/YESNO). For "leaves play" broader than defeat (bounce, capture), also call SWUCollectLeavePlayReactions(…, false) + FlushTriggerBag from SWUBounceUnit (and any other leave-play primitive) — defeat is only one way to leave play. Leader-side reactive abilities are active ONLY while the leader is undeployed (_SWULeaderReadyUndeployed($player, $cardID) — checks leader-zone CardID + !Deployed + Ready); once deployed, the unit-side (deployTextData, e.g. Boba's OnAttackEnd) replaces them, so the two never both fire. "Ready up to N resources" = SWUReadyResources($player, $n) (resources are fungible → auto-ready N exhausted, no choice). An "always-yes" optional reaction ("you may exhaust this leader; if you do, ready a resource" — Boba; like SLT SOR_083's "may put into play as a resource") should auto-resolve with NO prompt, but only when it benefits the player — gate it at collect time (SWUResourceCount($p) > SWUResourceCount($p, true) = an exhausted resource exists) and skip entirely when there's nothing to gain (don't pay the cost / don't ask). Confirm with a "full resources → no-op, leader stays READY" guard test plus a bounce-trigger test (use Waylay SOR_222). References: SOR_036, SOR_105, SOR_015. | | Reactive "When you play an event / a card" (own-play reaction) (SOR_182 Bossk: play an event → may deal 2 to a unit; SOR_143 Fighters for Freedom: play another [Aggression] card → may deal 1 to a base) | Mirror the existing opponent-play collector, don't invent new dispatch. There is already SWUCollectOpponentPlayReactions (TWI_210) wired at the three play sites. Add the own-play twin SWUCollectOwnPlayReactions($playingPlayer, $playedCardID, $playedUID=0) (GameLogic.php) and call it right next to each SWUCollectOpponentPlayReactions call — CollectEntryTriggers (unit entry; pass the played unit's $obj->UniqueID), CollectWhenPlayedAsUpgradeTriggers (upgrade; UID 0), and the event-branch block-5 handler ($customDQHandlers["TWI_210"]; UID 0). This gives identical, proven coverage (incl. discard plays that route through ActivateCard) for free. The collector reads CardType($playedCardID)/CardAspect($playedCardID) and AddTriggers each friendly observer in GetUnitsInPlay($playingPlayer) (event-filter for Bossk, Aggression-aspect-filter for FFF) → a DispatchTrigger case → a reaction fn using SWUQueueMayChooseTarget. "another [X]" self-exclusion = $uid !== $playedUID (the just-played unit doesn't trigger its own copy; events/upgrades pass UID 0 so they never self-exclude). Bossk's "a unit" always self-qualifies → no no-target fizzle. Test with a 2nd copy of the reactive card as the "another [Aspect]" fixture (doubles as the self-exclusion proof — exactly ONE trigger) and Confiscate (neutral event) as the clean event / non-Aggression guard. References: SOR_182, SOR_143. | | Cross-player decision: "an opponent chooses … during YOUR action" (SOR_040 Avenger / SOR_041 PotDS: opponent defeats their own unit; SOR_187 I Had No Choice: opponent picks among a caster-chosen set; SOR_233 I Am Your Father: opponent YESNO branch; SOR_145 K-2SO: each-opponent OPTIONCHOOSE; SOR_174 Smoke and Cinders: each player keeps N) | MZCountChoicesMZZoneCount resolves my/their via the GLOBAL $playerID, so a relative-mzID decision queued for the OPPONENT must be validated under $playerID = opponent. Two routing rules make this robust: (1) ExecuteStaticMethods does NOT save/restore $playerID around a CUSTOM handler (line 81) — so a CUSTOM can leave $playerID = opp for the next decision's validation — but DispatchTrigger DOES restore it after a WhenPlayed/OnAttack/WhenDefeated closure (line ~2420). ⇒ A trigger closure must queue the cross-player work via an intermediate CUSTOM, never inline (events in OnPlayEvent don't restore, so inline is fine there, but the intermediate CUSTOM works uniformly). (2) Leave $playerID = opp on return when you queue the opponent's relative-mzID MZCHOOSE/MZMULTICHOOSE. Forced single-target cross-player → resolve SYNCHRONOUSLY (a cross-player PASSPARAMETER auto-resolve is fragile — cf. SOR_167); collect under $playerID=opp, and on count==1 just call the effect directly. Reusable helpers: SWUOpponentChoosesOwnUnit($caster,$nonLeader,$tooltip,$handler) + universal OPP_DEFEAT_OWN_UNIT|{nonLeader} (opponent defeats one of THEIR units — 0 no-op / 1 synchronous / 2+ MZCHOOSE). "opponent picks among a CASTER-chosen set" (SOR_187): carry the chosen units by UniqueID (stable across perspective + mutation), re-resolve to opp-perspective mzIDs under $playerID=opp, leave it set; map the answer back by UID in the follow-up. Cross-player YESNO (SOR_233) needs NO $playerID care (no relative mzIDs) — just AddDecision($controller,"YESNO","-",1,tooltip:…) + a #1 continuation carrying {caster}|{targetUID}. SWUDiscardCards($p,n) already makes OtherPlayer($p) discard; SWUKeepNDiscardRest($p,$keep,$tip) (each player keeps N — CleanupRemovedCards first so spec mzIDs match compacted indices, queue opponent's before caster's so $playerID is left = caster). New leave-play primitive SWUUnitToBottomOfDeck($p,$mzID) (sibling of SWUBounceUnit). All these cross-player shapes drive cleanly in regression within ONE action (see the regression-divergence note). References: SOR_040/041/187/233/145/174. | | "Attack with a unit. Then (you may) attack with another" (chained / sequential attacks — SOR_009 Leia, SOR_103 Rebel Assault) | The first attacker is chosen normally; the follow-up handler arms SetSWUVar('SWU_CHAINED_ATTACK', "{rebelOnly},{mayDecline},{bonus},{excludeUID}") (comma-delimited — see the SWUVar gotcha) then calls BeginSWUAttack. The chained attack must fire after the first attack's FULL trigger resolution (so it nests correctly when the first attacker is e.g. the deployed Leia, whose own OnAttackEnd chain must resolve first). This rides the EffectStack SWU_TRIGGER_RESUME stack-empty handler, NOT the flat FlushTriggerBag: CollectAfterAttackTriggers flushes after-attack triggers via FlushEntryTriggerBag (the EffectStack path — the flat path queues sibling triggers that interleave), and the resume's stack-empty branch fires ChainedAttackTrigger($spec)SWUQueueAnotherAttack(...)CUSTOM CHAINED_ATTACK|{bonus} (one-shot SWUAddAttackPowerBonus + BeginSWUAttack), re-queuing a resume so the next attack also defers SWUAfterAction. If no trigger flush queued a resume (first attacker has no OnAttackEnd), CollectAfterAttackTriggers queues a bare resume when the var is set. This handles arbitrary nesting AND a declined optional OnAttackEnd (the chain still fires). The deployed-leader OnAttackEnd side ("you may attack with another") calls SWUQueueAnotherAttack directly (already in a post-attack trigger context). Note CollectAfterAttackTriggers only ever holds the single OnAttackEnd (WhenDefeated was flushed separately), so the EffectStack 2+ MZCHOOSE-ordering path is never hit. "More units than the defending player" (IG-88 SOR_012's conditional +1/+0) = the opponent in 2-player, so resolve it before the target is chosen. References: SOR_009, SOR_012, SOR_103, RebelAssault_PicksLeia_NestedChain.md. | | "Isn't defeated by having no remaining HP" (deployed-leader HP-defeat immunity) | There is NO single SBA — HP-defeat happens at 5 sites: both spots in SWUCombatDamage ($attackerHP<=0 / $defenderHP<=0), SWUDealDamageToUnit, SWUDealSplitDamage's sweep, and SWUCheckShrinkDefeats. Add one predicate (SWUImmuneToHpDefeat($obj)) and gate ALL FIVE with && !SWUImmuneToHpDefeat(...). Tie the window to the phase via GetCurrentPhase() — e.g. Chirrut SOR_004 is immune while GetCurrentPhase() !== 'RGS'; at regroup RegroupPhaseStart runs SWUCheckShrinkDefeats while the phase is 'RGS' (confirmed: SetCurrentPhase('RGS') precedes the handler), so immunity lifts and he's swept then. This is HP-defeat only — a "defeat target unit" effect still works. Test the interaction with "for this phase" debuffs: RegroupPhaseStart expires SWUDEBUFF_/SWUBUFF_ before the sweep, so a unit shrunk to lethal during the action phase (and kept alive by the immunity) can survive regroup if the debuff's removal lifts its damage back below HP (Chirrut + Open Fire 4 dmg + Make an Opening −2/−2 → lives). Reference: SOR_004. | | "This unit can't be attacked [while condition]" (SOR_142 Sabine Wren: protected while ≥3 aspects among other friendly units, unless she has Sentinel) | Exclude the unit from SWUGetValidAttackTargets (CombatLogic.php) — continue past it in the opponent-units loop when the condition holds. Gate on !HasKeyword_Sentinel($u) (an "unless she gains Sentinel" clause flips protection OFF and makes her a forced Sentinel target instead). This is the SWU-correct mechanism: the opponent's valid-target list (UI glow + auto-fire + MZCHOOSE options) simply omits her. ⚠ The server does NOT re-validate a MZCHOOSE attack-target answer against the offered list (a general gap), so a direct AttackGroundArena:idx:protectedIdx in a test BYPASSES the exclusion and hits her. To test "can't be attacked," isolate the protected unit as the ONLY unit in the attacked arena (put the condition-enabling units in the other arena) so the attack has just the base left and AUTO-FIRES at it — that proves the exclusion. (For aspect-counting use CardAspect comma-split into a set, same as Kanan/Sabine; "other friendly units" excludes the unit itself by UID.) Reference: SOR_142. | | "Choose any number of players. They each draw / …" (SOR_045 Yoda When Defeated) | 2-player: an OPTIONCHOOSE with single-token labels You&Opponent&Both → a handler that draws for the chosen set (You/Both → controller; Opponent/Both → opponent). (Multiplayer Twin Suns will swap this for per-player checkboxes + confirm; keep the 2-player 3-way for now.) Reference: SOR_045. | | "... can't ready this round (including the regroup phase)" (SOR_186 No Good to Me Dead) | Tag the unit by UniqueID on its controller: AddGlobalEffects($controller, 'SWU_CANT_READY_'.$uid). Gate readying in TWO places: OnReadyCard (no-op if the flag is set — blocks action-phase ready effects) and the round-start ready loop (the function with $gTurnNumber++ / "=== Round N ===", NOT RegroupPhaseStart) where you skip the unit AND consume the flag (SWUClearGlobalEffectsByPrefix) so it readies next round. The target list is ANY unit (an already-exhausted unit is a legal target — the exhaust no-ops but the flag still locks the next ready). Reference: SOR_186. | | "control [a card titled X] (as a leader, unit, or upgrade)" (SOR_184 Fett's Firespray: ready if you control Boba/Jango Fett) | _SWUControlsTitle($player, ['Boba Fett','Jango Fett']) (GameLogic.php) — exact-CardTitle match across the friendly leader zone (deployed or not), arena units, and their upgrade subcards. ⚠ "Boba Fett**'s Armor**" is a different title and must NOT match — match titles exactly, not substrings. Modern templating is "leader, unit, or upgrade" even when older text says just "leader or unit" (scan upgrades too). | | Unit "enters play ready" (or other entry-status override) | ActivateCard unit branch in GameLogic.php$entryStatus is 1 if SWUUnitEntersReady($cardID) (the card's own text) OR the global $gForceEnterReady is set. For a source that plays another unit ready ("Play an Imperial unit … it enters play ready", SOR_129 Ozzel), set $gForceEnterReady = true right before ActivateCard(...) and reset it after — don't try to ready the unit post-hoc. | | A play SOURCE that stamps a TurnEffect on the unit it plays (grant a keyword / mark for later) | Set the consume-once global $gPlayGrantTurnEffect to the token right before ActivateCard(...), reset after (mirrors $gForceEnterReady). The unit branch AddTurnEffects it onto the entering unit at placement (you don't need the post-ActivateCard mzID). SOR_003 Chewbacca tags 'SOR_003' (Sentinel this phase via its registry row); SOR_219 Sneak Attack tags 'SWU_SNEAK_DEFEAT' (a RegroupPhaseStart sweep defeats every unit still carrying it). | | An EVENT that plays a unit/card from hand (nested play) | The inner ActivateCard calls its own SWUAfterAction, which would double-advance the event (the event flow's FINISH_PLAY_CARD owns the after-action). Neutralise it like SWUPlayTopDeckCard: capture $gTurnPlayer + GetSWUVar('PASS') before the inner ActivateCard, restore both after. (SOR_219 Sneak Attack plays a hand unit at a discount this way; SOR_246/SOR_192 play the top deck card.) | | A UNIT's When Played (or any trigger) that plays ANOTHER card for free ("play a X for free from hand/discard", "play a Vehicle -5") | NOT a blocked seam (prior sessions wrongly deferred SHD_242/194 as "nested-free-play-drain fails"). The failure was calling ActivateCard synchronously inside the trigger closure. Do it from a deferred CUSTOM continuation instead (the closure queues an MZMAYCHOOSE/MZMULTICHOOSE + CardID#0; the #0 handler calls ActivateCard). Run later by ExecuteStaticMethods, the nested play drains to the arena AND auto-fires the played card's own When Played exactly once — same context as SEC_194's event #0 (proven: SEC_240 free-played via SHD_242 ends at DAMAGE:2, not 4). Still wrap the inner ActivateCard in the $gTurnPlayer+PASS save/restore. Compose with the entry seams: $gForceEnterReady (enters ready), $gPlayGrantTurnEffect='MARKER' (grant/find it), discount via ActivateCard($p,$mz,false,$discount) or free via ignoreCost=true. Reference: SHD_242 (hand OR discard picker), SHD_194 (search→play -5 ready). | | "Return this unit to its owner's hand at the end of the phase" (temp play) | Register a PERM marker in $turnEffectRegistry ('SWU_X_RETURN' => ['kind'=>'MARKER','duration'=>SWU_DUR_PERM] — perm so it survives the SWUExpireTurnEffects(SWU_DUR_PHASE) sweep) and grant it at play via $gPlayGrantTurnEffect. Add a RegroupPhaseStart drain loop (BEFORE the phase-expiry at ~L4498) that SWUBounceUnits each carrier to its owner's hand — an exact mirror of the LAW_074 SWU_LAW074_BOTTOM bottom-sweep (which uses SWUUnitToBottomOfDeck). Reference: SHD_194 (SWU_SHD194_RETURN). | | "... that didn't enter play this round" / "entered this round" (SOR_179 Boba Fett: deal 3 to an exhausted defender that didn't enter this round) | Reuse the existing SWU_PLAYED_UNIT_{uid} GlobalEffects flagActivateCard already sets it on every unit entry and RegroupPhaseStart clears it, so it IS "entered play this round." Read GlobalEffectCount($unit->Controller, 'SWU_PLAYED_UNIT_'.$uid) > 0. Do NOT build new entered-this-round tracking. (For an OnAttack that hits the defender, read the defender via GetSWUVar('SWU_CURRENT_DEFENDER'), guard base attacks with strpos($mz,'Arena')===false, and check $defender->Status for exhausted — see SOR_054 pattern.) | | "Action [N resources]:" with NO exhaust (repeatable resource-only unit action, SOR_184 Fett's Firespray) | Register $unitActionResourceCosts["CARD"]=N AND $unitActionCostKind["CARD"]='none'. The 'none' kind (added 2026-06-15) skips both the ready requirement and the exhaust, so the unit can use the action repeatedly while it can pay N ready resources. ('exhaust' = default: needs ready, taps the unit; 'defeat' = sacrifices it.) Add a SWUUnitActionAffordable case if the effect needs a valid target to exist. | | Upgrade with a "When Played:" ability | Register as $whenPlayedAbilities["CARD_ID:0"] (NOT $whenPlayedAsUpgradeAbilities). HasWhenPlayedAsUpgradeAbility is Pilot-only; CollectWhenPlayedAsUpgradeTriggers (ATTACH_UPGRADE handler) adds a WhenPlayedAsUpgrade trigger only if the first stub matches, ELSE falls back to a plain WhenPlayed trigger. So a non-pilot upgrade ("When Played: if attached unit is X…", e.g. SOR_136 Vader's Lightsaber / SOR_053) lands in $whenPlayedAbilities, and its closure receives the HOST unit's mzID as $mzID. Reach for $whenPlayedAsUpgradeAbilities only when the card is a Pilot. | | Upgrade attach restriction ("Attach to a non-Vehicle/Vehicle unit") | Add a case 'CARD_ID': to SWUGetUpgradeValidTargets's per-card switch in GameLogic.php (e.g. SOR_136 → the non-Vehicle case). It's friendly-only by default (my{Ground,Space}Arena); falling through to default skips the trait filter entirely. | | Play-cost modifier ("costs N less/more if …") | SWUSim/Custom/GameLogic.phpSWUComputePlayCost is the single source of truth (called by both CanAffordActivationReserve UI affordability and ActivateCard payment, so they never drift). TWO registries depending on who the source is: (a) subject-keyed $playCostModifiers["CARD_ID"] = fn($player, $subjectObj): int when the card modifies its own cost (SOR_248 Volunteer Soldier −1 if you control a Trooper; SHD_182 Bravado −2); (b) field/source-keyed $playCostFieldModifiers["SOURCE_ID"] = fn($subjectObj, $subjectPlayer, $sourceObj): int when a unit in play modifies other cards' costs (SOR_034 Del Meeko +1 to each event an opponent plays). Negative = cheaper, positive = dearer. Do NOT hand-edit the cost math in ActivateCard. NOTE: the schema's PlayCostModifier macro / generated EvaluatePlayCostModifier is an empty stub — these two registries are the real SWU wiring. Known gap: the play-from-discard TPP/OTPP cost sites still compute cost inline. "Ignore the aspect penalty on [trait] cards you play" (SOR_008 Hera, Spectre) is NOT a cost-modifier registry entry — hook the single chokepoint SWUAspectPenalty($player,$cardID) directly (return 0 when HasTrait($cardID, 'Spectre') && _SWUControlsHera($player)); it's called by every cost site (hand/discard play + affordability glow), so one edit covers all paths. "Controls the Hera leader" = the leader-zone CardID is SOR_008 (deployed or not — the leader-zone entry persists). | | Persistent passive tied to a unit ("While this unit is in play, …") | GroundArena/SpaceArena units have no serialized Counters field (only TurnEffects, which clear each turn) — so you can't hang lasting state on the unit object. Store it as a GlobalEffects flag keyed by the unit's UniqueID on the controller (AddGlobalEffects($player, "SWU_X|{uid}|{data}")), and at read time verify the UID is still in play (_SWUUnitInPlayWithUID); lazily array_splice stale flags. The effect then auto-ends when the unit leaves play — no SWUDefeatUnit/SWUBounceUnit/capture hooks needed. Reference: SOR_062 Regional Governor (SWUCardPlayBlocked + SWU_NAMEBLOCK\|{uid}\|{title}). | | Per-phase "a friendly/your unit was [defeated/…] this phase" counter | Set a GlobalEffects flag when the event fires; read it (GlobalEffectCount($player, 'SWU_X') > 0) when the card resolves. ⚠ "Friendly" / "your" = the Controller, NOT the Owner — a taken-control unit is friendly to whoever currently controls it. Flag the affected unit's Controller. For "a friendly unit was defeated", that's THREE defeat sites: both combat-defeat spots in SWUCombatDamage ($attacker->Controller / $target->Controller) plus SWUDefeatUnit (effect/sacrifice defeats — so a self-sacrifice counts). Clear it at RegroupPhaseStart with SWUClearGlobalEffectsByPrefix (clears ALL instances) — NOT RemoveGlobalEffect, which drops only the first, leaking a stale flag into next phase when the event fired ≥2× that phase. Reference: SOR_051 Luke (SWU_FRIENDLY_DEFEATED). | | Per-phase "return/act on the SPECIFIC cards that were [defeated/discarded/…] this phase" (SOR_091 "return each unit in your discard defeated this phase to hand") | Like the counter above but you must later RE-IDENTIFY the exact cards. ⚠ Do NOT tag the discard/zone entry with a marker prop ($entry->DefeatedThisPhase) or match by UniqueID — those do NOT survive the gamestate serialize/parse boundary between actions. The in-memory AddDiscard deep-copy sets UniqueID/extra props, but they're GONE by the next request (confirmed: GetNextTurn shows no UniqueID on discard entries), so a later-action event reads 0/unset and matches nothing. The cross-request-safe key is a GlobalEffects multiset counted per CardIDAddGlobalEffects($owner, 'SWU_DEFEATED_CARD_'.$cardID) at each of the THREE defeat sites (keyed by the owner, whose discard the card lands in), then the event returns up to that count of each CardID from the player's discard (GlobalEffectCount(... 'SWU_DEFEATED_CARD_'.$cid), decrementing a local tally as you pick copies). CardID (not UniqueID) is fine because copies are interchangeable. GlobalEffects persists across requests (that's how SWU_FRIENDLY_DEFEATED works); custom zone-object props do not. Clear the prefix at RegroupPhaseStart. Reference: SOR_091. | | Per-phase "you damaged an opponent's base this phase" (SOR_175 "each opponent whose base you've damaged this phase discards 2") | Flag inside SWUDealDamageToBase when $damage > 0: the damager is the caller's saved $playerID (the actor) — $damager = ($savedPID > 0 && $savedPID !== $targetPlayer) ? $savedPID : OtherPlayer($targetPlayer) — guard out self-base-damage ($damager === $targetPlayer → skip). Store AddGlobalEffects($damager, 'SWU_DMGBASE_'.$targetPlayer). The event checks GlobalEffectCount($player, 'SWU_DMGBASE_'.OtherPlayer($player)) > 0. To make the opponent discard, reuse SWUDiscardCards($player, $n) (it makes OtherPlayer($player) discard $n — auto-discards all if hand ≤ n, else queues n MZCHOOSE for the opponent to pick). Clear SWU_DMGBASE_ at RegroupPhaseStart. Reference: SOR_175. | | Reactive "When another UNIQUE unit is defeated" / "once each round" (SOR_115 Agent Kallus) | Another observer in the SWUCollectLeavePlayReactions defeat funnel (alongside Gideon/Krell). Self-exclusion ("another unit") is automatic — scan for the source unit IN PLAY (_SWUCountUnitsWithCardID, skips removed); a defeated source is already removed so an in-play copy never sees its own defeat. Uniqueness of the defeated unit = CardUnique($d['cardID']) (the generated accessor over $uniqueData). The reaction fires for any unique unit (friendly OR enemy) — re-read the dictionary text, the plan's prose may say "enemy" when the card says "another unique unit". "Use only once each round": gate at COLLECT time on a per-round GlobalEffects flag (GlobalEffectCount($p,'SWU_X_USED') <= 0), and SET the flag right there when you AddTrigger — so the trigger only ever fires once/round and a declined optional draw still consumes the round (standard "the ability triggered" ruling). The round boundary is RegroupPhaseStart (where $gTurnNumber increments) — clear the flag there with the other per-phase flags. Reference: SOR_115. | | "Cards played this phase" counter ("if you played another card", "for each other card you played", "your Nth card") | SWU_CARDS_PLAYED GlobalEffects counter (one entry per card, any type). Increment at EVERY play entry point — same all-paths discipline as the play-legality block: ActivateCard (hand + Ezra/YMOH deck-plays), SWUSmuggleResource, SWUPlayFromDiscard/SWUPlayFromOpponentDiscard, and the top-deck-play finalize SOR087_SEARCH_FINALIZE (once per unit placed). No double-count — those non-ActivateCard paths place cards directly. Clear at RegroupPhaseStart with SWUClearGlobalEffectsByPrefix. The count includes the just-played card, so "another card" = GlobalEffectCount > 1 and "each other card" = count − 1. Reference: SOR_190 Lothal Insurgent, SOR_191 Vanguard Ace. | | A play-legality block ("opponents can't play X", "you can't play units") | Implement as ONE shared predicate (SWUCardPlayBlocked($player, $cardID)) and call it at every play entry point — not just ActivateCard (hand): also SWUPlayFromDiscard/SWUPlayFromOpponentDiscard, SWUSmuggleResource, and the top-deck-play finalize SOR087_SEARCH_FINALIZE (which bypasses ActivateCard — blocked picks go to the bottom instead of entering play; covers Vader SOR_087 / U-Wing SOR_104). Also gate the glow in CanAffordActivationReserve (return false) so the blocked card doesn't light up — same "one affordability function" discipline as costs. Block BEFORE paying (no cost, no state change). |

Choose-a-target: use the helpers, don't hand-roll the PASSPARAMETER/MZCHOOSE/MZMAYCHOOSE tail. Two helpers in GameLogic.php collapse the ubiquitous "choose a target → run a handler" idiom:

  • SWUQueueChooseTarget(int $player, array $targets, string $tooltip, string $handler, int $block = 1) — mandatory choose. 1 target → auto PASSPARAMETER; 2+ → MZCHOOSE; then CUSTOM $handler. No-ops on [] (so drop the if (!empty(...)) guard). $handler may carry args ("DEAL_UNIT_DAMAGE|4"). Pass $block to match surrounding decisions (older handlers sometimes use block 0).
  • SWUQueueMayChooseTarget(int $player, array $targets, string $yesTooltip, string $chooseTooltip, string $handler, int $block = 1)"You may" choose. Emits ONE MZMAYCHOOSE (pick a target or decline via the Pass button) + CUSTOM $handler — collect the targets once in the trigger and pass them (no separate re-collecting handler, no double-predicate). The whole "you may give a Shield to another Spectre unit" card becomes one closure that calls this. $yesTooltip is retained for call-site readability; $chooseTooltip is the popup prompt. (Earlier versions queued a YESNO + a MAY_CHOOSE follow-up — that indirection is gone; it's a single MZMAYCHOOSE now.)
    • The $handler MUST no-op on a '-' decline (if (!$lastDecision || $lastDecision === '-' || $lastDecision === 'PASS') return;) — MZMAYCHOOSE runs the follow-up even when the player passes. Every universal effect handler (DEAL_UNIT_DAMAGE, GIVE_SHIELD, GIVE_EXPERIENCE, HEAL_TARGET, EXHAUST_UNIT, READY_UNIT, DEFEAT_UNIT, BOUNCE_UNIT) already does; a bespoke one must.
    • Keep a hand-written handler instead only when the target list or an effect amount is computed at resolution time from changeable state ("deal damage = resources you control"; "defeat a unit with ≤4 remaining HP"; reveal-then-target), or the decline needs special cleanup (attack-during-WhenPlayed CleanupRemovedCards). In those cases compute the list/amount up front in the ability and still call SWUQueueMayChooseTarget when you can (the reveal/commit moves into the follow-up handler — SOR_176 ISB Agent); fall back to a bespoke handler only when a mid-sequence sub-choice follows (a host→subcard pick like the upgrade-defeat family — now generalized via SWUQueueDefeatUpgrade + TempZone staging; see the "Pick among a unit's upgrades / any subcard" row).

Collect "another X" targets with _SWUCollectUnits, and know whether X is a TRAIT or an ASPECT. _SWUCollectUnits(int $excludeUID, callable $pred): array (CardDQHandlers.php) walks all four arenas, excludes one UniqueID ("another …"), and returns mzIDs where $pred($obj) holds. The predicate distinguishes:

  • Trait (Spectre, Imperial, Rebel, Mandalorian, Force…) → fn($o) => HasTrait($o->CardID, 'Spectre')
  • Aspect (the [Vigilance]/[Command]/[Aggression]/… icons) → fn($o) => strpos(CardAspect($o->CardID) ?? '', 'Vigilance') !== false

Easy to conflate — "Spectre unit" is a trait; "[Vigilance] unit" is an aspect. Use fn($o)=>true for "any unit", fn($o)=>!IsLeaderUnit($o) for "non-leader". Friendly-only or single-arena cases still inline ZoneSearch. A shared When-Played/On-Attack ability is one assignment: $whenPlayedAbilities["X:0"] = $onAttackAbilities["X:0"] = fn(...) (keyed off the source unit's mzID; exclude self by its UniqueID).

Universal effect handlers — reuse these as the $handler arg; no new handler needed:

  • DEAL_UNIT_DAMAGE|N — deal N to the chosen unit
  • DEFEAT_UNIT — defeat the chosen unit
  • EXHAUST_UNIT / READY_UNIT — exhaust/ready the chosen unit
  • READY_RESOURCE — ready the chosen resource
  • BOUNCE_UNIT — return the chosen unit to hand
  • GIVE_SHIELD — give a Shield token to the chosen unit
  • GIVE_EXPERIENCE|N — give N Experience tokens to the chosen unit
  • HEAL_TARGET|N — heal N from the chosen unit or base (handles myBase-0/theirBase-0 mzIDs)
  • DEAL_BASE_DAMAGE|N — deal N to the chosen base (myBase-0/theirBase-0); no-ops on a - decline
  • DRAW_CARD|N — draw N for the acting player (target-less; queue directly, not via the choose helpers)
  • GRANT_PHASE_KEYWORD|CARDID — give the chosen unit a keyword for this phase. Pass the source CardID (GRANT_PHASE_KEYWORD|SOR_086) and add a $turnEffectRegistry row ('SOR_086' => ['kind'=>'GRANT_KEYWORD','value'=>'SENTINEL','label'=>'Sentinel']). Expiry is registry-driven — do NOT extend any RegroupPhaseStart list. See "Turn-effect registry & durations" below.
  • APPLY_PHASE_DEBUFF|p|h|src / APPLY_PHASE_BUFF|p|h|src — −/+ p/h for this phase. src = the source CardID; register it ('SOR_124' => ['kind'=>'STAT_BUFF'] / 'SOR_076' => ['kind'=>'STAT_DEBUFF']) so it carries provenance + expires centrally. An unregistered src falls back to a synthetic token (works, but no source-card art in the popup).
  • SWU_AFTER_ACTION — close the current action (see base/leader-epic note below)

These handlers deliberately don't call SWUAfterAction, so events can use them. Event effect cases (in OnPlayEvent) need no SWUAfterAction — the event flow queues FINISH_PLAY_CARD (block 10) which cleans up. Base/leader epic abilities, however, must close the action: queue the effect (via SWUQueueChooseTarget) then CUSTOM SWU_AFTER_ACTION last (don't write a card-specific handler just to add the SWUAfterAction). Getting cleanup backwards = double-advance or a stuck turn.

Frame-animations come free if you use the animating helpers. Damage/heal cards must queue a frame-animation, but the standard primitives already do it: SWUDealDamageToUnit / SWUDealSplitDamageSWUQueueDamageAnim; OnHealUnit / OnHealBaseSWUQueueHealAnim; combat shield-absorb → SWUQueuePreventedAnim. So prefer those helpers and the animation comes along — when writing a NEW damage/heal primitive, mirror them (queue the anim). Buffs/debuffs have no frame-animation (only Damage / Heal / Prevented exist) — pass the source CardID to SWUApplyPhaseBuff/SWUApplyPhaseDebuff so the change shows as an Active-Effects badge instead. The regression is blind to animations (it bypasses the live ProcessInput path); for a new animating effect, do the headless UI smoke test (Step 3e). NOTE: frame-animations are delivered via a dedicated {gameName}_anim cache key (not a piece of the shared game-state cache blob — that blob is mutated by unlocked read-modify-write and would clobber a transient piece); don't store new per-action transient data as a cache piece.

For batches: implement cards in dependency order. Run the regression after each card so failures are local.

3b-effects. Turn-effect registry & durations (keyword grants, buffs/debuffs)

Per-unit "for this phase / this attack / while in play" effects are registry tokens, not ad-hoc strings (reworked 2026-06-16). When a CARD grants a TurnEffect, use the source CardID as the token so the Active Effects popup shows provenance (card art + a duration chip) for free.

  • Token format (# freed for handler step keys, 2026-06-17): CARDID[-params][@duration]. @duration is attack|phase|perm; omitted ⇒ phase (the default). -params carries DYNAMIC values only, dash-delimited (SOR_051-3-3 = −3/−3). STATIC values live on the registry row as 'amount', keeping the token bare (SOR_154 is Raid 2 because its row says 'amount'=>2). The base CardID/synthetic token never contains a dash, so the first - cleanly splits base from params; # is NOT used in tokens. Sourceless effects use a synthetic base (the bare keyword name, or TEMPORARY_STEAL/NO_ABILITIES).
  • Registry: $turnEffectRegistry in GameLogic.php, keyed by base token → ['kind'=>…, 'value'=>…, 'amount'=>…, 'duration'=>…, 'label'=>…]. Only set duration when it's NOT phase. Kinds: GRANT_KEYWORD (+value=keyword), GRANT_KEYWORD_VALUE (Raid/Restore/Exploit; static value in 'amount', else a dash param), STAT_BUFF/STAT_DEBUFF (dynamic -power-hp), SUPPRESS_KEYWORD, LOSE_ABILITIES, CONTROL. Implementing a grant card = add one registry row (+ 'amount' if a fixed value) + emit the CardID token.
  • Readers (never re-implement): SWUHasTurnEffectKeyword($obj,'SENTINEL'), SWUTurnEffectKeywordValue/Sum($obj,'RAID'), SWUTurnEffectStatBonus($obj,'power'|'hp'). The generated HasKeyword_*/GetKeyword_*_Value call these — if you touch them, patch the generator Data/ProcessKeywordsSWU.php too.
  • Producers: SWUApplyPhaseBuff/Debuff($mz,$p,$h,$source) emit CARDID#p_h; the GRANT_PHASE_KEYWORD|CARDID handler tags a CardID grant.
  • Expiry is central: SWUExpireTurnEffects('phase') (in RegroupPhaseStart) drops attack+phase tokens; SWUExpireTurnEffects('attack') (at the end of SWUCombatDamage, after damage resolves) drops attack tokens when the attack ends; perm is never swept (rides until the unit leaves play, when the arena entry is discarded). The old hand-maintained predicate ($phaseGrantKeywords/$phaseAbilityMarkers/SWUBUFF_/SWUDEBUFF_ substring list) is gone — add a registry row, don't edit a predicate.
  • Free UI: the Mode=CardIDs "Active Effects" hover popup (Core/CounterRendering.js, fed by CardDisplayEffects) shows each effect's source-card art + an Attack/Phase/Permanent chip automatically. No client work for a normal grant card. To hide a registered-but-implied token from the popup (e.g. SHOOT_FIRST's deal-first ordering) while keeping its mechanic + expiry, add it to $backendOnlyTurnEffects — that list only filters the display, not CardCurrentEffects/SWUExpireTurnEffects.
  • Attack-duration with provenance (e.g. SOR_217 Shoot First's "+1/+0 for this attack"): use a registered token with 'duration' => SWU_DUR_ATTACK (a STAT_BUFF via SWUApplyPhaseBuff(...,'SOR_217'), or a bare marker like SHOOT_FIRST) — SWUExpireTurnEffects('attack') at the end of SWUCombatDamage drops it when the attack resolves, and the phase sweep is the fizzle-safety net. Still legacy (NOT in this system): the inline one-shots SWUAddAttackPowerBonus (SWU_ATK_POWER_*) and SWU_DEF_DEBUFF_* are read-and-removed in-place during damage calc (no registry row, no popup) — fine for a fleeting attack bonus with no provenance need; use the registered path when you want it shown in Active Effects.
  • ALWAYS test the duration, not just the grant. A wrong duration (or a missing registry row) silently mis-expires — historically several "this phase" grants persisted forever because nothing tested expiry. For a phase grant: play it → assert the effect is present → P2>Pass/P1>Pass to end the action phase (regroup runs SWUExpireTurnEffects) → assert it's gone (NOTKEYWORD:Sentinel, NOTKEYWORD:Raid, or stat back to base). For perm, assert it survives the regroup. Reference: GladiatorStarDestroyer_Sentinel_ExpiresNextPhase, RallyingCry_Raid2_ExpiresNextPhase.

3c-stats. Stat modifiers, shrinks (−X/−Y), and passive debuffs

SWU "give −X/−Y", "+X/+Y", and "while you control Z, units get …" effects all flow through ObjectCurrentPower / ObjectCurrentHP in GameLogic.php. Before assuming the framework table's "add a case" advice applies, check what those two functions actually do — historically they did not loop TurnEffects at all, so the first stat-modifying effect has to add that loop (it is not a pre-existing hook). HP reads through ObjectCurrentHP exactly as power reads through ObjectCurrentPower; the UI's ObjectCurrentPowerDisplay/HPDisplay just delegate to them, so a correct stat change surfaces on the board automatically (no display-layer work, low-risk UI smoke test).

  • "For this phase" buffs/debuffs: apply via SWUApplyPhaseBuff/Debuff($mz,$power,$hp,$source) (emits the registry token CARDID-power-hp) and add a $turnEffectRegistry row (['kind'=>'STAT_BUFF'|'STAT_DEBUFF']). ObjectCurrentPower/HP already fold these in via SWUTurnEffectStatBonus($obj,'power'|'hp') (STAT_BUFF − STAT_DEBUFF) and return max(0, $base) — a printed stat is floored at 0 (clamp the final result). Expiry is central (SWUExpireTurnEffects); do not hand-add a case/string-match or touch RegroupPhaseStart. Write a floor-at-0 test (−4 on a 3-power unit → 0, not −1). See "Turn-effect registry & durations".
  • Field-presence passives (e.g. Snoke SHD_037 "each enemy non-leader unit gets −2/−2"): subtract in the same two functions by scanning the opponent's arena for the source card. No TurnEffect is stored — it's continuous, recomputed on every read.
  • There is NO general state-based-action defeat check. A shrink that drops a unit's effective HP to 0 does not auto-defeat it. Add an explicit sweep (ObjectCurrentHP($obj) − Damage ≤ 0SWUDefeatUnit) and call it at every board-change point: after applying a shrink, and in ActivateCard's unit-enters-play path (so a continuous passive like Snoke defeats both existing enemy units and a small unit played under it). Guard against the just-entered unit being defeated on entry (re-check by UniqueID before running its entry triggers on a now-stale mzID).
  • The shrink is HP reduction, not damage — shields do not prevent it. A 2/2 shielded unit shrunk −2/−2 dies. The defeat sweep must be shield-independent.
  • Combat lethality uses ObjectCurrentHP (printed + upgrades + "for this phase" buffs − debuffs), not printed CardHpSWUCombatDamage in CombatLogic.php (lines ~430/459/460). So a +HP buff/upgrade keeps a unit alive in combat, and a debuff/shrink makes it die more easily. (Historical: combat used to use printed CardHp; that simplification was fixed 2026-06-14 — ignore older notes claiming "combat uses printed HP" or "don't rework combat".) A "for this phase" +HP buff that kept a damaged unit alive is resolved at end of phase: RegroupPhaseStart runs SWUCheckShrinkDefeats() after SWUExpireTurnEffects('phase') strips the STAT_BUFF/STAT_DEBUFF tokens, defeating any unit now at/over its un-buffed HP. Test BOTH the in-phase survival (assert P…GROUNDARENACOUNT:1 while the buff is up) AND the post-expiry defeat (Pass to end the action phase → the unit dies, COUNT:0). Reference: OverwhelmingBarrage_BuffThenSplit (survives) + OverwhelmingBarrage_DelayedDefeatWithoutBuff (dies at regroup). When testing a −X/−Y debuff's survival case, target an UNDAMAGED enemy — one that already took combat damage can be defeated by the −HP (ObjectCurrentHP − Damage ≤ 0SWUCheckShrinkDefeats), so use a separate undamaged target if you need it to survive (SOR_051 Luke's −6 test).
  • "+X/+0 for THIS ATTACK" ≠ "for this phase" — use the right primitive. A per-attack buff (Surprise Strike, "attack with a unit; +2/+0 if it's a {trait} unit") must be SWUAddAttackPowerBonus($mzID, $power) — a one-shot bonus SWUCombatDamage adds to attack power and consumes (mirrors Raid), so ObjectCurrentPower is identical before/after, only the dealt damage rises. Do NOT use SWUApplyPhaseBuff for it — that persists to RegroupPhaseStart and was a real bug (lingering +3). Reserve SWUApplyPhaseBuff for genuine "for this phase". Test BOTH: the dealt damage (proves it applied) AND post-attack POWER:<base> (proves it didn't linger — a base-damage-only test passes the buggy phase-buff version). When the attack is granted inside a WhenPlayed/OnAttack entry trigger, do NOT call SWUAfterAction after BeginSWUAttack (the trigger-resume owns it); only the event form (SOR_220) calls it on the null/decline path.

3d. Gotchas

  • $playerID before GetZoneObjectGetZoneObject("myGroundArena-0") resolves "my" against $playerID. If it's not set, the object comes back null. Always global $playerID; $savedPID = $playerID; $playerID = $player; before calling GetZoneObject with a relative mzID, then restore after.
  • $playerID when QUEUEING a relative-mzID decision — do NOT restore before returning. A queued MZMULTICHOOSE/MZCHOOSE whose param uses relative mzIDs (myGroundArena-N) is validated by the harness's MZCountChoices immediately after the queuing handler returns, resolving those zones under the global $playerID. If the handler restored $playerID to anything other than the decision's player (common in CUSTOM / phase handlers that save+restore), MZCountChoices counts 0 valid choices and the decision auto-resolves to - (choose none) — silently skipping it. So any handler that queues such a decision must leave $playerID = the decision's player on return (the same reason ActivateCard's event branch deliberately doesn't restore). This is the OPPOSITE of the synchronous GetZoneObject case above (where restoring after is correct). Diagnostic signature: a live step-through works (the player clicks the menu) but the regression's scripted AnswerDecision "doesn't land" — the resolver receives - instead of the pick. That UI-passes / regression-skips split ⇒ suspect $playerID at MZCountChoices time. (Caught 2026-06-16: the SEC_122 Falcon-regroup Droid MZMULTICHOOSE, queued from SOR193_REGROUP which restored $playerID, auto-resolved to none → the Falcon bounced; the action-phase Exploit multichoose worked only because its call site already had $playerID = the actor. Centralized in SWUOfferDroidPayment, which leaves $playerID set.)
  • Indices shift the moment a unit is defeated mid-ability. SWUDefeatUnit calls CleanupRemovedCards() immediately, so every mzID captured before the defeat (the attacker's own mzID, pre-built MZCHOOSE strings, DQ continuation params) is stale after it. Two rules: (a) compute target lists after the zone mutation, never before — SOR006_ONATTACK_SACRIFICE in CardDQHandlers.php is the canonical pattern (defeat first, then ZoneSearch); (b) any DQ param that references a unit across a potential mutation must carry the UniqueID alongside the mzID and re-resolve by UID. Use SWUFindMzByUID($uid) (GameLogic.php) for this — it scans all four arenas and returns the current mzID or null. Example: SOR_234 ("two Imperials deal power to the same unit" — re-resolves the shared target between hits); also for AOE "deal X to each unit" (snapshot UIDs, then SWUFindMzByUID per hit).
  • A deployed leader defeated by an ENEMY effect/combat must be removed under the CALLER's $playerID context, not the leader-owner's. SWUReturnLeaderToZone once set $playerID = $ownerPlayer before fetching the leader-unit's mzID, so when P1's Takedown defeated P2's deployed leader, the relative theirGroundArena-0 (resolved while collecting under P1) was re-resolved under P2's context → pointed at P1's empty arena → the leader-unit was never removed (arena count stayed 1, Deployed never cleared). Fix: remove the unit under the incoming $playerID first, then set $playerID = $ownerPlayer only for the GetLeader reset loop. Symptom: a deployed leader survives an enemy direct-defeat or stays on board as a defeated combat-defender. Guard: Chirrut_Deployed_DiesToTakedown.md.
  • Always null-check GetZoneObject on a possibly-stale mzID. Never assume an index still exists after a defeat/bounce. (Historical note: before 2026-06-12, probing a missing index through this by-ref function silently appended a null element to the zone array, corrupting arena counts far from the call site. The generated GetZoneObject now has an isset guard and returns null — but the discipline stands, and any hand-rolled by-ref accessor returning $arr[$idx] has the same auto-vivification footgun.)
  • Use empty($obj->removed), never !isset($obj->removed), for "is this unit still in play?". isset() is TRUE when the property exists but is false, so !isset($obj->removed) wrongly evaluates to FALSE on a live unit whose removed was initialized to false — silently SKIPPING the block it guards. (Caught 2026-06-18: the combat defender-defeat branch used !isset($target->removed) while the attacker branch and SWUDefeatUnit used truthy checks; a leave-play path was skipped for certain hosts.) The correct "not removed" idiom across the engine is empty($obj->removed) (true for unset / null / false) or (!isset($obj->removed) || !$obj->removed).
  • Costs gate before any state change; effects fizzle after. Check affordability of ALL cost components — resource costs, printed additional costs ("defeat a friendly unit"), epic-action conditions — before mutating anything. Unpayable cost = complete no-op and the player keeps their action; paid cost whose effect has no valid targets = legitimate fizzle. Conditions ("if you control N or more resources") count total resources; payments require ready resources. Helpers: SWULeaderActionAffordable + SWUResourceCount in GameLogic.php, costs registered in $leaderActionResourceCosts in LeaderAbilities.php.
  • One affordability function, shared by the action gate and the UI flag. If an action gets a glow in SWUComputeActionsData, both the flag and the server-side action must call the same eligibility function. Duplicated eligibility logic will drift — the glow once disagreed with the server about leader actions.
  • Every action entry point validates its own preconditions server-side. The UI glow is a convenience, not a gate. SWUDeployLeader once allowed 0-resource and double deploys because only the glow checked anything. New action functions must no-op on illegal input.
  • Cross-action state must be a schema zone, never a bare PHP global. Globals die with the request: they pass the single-process regression suite and silently fail in the real game ($gGameLog did exactly this — green tests, empty UI log). Counters, logs, and flags that survive between actions belong in GameSchema.txt. Zone values must stay single-line (the gamestate format is line-based); encode embedded newlines as <NL>. GlobalEffects flags must be SPACE-FREE — the GlobalEffects constructor does explode(" "), so a flag with spaces is truncated at the first space. Encode any free text (e.g. a card title) before storing — str_replace(' ', '_', $title) — and compare the normalized form (same constraint as OPTIONCHOOSE labels and DecisionQueue params).
  • SetSWUVar/GetSWUVar values live in a PIPE-delimited KEY=VALUE string — a | OR = in the value corrupts storage. For a multi-field SWUVar spec, delimit with , (e.g. "{rebelOnly},{mayDecline},{bonus},{uid}"), never |. Same constraint if that spec also rides through a pipe-delimited DQ param (RESOLVE_TRIGGER|…, CUSTOM Param). Symptom of getting it wrong is silent + downstream: the value reads back truncated, so a parsed flag/amount comes out as 0/empty (a mayDecline flag became false → an optional choice couldn't be declined; a +1 bonus became +0). Tests still "run" — they just assert the wrong number. (Same delimiter-discipline family as the GlobalEffects/OPTIONCHOOSE space rule above.)
  • Delayed "at the start of the next phase" triggers ride on a GlobalEffects flag and must survive the regroup. "At the start of the next action phase, do X" = arm a GlobalEffects flag (a persisted schema zone — count-based so multiple arms stack) when the source resolves, then consume it (and RemoveGlobalEffect) in the target phase handler. Critically, do NOT add the flag to RegroupPhaseStart's per-phase cleanup loop — it has to live through the regroup to fire at the next action phase. (SOR_017 Han Solo's "defeat a resource you control" pending trigger is the canonical example: armed in the leader action / On-Attack, consumed in ActionPhaseStart.)
  • Deployed leaders: "Unit" filter EXCLUDES them, "Leader Unit" filter INCLUDES them. A deployed leader's CardType() is "Leader" (not "Unit"), so ZoneSearch(["Unit"]) skips it — perfect for "non-leader unit" restrictions (no extra filter needed). ZoneSearch now maps a deployed leader to also match the "Leader Unit" token (fixed 2026-06-15 — one line in ZoneSearch adds 'Leader Unit' to the type list when IsLeaderUnit($obj)), so the ubiquitous ['Unit','Token Unit','Leader Unit'] pattern correctly finds deployed leaders. So the right way to "include deployed leaders" is to put 'Leader Unit' in the filter — never 'Leader', and never a bare ZoneSearch enumeration. Before this fix, the 'Leader Unit' token matched nothing (the data uses 'Leader'), silently excluding deployed leaders from EVERY "a unit" effect (deal damage, give −X/−Y, etc.) and from state-based sweeps — they were untargetable. If you ever see a deployed leader not being found/targeted/swept, confirm the search uses 'Leader Unit' and that the ZoneSearch mapping is intact. Test fixtures: a deployed leader IS a valid target for "deal N to a unit" / "−X/−Y a unit" — adding such a fixture can turn a 1-target auto-PASSPARAMETER into a 2-target MZCHOOSE (audit rule #8 applies to deployed leaders too).
  • ZoneSearch exact-matches type strings, so ["Unit"]/["Unit","Leader Unit"] SILENTLY EXCLUDE Token Units (Battle Droids, TIE Fighters, Clone Troopers — CardType "Token Unit", which does not intersect "Unit"). Any "units you control" / "friendly units" / "enemy units" effect almost always means tokens too — use the full ['Unit','Token Unit','Leader Unit'] triplet (drop 'Leader Unit' only for an explicit "non-leader" restriction). When you write a unit enumeration, decide explicitly whether tokens count and write the type list to match — omitting 'Token Unit' is an easy, silent bug (the engine offers fewer targets; a token-answering test can still pass falsely — see the MZMULTICHOOSE row). Canonical correct reference: SOR_245 Medal Ceremony / SWUExploitFodder. (Caught 2026-06-16: Exploit fodder enumerated ["Unit","Leader Unit"], so Battle Droid tokens weren't offerable as defeat targets — UI showed "defeat up to 1".)
  • ZoneSearch's $cardSubtypes (5th) arg is a DEAD stub in SWUSim. CardSubtypes() always returns '' (SWU keeps traits in trait data, not subtypes), so ZoneSearch($zone, $types, null, null, ['Droid']) silently matches NOTHING (returns 0). Filter by trait with GetField($player) + HasTrait($obj->CardID, 'Droid') instead — the canonical pattern for every trait-gated enumeration (Droid, Trooper, Spectre, Force, …); the choose-target predicates at the top of this section already use HasTrait. (Caught 2026-06-16: SEC_122's "−1 per friendly Droid" first tried the $cardSubtypes arg and counted 0 discount.)
  • "If you have the initiative" = the InitiativeCounter prefix, not a separate flag: $holder = strpos((string)GetInitiativeCounter(), 'P1') === 0 ? 1 : 2; (it returns P1_CLAIMED/P1_UNCLAIMED/P2_…). Test it via GIVEN WithInitiativePlayer: N + WithInitiativeClaimed: true (P1OnlyActions gives P2 the initiative).
  • A played event is already in its own discard when OnPlayEvent runs. ActivateCard moves the event to discard before calling OnPlayEvent, so a card that reads/targets a discard pile sees itself there (e.g. SOR_252 Restock's discard count includes Restock). Account for it in counts, and note such an event can technically target itself (usually harmless). Caught a wrong test expectation.
  • A just-played event lingers as a removed-but-uncompacted entry in its CASTER's hand during its own resolution — so any handler that collects the caster's own hand mid-event (to discard/pick/count it) must call DecisionQueueController::CleanupRemovedCards() before ZoneSearch. Otherwise ZoneSearch (skips removed) and GetZoneObject (raw array index) disagree: ZoneSearch returns myHand-0 for the survivor while index 0 is still the removed event, so the wrong card (or none) gets acted on. Symptom: the effect "works" in the live engine but the regression shows the caster's hand one too high / the wrong card touched. Only bites the caster's hand (an opponent's hand has no removed event in it). Reference: SOR_167 Force Throw choose-self. Generalized (2026-06-21): ANY hand picker queued from a card that JUST left hand hits thisSWUQueueDisclose (the disclose card/event that triggered it was just removed) calls CleanupRemovedCards() before building the MZMULTICHOOSE, else the offered hand mzIDs are offset by the stale slot and resolve to the wrong cards at answer time (coverage came up one icon short). See [[swusim-disclose-mechanic]].
  • Don't smuggle metadata into shared decision PARAMS — use the tooltip (or a dedicated continuation). A leading pseudo-spec (DISCLOSEREQ=…) prepended to a MZMULTICHOOSE param's specs INSTANTLY broke server MZMULTICHOOSE handling (5 tests red). MZMULTICHOOSE/MZCHOOSE specs are shared, fragile infra. To pass client-only UX hints (e.g. disclose's required aspects for confirm-gating), append to the tooltip with a sentinel the popup strips ("~REQ~Aspect-Aspect") — the server never parses the tooltip semantically. Client reads card data from window.aspectData (already emitted to the generated JS dict). See [[swusim-disclose-mechanic]].
  • Multi-step "all within ONE action" (e.g. a Plot window across a leader deploy): intercept SWUAfterAction behind a SWUVar flag rather than fighting ActivateCard's lifecycle. ActivateCard owns its terminal After Action (sync / async SWU_TRIGGER_RESUME / upgrade _SWUFinalizeUpgradeAttach — all funnel through SWUAfterAction). Set a SWU_*_IN_PROGRESS flag; in SWUAfterAction, if set, redirect to your orchestrator (re-offer / next step) instead of SWUSwapTurnPlayer; clear it + swap when done. For "exclude cards added mid-window" (CR 19.d replacements), use a position window [0, K-P) (snapshot count K, increment P per step) — CleanupRemovedCards keeps originals compacted at the front and appends new entries at the end. See [[swusim-plot-mechanic]].
  • BeginSWUAttack($player, $mzID) auto-resolves a single valid attack target (runs ExecuteSWUAttack immediately) and queues a MZCHOOSE only for 2+. For "attack with a unit" event/leader tests, give the opponent only a base so the attack target is deterministic with no extra AnswerDecision. It also handles exhausting the attacker and the combat continuation — don't call SWUAfterAction after it.
  • OnAttack handlers don't receive the defender. $onAttackAbilities["X:0"] is called with ($player, $hostMzID) — the attack target is not passed. For abilities that hit "the defender" (e.g. SOR_054 Jedi Lightsaber's granted "give the defender −2/−2"), ExecuteSWUAttack exposes it via SetSWUVar('SWU_CURRENT_DEFENDER', $targetMzID); read it with GetSWUVar('SWU_CURRENT_DEFENDER') (resolves relative to the attacker's $playerID). Guard out base attacks (strpos($mz, 'Arena') === false).
  • Verify helper arity before calling it — and grep the name before DEFINING a new one. grep -n "function HelperName" SWUSim/** first, both when calling (signatures drift: HealBase is HealBase($player, $targetPlayer, $amount) / OnHealBase($player, $targetPlayer, $amount), NOT the 2-arg form some docs imply — a wrong-arity call passes RED-check then fatals at runtime) AND before adding your own shared helper. A generic-sounding name (_SWUUnitHasUpgrade, _SWUHasX, …) may already exist in another file — redefining it is a fatal cannot redeclare function that takes down the ENTIRE regression suite (every test errors), not just one card. Reuse the existing definition (mind its param types — _SWUUnitHasUpgrade(object $unit, …) is non-nullable, so null-guard the call). The redeclare fatal can also be masked by an unrelated failure (e.g. an ENOSPC temp-disk error swallowing the curl output) — if the WHOLE suite suddenly errors after you add a helper, suspect a duplicate definition first.
  • "Deals combat damage before the defender" (deal-first / first-strike) rides the $hasShootFirst branch in SWUCombatDamage (attacker hits first; if the defender is defeated it deals NO counter-damage). The marker is the SHOOT_FIRST TurnEffect (the deal-first ordering only) — set $hasShootFirst = in_array('SHOOT_FIRST', $attacker->TurnEffects) || $attacker->CardID === 'SOR_198' (SOR_198 Han Solo has the innate ordering). ⚠ The SOR_217 "Shoot First" card ALSO grants +1/+0 — that half is a SEPARATE registry STAT_BUFF (token SOR_217), folded into ObjectCurrentPower, not added in combat. So the producer applies BOTH (SWUApplyPhaseBuff($mz,1,0,'SOR_217') + AddTurnEffect($mz,'SHOOT_FIRST')); SOR_198 gets only the ordering (no buff). General lesson: when a card's named mechanic bundles an ordering effect + a stat rider, keep them as two decoupled tokens so an innate-ordering card (SOR_198) reuses just the ordering. Test BOTH a kill (defender deals 0 counter → attacker DAMAGE:0) AND a survive (defender takes the power and counters). Reference: SOR_217, SOR_198.
  • Restore fires on EVERY attack (a unit OR a base target), not just base attacks — it lives in ExecuteSWUAttack (once per attack, heals the attacker's own base by the Restore value), NOT inside the theirBase branch of SWUCombatDamage. (Fixed 2026-06-15: it was previously base-attack-only, masked because every existing Restore test attacked the base. Card text is "When this unit attacks, heal X.") When testing Restore on a unit attack, pre-damage the attacker's base (myBaseDamage) and have the unit survive the combat.
  • New ability registries need an include_once in GameLogic.php.
  • MZCHOOSE target string must be pre-computed before the AddDecision call; no function calls as inline params.
  • Always call SWUAfterAction($player) at the end of any ability DQ handler that doesn't chain to another action.
  • Passive keyword grants (e.g. "while X is in play, units gain Ambush") are applied at play time via CollectEntryTriggers or a post-play hook. Tag the unit with the granting card's CardID token (e.g. AddTurnEffect($mz,'SOR_100')) and add a $turnEffectRegistry row (['kind'=>'GRANT_KEYWORD','value'=>'AMBUSH','duration'=>SWU_DUR_PERM] for while-in-play, omit duration for "this phase"). See "Turn-effect registry & durations".
  • Leader deploy is a free epic action. SWUDeployLeader must NOT exhaust resources. Never add SWUExhaustResources to the deploy path.
  • WhenDefeated units block PlayHand even when unimplemented. If a unit is in HasWhenDefeatedAbility, FlushTriggerBag queues a RESOLVE_TRIGGER DQ entry regardless of whether $whenDefeatedAbilities has a handler. That entry makes AllQueuesEmpty() return false, blocking ActionMap for all players. Choose test-fixture units that are NOT in HasWhenDefeatedAbility.
  • WithInitiativePlayer: 2 alone breaks PlayHand. ActionMap checks $playerID == $turnPlayer. If P2 holds initiative and WithActivePlayer: 1 is not also set, TurnPlayer defaults to P2 and P1's PlayHand silently does nothing. Use P1OnlyActions: true instead of the three-line pattern.

3e. Verify

Run the regression after each card is implemented:

curl http://localhost:3400/TCGEngine/zzRegressionSWUSim.php

The suite has no single-test filter that targets one case: ?filter= is matched against the compiled harness filename (SchemaBasedTest.php) before the per-test function name, so ?filter=bodhi (or any case/card name) matches zero files and runs nothing. To exercise just one case, use the live-engine path in the UI smoke test below (TestSchemaSetupTestSchemaStepGetNextTurn), which drives a single schema through the real engine.

⚠ Clause-coverage gate (before declaring the card done). Re-read the card text and confirm EVERY enumerated clause/branch (from the Step-2 decomposition) has a passing test that OBSERVES it — not just that the card "works." For a multi-branch conditional grant, grep the CardID and confirm the impl-hit count matches the branch count: grep -rn '<CardID>' SWUSim/Custom/ (recursive — descends cards/<set>/) — a 3-branch card (e.g. host-aspect → Raid / Restore / Sentinel) that shows only 2 keyword-function hits has a missing branch; a 2-clause passive (protection + trait grant) that shows only the protection is half-done. This 30-second grep is what catches the "one clause silently absent" bug class (Mythosaur, LOF_261) that a happy-path test sails past.

Debug from output — don't skip steps. Common failure patterns:

  • Call to undefined function Do*() → missing Do* implementation
  • expected X, got 0 on SHIELDCOUNT / DAMAGE → $playerID not set before GetZoneObject
  • Unknown schema command → WHEN command not wired in SchemaTestRunner
  • Unknown EXPECT assertion → assertion regex not added to SchemaTestRunner

Debugging rules learned the hard way:

  • Reproduce a "known / deferred engine bug" via TestSchemaStep BEFORE trying to fix it — a later infra rework may have already resolved it. A plan note flagged JTL_227's "onAttack-mid-combat indirect-split mis-resolves" as a genuine bug to fix; stepping the exact scenario showed it now resolves perfectly (the session-50 indirect rework fixed it as a side effect). The right action was to write the deferred guard test and flip the note, not to "fix" working code. Bug notes age; the engine moves under them.

  • A comment is not evidence the code exists. A card can look implemented because a comment says where its logic "is handled" — KeywordEffects.php claimed the combat resolver re-checked SOR_130's "while attacking a damaged unit" Overwhelm, but the resolver never did; the card was fully unimplemented. Before trusting that a passive/keyword is wired, grep the named consumer (e.g. CombatLogic.php for a combat-time effect) and confirm the card ID actually appears there. Same archaeology habit as the dead-duplicate-function trap — a producer's claim with no consumer is a half-built feature. Also relevant when a case exists only in the dead GA-fallback ObjectCurrentPower/HP (~line 10555): present in the file, absent from the live function.

  • The game log accumulates across the entire suite run. Entries you grep may belong to a different test — misreading the preceding test's log entries as the failing test's once cost hours. Anchor to the current test's boundary before interpreting log lines, or assert via LASTLOGCONTAINS instead of eyeballing.

  • When a guarded action doesn't happen, instrument the COLLECTION/loop, not the predicate. A && short-circuit hides the cause: if (HP - dmg <= 0 && !Immune($o)) — if $o is never even iterated (e.g. ZoneSearch didn't return it), an error_log inside Immune() logs nothing and looks like "the guard is firing." Log what the loop is iterating (mzID + CardID + the first operand's value) one level up. A deployed-leader sweep miss looked exactly like a phase/immunity bug until the loop dump showed the leader was never in the search results. (PHP error_log may go nowhere in the container. ⚠ @file_put_contents('/var/www/html/TCGEngine/.claude/tmp/…') is NOT reliable in every env — that container path does not always map to the local workspace, so the file never appears locally (cost a debug cycle in the SEC Phase-13 run). The robust, env-independent probe is AddGameLogEntry('ABILITY', 'TAG '.$var…) at the point of interest, then read it back via curl …/GetNextTurn.php?gameName=N&playerID=1 | grep -o 'TAG[^"<~]*' (drive the scenario with TestSchemaSetup+TestSchemaStep first). Remove the temp log line when done.)

  • TestSchemaStep.php's pending array is the fastest way to diagnose a target-collection bug. It shows the on-the-wire decision (type + param + tooltip) for each step — e.g. it immediately revealed that a deal-damage MZCHOOSE only listed the enemy arena (deployed leader missing) and that Open Fire (SOR_172) emits an MZCHOOSE even for a single target (no 1→PASSPARAMETER optimization, so a test must answer it). When reusing an existing card in a multi-step scenario, drive it through TestSchemaStep first to learn its actual decision shape before writing the WHEN block.

  • GetNextTurn's per-card CurrentPower/CurrentHP render fields are unreliable for deployed leaders (often show -1/0 for healthy units) — a snapshot-context artifact, NOT the real engine value. Don't read them when smoke-testing; assert via the regression's engine-side :POWER/:HP/:DAMAGE checks (read Damage, Status, TurnEffects from the raw object instead).

  • If an assertion result contradicts everything upstream, instrument the assertion, not the action. When the action flow traces correct end-to-end but the EXPECT still fails, dump the raw zone state (including removed flags and element count) at assertion time. A phantom null zone entry was invisible to all upstream tracing and instantly visible in a raw dump.

  • Fixes to generated files must land in the generator too, then be verified by regenerating. Patch both, re-run zzGameCodeGenerator.php?rootName=SWUSim, and confirm the regenerated file still contains the change (it also won't show in git diff — generated files are untracked, so grep for the change directly).

  • Regenerating silently destroys hand-edits in untracked generated files — with zero git evidence. And the regression suite stays green if the loss is in the transport layer (a hand-wired GetNextTurn piece was once erased this way; tests passed while the UI broke). After any regen, run the UI smoke test below. Treat a consumer with no producer — e.g. a window.*Data read that nothing assigns — as the signature of an erased hand-patch or a half-built feature.

  • A regen also REVEALS latent schema gaps (the inverse of the erase trap) — a previously-working generated file can mask a schema missing a declaration the generator references unconditionally. zzGameCodeGenerator.php rewrites every generated file from the CURRENT schema, so a stale generated file built under an older (more complete) schema keeps working until you regen for some unrelated reason — then a swath of unrelated tests fatal with Call to undefined function Get<X>()/Set<X>(). The fix is NOT to revert the regen: the schema is genuinely missing that scalar zone's declaration. Diff against the GA schema (the template — Schemas/GrandArchiveSim/GameSchema.txt), add the missing zone declaration and its Module: Versions= entry, and regen again. (Caught 2026-06-17: adding the TempZone zone forced a regen that surfaced a missing MacroGameIndex scalar — the generator always emits GetMacroGameIndexArray()GetMacroGameIndex(), but SWU's schema had only MacroTurnIndex; 16 capture/shield tests fatal'd. GA declared MacroGameIndex - Value:string; SWU didn't.) Companion habit: after adding any new zone, run the full regression — a green-before/red-after on unrelated tests means the regen exposed a pre-existing gap, not your feature.

  • ⚠ Running zzCardCodeGenerator.php re-fetches the live card API and rewrites the WHOLE GeneratedCardDictionaries.php from current upstream data — which can have DRIFTED from the (untracked, no-backup) dictionary the project was built on. A single-card stub regen once pulled: Unicode typography (curly quotes that defeat the generator's straight-" grant-style detection, en-dashes in –3/–3, non-breaking hyphens in non‑leader), reworded triggers, and a corrected stat — silently breaking 5 unrelated tests. Prefer hand-editing GeneratedAbilityStubs.php directly to add ONE card's triggerbut only for a true one-off where no regen is planned. A hand-edit to a generated file is silently WIPED the next time anyone runs the generator (the files are untracked, so there's no git evidence either). If the work already regenerates (you're touching zzCardCodeGenerator.php, or the card's trigger text isn't auto-detected), put the membership in a generator manual list so it survives regen — the generator now has these hooks: $onAttachedManual (Phase 5 SWUSim block) and $manualStubAdditions (Phase 7, ['whenPlayed'=>[...], 'whenPlayedAsUpgrade'=>[...]]). (Caught 2026-06-18: a card hand-added to the stub for a "When played as a unit:" trigger the auto-match doesn't catch would have been erased by the same feature's Phase-1 regen; moving it to $manualStubAdditions made it durable — verified by a fresh regen reproducing it.) If you must regenerate: the generator now normalizes punctuation (NormalizeCardPunctuation), but run the full regression immediately after and treat any newly-red unrelated test as data drift (cross-check stats against cards.json, which is authoritative). Other untested cards may have silently changed — the regression only guards tested ones.

  • Stub detection is coupled to exact text phrasing; the upstream dataset rewords triggers. GeneratedAbilityStubs.php is generated by matching substrings in card text. The current dataset uses phrasings the base rules miss: "When this unit completes an attack:" (→ onAttackEnd, not "On Attack End:"), "When Deployed:" (→ whenPlayed for leaders, not "When Played:"), "When this upgrade becomes attached…" (→ whenPlayedAsUpgrade, not "When played as an upgrade:"), "When this unit is attacked:" (→ onDefense, not "On Defense:" — see the CR-window note below). If a trigger won't fire and Has<Trigger>Ability(cardID) returns false despite matching card text, add the phrasing to the generator's Phase-7 detection (zzCardCodeGenerator.php) — note Has<Trigger>Ability stubs come from zzCardCodeGenerator.php (the API-refetch one), so to avoid a dictionary regen, hand-edit GeneratedAbilityStubs.php to add the one case AND patch the generator's detection (so a future regen keeps it), but do NOT run the card generator just for this. Register the handler in the array matching the trigger window the engine actually fires (whenPlayedAsUpgrade dispatches to OnWhenPlayedAsUpgrade; the whenPlayed fallback dispatches to OnWhenPlayed — they are different arrays).

  • CR window-equivalence — don't invent a new trigger type for an alternate phrasing; map it to the existing window. Per CR 15.c, "'On Attack,' 'On Defense,' 'When a unit attacks,' and 'When a unit is attacked' abilities all resolve in the same timing window." So "When this unit is attacked:" = the On Defense window ($onDefenseAbilities["X:0"], fired for the defender in CollectCombatStep1Triggers); a plan that proposes a brand-new WhenAttacked trigger is over-engineering — register it as On Defense. Check the CR (.claude/SWUSim/refs/comprehensive-rules.md) before building a new trigger type for any "When a unit [verb]s" phrasing.

  • Being the FIRST consumer of a wired-but-unused window = expect latent bugs; test the cross-player path. A trigger window can be fully wired in the engine yet have zero registered handlers (so it has never actually fired). On Defense was exactly this until SOR_196 — HasOnDefenseAbility had detected LAW_121/TS26_24 but neither had a handler, so the path was effectively untested. Implementing the first real one surfaced two latent bugs worth knowing for any defender/opponent-context trigger: (1) relative-mzID frame — the trigger's mzID was captured in the active player's frame (theirGroundArena-N) but dispatched under the defender's controller, so it resolved to the attacker; fix is a frame-flip at the AddTrigger site (preg_replace('/^their/','my',$mzID) — the defender always sits in the attacker's their zone). (2) single-trigger orchestration ownerFlushCombatTriggerBag's 1-trigger branch queued RESOLVE_NEXT_TRIGGER/SWU_TRIGGER_RESUME on the trigger's owner, which is the active player for On Attack (works) but the opponent for On Defense → the orchestration never drained in the attacker's action and combat hung; fix is to queue orchestration on the active player (the effect still dispatches under the EffectStack entry's Controller, so it applies to the right player). General rule: when a single combat/entry trigger is owned by the non-acting player, its orchestration belongs on the acting player's timeline. Reference: SOR_196 Chewbacca (first On Defense).

  • Cards readied at "the start of the next round" are NOT readied during regroup itself — the ready loop runs at the next ReadyPhase, AFTER the regroup-resource step. To observe a post-regroup ready state (e.g. SOR_186 "can't ready this round" leaving a unit exhausted while others ready), drive the action phase to a close with consecutive passes (P2>Pass then P1>Pass reaches regroup), then resolve the regroup-resource MZMAYCHOOSE for both players with P1>ResourcePass + P2>ResourcePass — only then does the new round's ready loop run and the state become assertable. A WHEN block that stops at the first regroup prompt asserts mid-regroup (units not yet readied) and fails confusingly.

  • The regression runner's answerDecision diverges from the live ProcessInput path for TWO consecutive opponent (P2) decisions after a turn-swap in the same WHEN block. P1OnlyActions makes P2 auto-pass (it eats P2's decisions); but even without it, the in-process runner only applies the FIRST of two consecutive P2>AnswerDecisions when a turn-swap (P1>AttackP2>PassP1>Play…) preceded them — the live TestSchemaStep path applies both correctly (verify there: both MZCHOOSEs appear and resolve, final state correct). A single-turn opponent-multi-decision (P1>PlayHandP2>AnswerP2>Answer, no intervening swap) DOES work in regression (Pillage_ForcesDiscard). CONFIRMED in Phase F — a single P1 action that queues a cross-player decision drives cleanly in regression, in every shape tested: P1>PlayHandP2>AnswerDecision (opponent MZCHOOSE — SOR_041; or YESNO — SOR_233), AND P1>Answer(target/multichoose)P2>Answer(mzchoose/multichoose) (caster-decision-then-opponent-decision within the SAME action, no turn swap — SOR_187 caster picks 2 → opponent picks 1; SOR_174 both players keep-2). So the broken case is narrowly TWO consecutive P2 decisions after a turn-swap — NOT "any opponent decision." Write the cross-player test normally (drive P1>… then P2>…); only fall back to a smoke test for the genuine two-P2-after-swap shape. So: when your card's opponent-choice path can't be driven cleanly in a multi-turn regression test, don't ship a red/brittle test — verify it with a live smoke test and lean on the shared helper's own single-turn regression (e.g. SWUDiscardCards's choose-N path is already covered by Pillage; a card that just calls it only needs to test its own gating/invocation, not re-prove the helper). Reference: SOR_175 (opponent discards 2-of-3), SOR_187/SOR_233/SOR_174 (Phase F cross-player paths).

  • SEC run (Phases 6–11) regression-driving lessons — three more in-process-runner gaps + two setup shortcuts:

    • Cross-player WhenDefeated (your unit killed on the OPPONENT'S turn) leaves a pending RESOLVE_TRIGGER the runner does NOT auto-drain → the whenDefeated effect never applies in the snapshot (cost SEC_055's heal-on-defeat; the live step harness showed RESOLVE_TRIGGER|WhenDefeated|… player:1 still pending after P2>Attack). Fix: test a whenDefeated by having the unit die as the ATTACKER (P1>AttackGroundArena:0:0 into a bigger/defended unit, or pre-damage it with CARDID:1:N so the counter kills it) — then it resolves inside P1's own action and drains cleanly. (SEC_055, SEC_154, SEC_207, SEC_215, SEC_221, SEC_261 all tested this way.)
    • TWO consecutive SAME-PLAYER MZCHOOSE decisions queued by one event do NOT both drive in the runner (even the first may not apply) — SEC_232 "draw 3, then put one card on top of deck and another on the bottom" left the deck untouched in regression but resolved end-to-end in the live TestSchemaStep path (TOP choose → answer → BOTTOM choose → answer → pending:[]). Guard the drivable prefix (assert the draw) and verify the sequential picks live; don't ship the red multi-pick test.
    • A cross-player auto-PASSPARAMETER (opponent has exactly 1 legal target) does NOT auto-drain — the active player's auto-decision drains, the opponent's does not (SEC_147 each-player-discard: P1 auto-discarded, P2 didn't). Fix: give the opponent ≥2 targets so it's a real MZCHOOSE, then answer P2>AnswerDecision:myHand-0 explicitly (a single opponent decision after one P1 action drives fine).
    • Resource setup + assertion: WithP1Resources: 3:SOR_046:1,1:SOR_046:0 = 3 ready + 1 exhausted resources (comma-sep count:cardID:status groups); assert ready count with P{n}RESAVAILABLE:K (works for P1 AND P2). Use these for exhaust-/ready-a-resource cards (SEC_215/216/225/235) and cost-discount cards (assert leftover resources to prove -N).
    • Exhausted leader setup: CommonSetup: …/{myLeaderReady:0} starts P1's leader exhausted (for "ready a non-unit leader" — SEC_188). Assert leader state with P1LEADER:READY / P1LEADER:EXHAUSTED.
    • Cost-modifier patterns (Phase 9): subject-keyed one-shots → $playCostModifiers[$cardID]; armed "next X costs less" → a SWU_…_DISCOUNT_NEXT global set in the trigger, read in SWUComputePlayCost, consumed in ActivateCard, cleared at RegroupPhaseStart (SEC_110/261); "first X each phase" → a SWU_…_USED flag set at the play/attach site (SEC_064 marks at ATTACH_UPGRADE). Keyword-grant-to-others (Raid/Restore/Overwhelm/Hidden/Sentinel "each other friendly…") → add a case/loop in the matching GetConditionalKeyword_X_Value / HasConditionalKeyword_X (SEC_047/099/104/140/201/203) rather than a handler.
  • SEC Phase 12 — combat reactions, ability-loss, interactive prevention (9 new reusable seams):

    • onDefenseFromUpgrade seam (SEC_052) — mirror of onAttackFromUpgrade: scan the DEFENDER's upgrades in CollectCombatStep1Triggers, AddTrigger($defController,'OnDefenseFromUpgrade',$up->CardID,$defMzForDef); OnDefenseFromUpgradeTrigger dispatches $onDefenseFromUpgradeAbilities[CardID] AND sets SWU_PENDING_DEF_REACTION so the combat-pause holds. Register a granted On-Defense-from-upgrade by adding $onDefenseFromUpgradeAbilities["X"].
    • Disclose woven into On-Defense (SEC_098 own-unit, SEC_052 upgrade) — the disclose MZMULTICHOOSE is a blocking decision, so the existing combat-pause hops the resume onto the defender's queue exactly like LOF_067's Force YESNO. Just register $onDefenseAbilities["X:0"]SWUQueueDisclose(...).
    • PlayerCanDisclose cross-context — it now restores $playerID AFTER its hand loop (was before → read the caller's hand). For a disclose whose discloser ≠ the ambient $playerID (e.g. SEC_038 Condemn: the DEFENDER discloses but the trigger fires under the ATTACKER), set $playerID = $discloser before SWUQueueDisclose.
    • Defender UID re-validation in combat (SEC_187) — ExecuteSWUAttack captures SWU_CURRENT_DEFENDER_UID; SWUCombatDamage re-resolves the unit target by UID and fizzles the attack (no damage) if the defender left play before damage (any On-Defense bounce/defeat), instead of hitting whatever shifted into the stale theirGroundArena-N. So a mandatory pre-damage self-bounce On-Defense is just $onDefenseAbilities["X:0"] = SWUBounceUnit (synchronous, no pause needed).
    • Attack-duration LOSE_ABILITIES (SEC_038 self while attacking, SEC_157 the defender for this attack) — register the CardID with ['kind'=>'LOSE_ABILITIES','duration'=>SWU_DUR_ATTACK]; since that equals the registry default, SWUMakeTurnEffect emits a BARE 'CARDID' token → LostAbilities's raw in_array detects it AND SWUExpireTurnEffects('attack') drops it at attack-end. Set it in BeginSWUAttack (own, pre-target so line-492 own-OnAttack is suppressed) or in CollectCombatStep1Triggers (defender, before the OnDefense collection). The OnDefense collection now gates on !LostAbilities($defender).
    • ⚠ VALUE keywords don't honor suppressionGetKeyword_Raid_Value/Restore read the registry directly (no SWUKeywordSuppressed). For a lose-abilities unit, guard the COMBAT reads: $raidVal = LostAbilities($attacker) ? null : GetKeyword_Raid_Value($attacker) (SWUCombatDamage ~905, Restore ~827). Combat is the only place Raid/Restore matter, so this is complete.
    • Multi-attack loop (SEC_103 "any number of other units, even if exhausted") — SWU_MONMOTHMA_LOOP var holds the exclude-UID CSV; _SWUMonMothmaOffer MAY-offers the remaining OTHER units (ready or exhausted) → BeginSWUAttack(noBases=true). Rides the chained-attack hooks: CollectAfterAttackTriggers queues a resume while the var is set; the SWU_TRIGGER_RESUME stack-empty branch re-offers after each attack. NO SWUAfterAction (the play's FINISH_PLAY_CARD finalizes — mirror SEC_172).
    • "When damage is dealt to this unit" reaction (SEC_143) — POST-damage, no pause. _SWUOnUnitDamaged($obj) dispatcher fired from CollectCombatStep3Triggers (capture $combatCtx['attackerTookDmg'] at the counter-damage points; defender via dealtToUnit; each gated on survived + >0) AND from SWUDealDamageToUnit (survived).
    • Granted On-Defense via a per-unit marker (SEC_231) — a non-interactive reaction (e.g. create a Spy) just checks the marker on the defender in CollectCombatStep1Triggers and acts directly (no trigger/pause). One token can double as a GRANT_KEYWORD (Sentinel) AND the On-Defense signal.
    • ⚠⚠ Interactive pre-damage prevention (SEC_101, first one) — combat path MUST go through AddTrigger (a synthetic trigger type), NOT a direct decision: a plain queued decision leaves FlushCombatTriggerBag=0, so ExecuteSWUAttack queues SWUCombatDamage directly and combat commits with NO pause (the offer never resolves first). The trigger forces the SWU_TRIGGER_RESUME/combat-pause path; the handler sets a one-shot marker SWUCombatDamage consumes before each of its 6 unit-damage points (the 3 combat-ordering branches — Shoot First, LAW_086 defender-first, normal-simultaneous — × the attacker + target chains). ⚠ It's 6, not 4 — the defender-first branch added two; any "prevent / bypass / modify combat damage to a unit" card must touch ALL 6 (ASH_062 The Mandalorian prevent via _SWUConsumeAsh062Prevent, ASH_196 Gorian Shard's Corsair unpreventable-bypass via _SWUDamageUnpreventable — both prepend their branch to all six chains). Grep _SWUConsumeAmidalaPrevent in CombatLogic to enumerate the live set before editing. Ability path: add $skipPrevent to SWUDealDamageToUnit, defer + offer, re-apply (skipPrevent) on decline. Indirect is exempt for free — it writes Damage directly, never through SWUDealDamageToUnit or SWUCombatDamage's unit path.
  • SEC Phase 13 — novel subsystems (reusable seams; folded at the end-of-run retro):

    • Source-linked state that ends when the SOURCE leaves play = UID-keyed GlobalEffects flag + a LAZY sweep, not a precise leave-play hook (SEC_192 Tarkin take-control-reverts-when-he-leaves). Store SWU_X|{srcUID}|{payload} on the source's controller; run a sweep (_SWURevertSec192Steals from SWUAfterAction, next to SWUFlushDeferredReplacements) that reverts/cleans any entry whose srcUID is no longer in play (_SWUUnitInPlayWithUID). This covers ALL leave-play paths uniformly (defeat / bounce / capture / return-to-hand) with one hook; UIDs never repeat so a stale flag can't mis-match. Same idea drives base captives (SEC_195 Arrest): bases have no Subcards, so _SWUBaseCaptureUnit mirrors DoCaptureUnit's CR 8.34 steps but stores the captive in SWU_BASECAPTIVE|{cardID}|{owner}, and _SWURescueBaseCaptives (RegroupPhaseStart) rescues via DoRescueUnit — note DoRescueUnit($captiveSubcard,$hostObj) ignores $hostObj, so a synthesized (object)['CardID'=>…,'Owner'=>…] + null host works.
    • Per-action tracking ("during their PREVIOUS action this phase") (SEC_194) — the phase-level SWU_DMGBASE_ is damage-/phase-scoped, not action-scoped. Set a TRANSIENT in ExecuteSWUAttack when the target is a base (SWU_ACTION_BASEATK=base owner), then FINALIZE in SWUAfterAction into SWU_LAST_ACTION = "{actor},BASEATK,{owner}" or "{actor},OTHER" (every action overwrites it → it always holds the previous action; reset at RegroupPhaseStart). The reader checks opp,BASEATK,me. ⚠⚠ ,-delimited, NEVER || is the SWUVar KEY=VALUE delimiter, so a |-joined value silently truncates to its first field (a "2|BASEATK|1" read back as "2" → condition always false; cost a debug cycle even though the comma rule is already documented — it bites on structured values, not just multi-field specs).
    • "Can't be played from your hand" = a hand-SOURCE block, not a global one (SEC_053). Add a _SWUCantPlayFromHand($cardID) registry; gate in ActivateCard on strpos($mzID,'Hand')!==false (so the Plot/resource path myResources-N is unaffected) + suppress the hand glow in CanAffordActivationReserve (confirmed hand-only caller). Don't reuse SWUCardPlayBlocked (that's the global name-block path → would block Plot too).
    • On-demand Plot-from-resources play (SEC_245) — reuse the leader-deploy Plot affordability scan ($Plot_Cards + SWUComputePlayCost ≤ ready) MINUS the SWU_PLOT_K/P window gate; MZMAYCHOOSE → guarded nested ActivateCard (JTL_089#1 turn/PASS save-restore). ⚠ Ruling: "play a card WITH Plot from your resources" does NOT trigger the Plot keyword's deploy "replace with top of deck" (that's the leader-deploy ability) — the card's own ramp clause is the refill (verify via resource arithmetic: exactly 1 deck card consumed, not 2). "Put top card into play as a resource" = SWURampResourceReady (enters READY — the SEC_107 convention).
    • New play-from-opponent's-discard variant = extend the OTPF/OTPP modifier family (SEC_205). OTPN = "opponent may play from their discard at cost, IGNORING aspect penalties": add it to the modifier check + cost branch in SWUPlayFromOpponentDiscard (cost = CardCost, no SWUAspectPenalty). Combat base-hit (SWUCollectCombatHitTriggers case gated on dealtToBase) mills the defender's deck (SWUMillTopCard) and stamps the milled discard entry. "For this phase" rides the existing SWUClearDiscardModifiers (RegroupPhaseStart). ⚠ Pre-existing limit: SWUPlayFromOpponentDiscard only places UNITS.
    • Power-at-defeat snapshot (SEC_035 "if he had 7+ power, return to hand") — the whenDefeated closure runs AFTER subcards (Experience) are stripped, so it reads base power. Capture the real power in CollectWhenDefeatedTriggers's subcards-intact block (the same place JTL_073/SEC_039 read upgrades — and it runs for COMBAT defeats too) into an mzID-keyed global, read it in the closure. The positive test must use a value only reachable via subcards (base <7, Exp-boosted ≥7) so a pass proves the snapshot, not base power.
    • Per-instance trait GRANT (SEC_156 "attached unit gains the Rebel trait") — the mirror of the SEC_054 trait-LOSS line: TraitContains($obj,'REBEL') returns true when _SWUUnitHasUpgrade($obj,'SEC_156'). Counters that use TraitContains (not bare HasTrait) then see the granted trait; prove it with a counting test where a granted unit changes the tally (value distinguishes grant-counts vs grant-ignored).
    • In-combat reveal-top-N + trait-match (SEC_220) — the first decision (OPTIONCHOOSE which deck) is safe in OnAttack (fixed labels, not mz-counted); the reveal + trait-filtered MZMAYCHOOSE run in continuations (safe from the OnAttack $playerID-restore skip). Reveal = array_shift off the deck (SOR_223 seam), trait union via CardTrait split, bottom via _topDeckPutRemainingToBottom.
    • Galen-style "named cards lose ALL abilities while X in play" (SEC_046) — NAMECARD (SOR_062 NAMEBLOCK template) → SWU_GALEN|{uid}|{encName}. The reusable primitive is a CARD-LEVEL helper _SWUGalenSuppressesCard($owner,$cardID) (non-leader card owned by $owner, named by an in-play opposing namer) — it works for cards NOT in play, which a LostAbilities($obj)-only approach can't reach. "Loses ALL abilities" means gating EVERY surface, and they split two ways: (1) already covered by LostAbilities — anything that flows through SWUKeywordSuppressed (innate/granted keywords incl. Sentinel/Shielded-on-entry) or already has a LostAbilities($attacker) guard (the combat Raid/Restore value reads, line ~996/827 — VALUE keywords don't honor suppression otherwise) — these only need the in-arena object to have Owner set (it does). (2) need an EXPLICIT gate with _SWUGalenSuppressesCard($player,$cardID) (or LostAbilities($obj) where an in-arena object is in hand) wherever the surface keys on a CardID / hand card / resource (no reliable object Owner): When Played (CollectEntryTriggers), When Defeated (CollectWhenDefeatedTriggers), Plot (_SWUEligiblePlotResources+PlayerHasPlotsToPlay), Smuggle (SWUSmuggleResource), Piloting (the Unit/Pilot offer — the hand object's Owner is unset, so gate on $player not HasKeyword_Piloting), a reactive base trigger (Force bases in CombatLogic), shield prevention (SWUConsumeShieldToken → return false; the token stays attached but does nothing), Events (OnPlayEvent — a named event still pays cost + discards but does nothing), On Attack End (CollectAfterAttackTriggers, gate LostAbilities($attacker)), combat-hit "deals combat damage" (SWUCollectCombatHitTriggers early-return), When-Played-as-Upgrade (CollectWhenPlayedAsUpgradeTriggers), play-cost modifiers (subject-keyed + the field-source loop in SWUComputePlayCost), and reactive observers (Gideon/own-/opp-play — use a LostAbilities-filtered count _SWUCountActiveUnitsWithCardID, NOT the plain _SWUCountUnitsWithCardID which control/"you control N" checks keep, since a suppressed unit is still controlled). Also: base Epic Actions (SWUBaseAction), Bounty (all 3 offer sites — capture + the 2 defeat blocks), and the from-upgrade combat seams (onAttack/onDefense/onAttacked/onAttackEnd-FromUpgrade — gate per-upgrade so a named upgrade grants nothing). ⚠ The repo has no single ability chokepoint — "loses ALL abilities" means auditing EVERY trigger COLLECTOR; grep grep -rn "LostAbilities(" Custom/ to see what's already gated, then sweep the collectors that aren't (events, OnAttackEnd, combat-hit, whenPlayedAsUpgrade, cost modifiers, reactive observers, base epics, bounty, the from-upgrade seams were ALL gaps found by sweeping in two passes after the first cut — budget a dedicated sweep for any "loses all abilities" card). ⚠ Printed STATS are not abilities — naming "Experience" must NOT remove the +1/+1 (a deliberate NEGATIVE test); the upgrade stat loop in ObjectCurrentPower/HP correctly ignores LostAbilities. Continuous + auto-ends when the namer leaves play (lazy _SWUUnitInPlayWithUID). Test each surface by playing Galen + NAMECARD (AnswerDecision:<title>, spaces OK) then the opponent's matching action.
    • "At the start of the NEXT action phase, …" (SEC_073) — arm a GlobalEffects flag that SURVIVES the regroup (do NOT add it to RegroupPhaseStart's clear list — mirror SWU_HAN_DEFEAT_RESOURCE); consume + resolve it in ActionPhaseStart. A "each enemy unit, its controller pays 1 or it's exhausted" is ONE cross-player MZMULTICHOOSE (set $playerID=target before queuing; cap = min(units, ready resources); cap 0 → exhaust all inline): selected = pay 1 each (kept), unselected = exhausted. The multi-round regression drive works: P1>PlayHandP2>PassP1>Pass (→regroup) → P1>ResourcePass + P2>ResourcePass (→ next action phase, the queued decision appears) → answer it (probe the exact sequence with TestSchemaStep first — the pending array shows when the decision surfaces).
    • Win condition (SEC_145 "you win the game") — set global $gWinner; $gWinner = $caster; (the same var the base-defeat path sets; the test asserts P1WIN/P2WIN via state->winner()). Arena-control win = arm SWU_CONFIDENCE|{caster}|{arena} at play (OPTIONCHOOSE Ground/Space), check at RegroupPhaseStart (caster controls units in that arena via GetGroundArena/GetSpaceArena($p) count, opponent controls 0), one-shot consume.
    • "Play only as your first action in the action phase" (SEC_145) — a per-player SWU_ACTED_PHASE flag set in SWUAfterAction (guarded so it doesn't stack) and cleared in ActionPhaseStart; gate the card in SWUCardPlayBlocked ($cardID===X && GlobalEffectCount($player,'SWU_ACTED_PHASE')>0). The play-legality check runs BEFORE the play's own SWUAfterAction, so the caster's first action sees the flag unset. Test by taking any action first (an attack is free) then asserting the card stays in hand.
  • SEC Phase 14 — TWO-SIDED LEADERS (all 18 SEC_001–018; folded at the end-of-run retro):

    • The two-sided-leader recipe. Front ability = $leaderAbilities["SEC_XXX"] (BARE CardID — the "Action [...]:" line; SWULeaderAction exhausts the leader BEFORE calling the closure, so the closure only PAYS resources, never re-exhausts). Resource cost → $leaderActionResourceCosts["SEC_XXX"]=N (gate-checked before exhaust). Per-card availability conditions ("if a friendly damaged unit exists", "if 4+ exhausted units") → a case in SWULeaderActionAffordable. Deploy side = the NORMAL trigger registries under SEC_XXX:0 ($onAttackAbilities/$onAttackEndAbilities/$whenPlayedAbilities) OR a reactive (combat/hook). A leader-front passive (not an Action) is active while undeployed: _SWULeaderReadyUndeployed($p,'X') for a ready-gated/exhaust-cost passive, or a plain leader-zone-CardID check (_SWUControlsMonMothma = GetLeader CardID===X) for a continuous one that's on BOTH sides; the deployed-side ability replaces the front passive.
    • Front-vs-deploy reactive SPLIT (SEC_013 Luthen, SEC_016 Padmé) — the same reactive trigger on both sides with different payoffs: front = "may exhaust this leader → smaller effect", deploy = "may → bigger effect (no exhaust)". ONE trigger/dispatcher fn branches on _SWULeaderReadyUndeployed (front) vs _SWULeaderDeployed (deploy); the front's exhaust is the cost (YESNO → set $l->Ready=false in the GetLeader loop, then the effect). Fire it via AddTrigger at the event site so it rides the flush — e.g. SEC_013 at the combat attacker-death site (next to SWU_ATTACKER_DEFEATED), SEC_017 in SWUCollectCombatHitTriggers. ⚠ The deploy side and front side use distinct trigger types when both can be armed from the same site: bare SEC_017 (deploy, CardID-keyed) + SEC_017#1 (leader-front observer for ANY friendly attacker). "a unit or base" target set = _SWUAllUnitsAndBases + DEAL_TARGET|N; "a unit" only = _SWUAllUnitsOnly.
    • _SWUFinalizeUpgradeAttach gained $suppressAfterAction (8th param) + returns the triggered count. Use when a card plays an upgrade then does MORE (SEC_003 Lama Su: attach → deal 1 to the host → close) or when combat owns the close (onAttackEnd play-from-discard). prepaid=1 = a clean "−1 cost" discount (SWUPayCost($p,$cost,$prepaid) exhausts cost−prepaid). Caller owns the After Action: call SWUAfterAction only when $triggered===0 (a triggered upgrade's own flush owns it). Host-filter via SWUGetUpgradeValidTargets ∩ <friendly non-Vehicle>.
    • SWUReturnResourceToHand Owner==0 latent-bug FIX (SEC_008 Bail) — resource Owner is often 0 (set, not null) so intval($obj->Owner ?? $player) returned 0 → AddHand(0) SILENTLY DROPPED the returned card (HANDCOUNT stayed 0). Now defaults $owner<=0 → $player. The documented zone-Owner gotcha bites shared helpers too — any AddHand($obj->Owner …) on a resource/zone object needs the <=0 → controller guard.
    • Reconciling SPECULATIVE wiring (SEC_012 Cassian) — when a memory/plan flags a card with speculative wiring from a prior session, grep ALL its sites, REMOVE them, and re-derive from the printed text. SEC_012's bogus "immune while initiative" (in SWUImmuneToHpDefeat/SWUAvoidsDefeat) was deleted; the real "friendly units that damaged an opponent's base this phase can't be attacked (unless Sentinel)" reused the EXISTING per-unit SWU_DEALT_BASEDMG_{uid} flag (set in combat for SEC_077, cleared at RGS) — grep for an existing per-unit/phase flag BEFORE building new tracking; gated in BOTH SWUGetValidAttackTargets loops with a Sentinel exception (SOR_142 pattern). Leader-front passive ⇒ gate on …CardID==='SEC_012' && empty($l->Deployed).
    • SWUQueueAnotherAttack constraint extended with costlt:N / costle:N (SEC_006 Yularen) — the chained-attack subsystem (SWU_CHAINED_ATTACK="rebelOnly,mayDecline,bonus,uid,constraint") now supports cost caps. Leader "attack then attack a CHEAPER one" arms costlt:{firstCost} before BeginSWUAttack; deploy "costs 4 or less" = costle:4.
    • Custom disclose (OR over aspects) ≠ SWUQueueDisclose (SEC_004 Leia) — "disclose [Vigilance, Command, …, or Heroism]" is an OR (reveal a card with ANY one of those icons), NOT the AND-multiset SWUQueueDisclose covers. Implement as a plain MZ(MAY)CHOOSE over hand cards with ≥1 matching icon (array_intersect(SWUCardAspectIcons($cid), $five)), then read the CHOSEN card's aspects for the follow-up ("a unit that doesn't share an aspect with the disclosed card" = array_intersect empty). Shared offer fn takes a $may flag (leader mandatory / deploy On-Attack MAYCHOOSE — safe in OnAttack) + a $closeAction flag (leader owns close, deploy combat-owns).
    • Play-then-find (act on a unit AFTER playing it from hand) (SEC_007 Dryden Ambush-grant, SEC_018 DJ capture) — set $gPlayGrantTurnEffect='SEC_XXX' (a findable MARKER $turnEffectRegistry row) before the nested ActivateCard (with $gTurnPlayer/PASS save-restore neutralizing the inner after-action), then scan all 4 arenas for the unit bearing that token. SEC_018 then DoCaptureUnit($captor,$newMz). ⚠ "When Played resolves AFTER capture" is approximated (When Played fires on the normal play path, BEFORE capture — an edge for non-vanilla played units; test with a vanilla unit). "Rescued units enter play ready" = gate DoRescueUnit's entry Status on _SWULeaderDeployed($owner,'SEC_018').
    • Cross-player leader decision (SEC_010 Dedra) — "choose an enemy unit; ITS CONTROLLER may …" = leader MZCHOOSE → a CUSTOM continuation queues the OPPONENT's YESNO ($playerID→opp, decision owner = opp) → a 2nd CUSTOM resolves under the opp frame (YES) or the caster (NO). Drives cleanly in regression as a single P1 action; test with WithActivePlayer:1 (NOT P1OnlyActions, which makes P2 auto-pass and EATS the YESNO) + P2>AnswerDecision:YES/NO.
    • DSL/fixture notes: AttackGroundArena:idx:BASE attacks the base directly even with enemy units present (forces theirBase-0); needed for "deal/observe base damage with a board". Token-unit fixtures: TWI_T01 (Battle Droid, 1/1 ground) for "ready/count a token unit"; SEC_T01 = the Spy token. A deployed-leader reactive that's a FIELD-OBSERVER (keyed on the CardID being in play, e.g. SEC_002 Jabba's amount-aware on-damaged, SEC_011 token-count passive) is testable by placing the leader-unit via WithP*GroundArena: SEC_XXX:1:0 + the :1:1:1 deployed leader flag — it exercises the real observer path (the flag isn't strictly needed; the observer reads CardID-in-play). Combat amount-aware reactions: _SWUOnUnitDamaged($obj,$amount) now threads the dealt amount (captured as attackerDmgAmt/defenderDmgAmt in SWUCombatDamage) for "deal THAT MUCH" effects.
  • LAW Phase 5-8 lessons (upgrades, bases, two-sided leaders; folded at the end-of-run retro):

    • ⚠ Cross-player auto When-Defeated triggers don't self-drain — the test needs a trailing P1>Pass. When an ENEMY action (e.g. P2>AttackGroundArena) defeats a unit whose granted/own When-Defeated belongs to P1, the RESOLVE_TRIGGER CUSTOM is queued in P1's decision queue but NOT processed during P2's action cycle (it's the non-active player's queue). It sits pending. A trailing - P1>Pass drains it (P1's next action processes the auto-CUSTOM first). Symptom: the trigger fires (pending: RESOLVE_TRIGGER|…) but its effect (damage/credit) hasn't applied at assertion time, with 0 failed elsewhere. Do NOT feed AnswerDecision:- — that CANCELS the auto-trigger. (LAW_201 Thermal Detonator.) Same-player defeats (P1 attacks and P1's unit dies) resolve inline — no extra step.
    • ⚠ A played upgrade auto-attaches when there's exactly one legal host → its When-Played fires immediately after PlayHand:N, with NO separate ChooseMyGroundUnit step. Adding a phantom ChooseMyGroundUnit:0 then desyncs the very next decision (it gets eaten as that answer). The host-choice prompt only appears with ≥2 legal hosts. Step the flow via TestSchemaStep to see whether the pending after PlayHand is the host-choice or already the When-Played decision. (LAW_187 Staccato Repeater.)
    • UPGRADECOUNT:N counts Shield (SOR_T02) and Experience (SOR_T01) token subcards too, not just real upgrades — a host that gets +1 upgrade AND a granted shield is UPGRADECOUNT:2. SHIELDCOUNT:N counts only SOR_T02 subcards. (LAW_111 Leia's Disguise: the When-Played shield made the host UPGRADECOUNT 2.)
    • Multi-target damage where targets can DIE mid-resolution: resolve picks to stable keys FIRST (Token-unit/arena → UniqueID via SWUFindMzByUID; Credit tokens → re-resolve SWUUsableCreditTokenMzIDs each iteration; hand discards → sort mzIDs DESCENDING by index). Dealing/defeating by the original myZone-N strings mis-targets after the first lethal hit reindexes the zone — the same discipline as the SOR_043 mass-defeat row, but it bites MZMULTICHOOSE-then-act handlers too (LAW_187, LAW_201, LAW_017, LAW_011).
    • New per-phase flags go in the central hook + clear at RegroupPhaseStart next to SWU_FRIENDLY_DEFEATED: SWU_REBEL_DEFEATED (set in SWUCollectLeavePlayReactions when a defeated unit HasTrait Rebel; LAW_005 Jyn), SWU_CREATED_TOKEN (set in ALL token creators — SWUCreateCreditToken/DoGiveShieldToken/DoGiveExperienceToken/SWUCreateUnitToken; LAW_016 The Client). A leader-front PASSIVE-only side (no Action, e.g. LAW_009 Hera) needs SWULeaderActionAffordable → false so the engine doesn't offer a phantom UseLeaderAbility (the affordability check does NOT verify $leaderAbilities[cardID] exists). Same →false guard for any DEFERRED leader's front (LAW_014/LAW_015).
    • Self-defeat-after-attack = grant the LAW_062 attack-duration marker (unconditional; SWUMakeTurnEffect('LAW_062',[],SWU_DUR_ATTACK) → sets combatCtx['law062SelfDefeat'] → reuses the LAW_205/HeroicSacrificeDefeatTrigger dispatcher) alongside SWUAddAttackPowerBonus + an OVERWHELM marker for "attack with a unit, +X/+0 + Overwhelm, then defeat it" leaders (LAW_001 Saw). Granted When-Defeated via an upgrade subcard → add to the CollectWhenDefeatedTriggers subcard scan (SEC_156/JTL_073 pattern); pass needed payload (host cost, ready-flag) via the mzID slot since FlushTriggerBag drops extraParams (LAW_141, LAW_201).

Resource-reveal / multi-defeat lessons (Elia Kane SEC_242 + Scanning Officer SHD_114; folded at the standalone-batch retro):

  • Reusing a generic helper across cards → the card-specific logic lives in the HANDLER, not the helper; resist a cardID param. SWURevealResources($activePlayer,$ownerPlayer,$count) just reveals N resources Ready-first (anti-exploit mt_rand tiebreak), card-agnostic. Both Elia Kane (player picks 1 to defeat, replace ready) and Scanning Officer (auto-defeat each revealed Smuggle, replace exhausted) differ ONLY in the post-reveal handler — so no per-card weighting/param is needed. Before threading a cardID through a shared helper, check whether the divergence is actually downstream of it. (See [[swusim-look-at-opponent-resources]] for the shuffler's priority order + the "only a REVEALED mzID may be defeated" enforcement for player-pick variants.)
  • ⚠ Defeating/moving MULTIPLE cards out of one indexed zone in a single pass — two separate hazards: (1) defeat in DESCENDING index order (each SWUDefeatResource runs CleanupRemovedCards and compacts, so a low index processed first shifts the rest — same discipline as the SOR_043 / LAW_187 mass-defeat rows, now also for resources); (2) MZMove/SWURampResourceExhausted/…Ready only mark the SOURCE card removed — they do NOT compact it out, so a loop that replaces N-from-deck must re-scan for the first non-removed myDeck card each pass rather than blindly taking myDeck-0 (which stays the spent, now-removed card → only the first of N moves succeeds). Single-move callers (SEC_242) never expose this; the multi-move SHD_114 did.
  • Multi-card deck/hand fixtures are WHITESPACE-separated, optionally bracketedWithP2Deck: [SEC_080 SOR_095 SOR_100], NOT comma-separated (A,B,C parses as ONE CardID "A,B,C" and silently seeds a 1-card deck). _parseDeckList trims [] then splits on \s+. A wrong-count replacement/draw assertion is the symptom.

TWI Phase 7-22 lessons (direct-damage → leaders, 16 phases; folded at the autonomous→pair-programmed retro):

  • ⚠⚠ PRE-EXISTING ENGINE BUG — own-play reactions do NOT fire when the played unit enters the SPACE arena. SWUCollectOwnPlayReactions ("when you play a/another unit") is never reached for a space-unit play (reproduced with the untouched LOF_087 Eighth Brother, so it's engine-wide — affects SOR_182/LOF_087/TWI_080/101/018/etc.). SWUCollectOpponentPlayReactions likely shares the gap. When testing a "when you play a unit" reaction, use a GROUND-unit fixture (a space fixture silently no-ops → the reaction looks broken). Root cause is in the space-play path of ActivateCard/CollectEntryTriggers (the $isPlay own/opponent reaction block at ~GameLogic 6735 isn't reached for space entries) — an owed engine fix, not a card bug.
  • Test-writing: the attacker-choose in an "attack with a unit" leader/event action AUTO-RESOLVES with exactly 1 ready unit (SWUQueueChooseTarget → PASSPARAMETER). Do NOT answer it — only answer the attack TARGET's MZCHOOSE. Over-answering feeds the (superfluous) attacker answer to the target choose → the attack mis-targets and the assertion is confusing (attacker takes the wrong counter). Seed ≥2 ready units only if you actually want to exercise the attacker pick.
  • SWU_PLAYED_VILLAINY and the other play-flags at ActivateCard ~L10030 are in the UNIT branch — EVENTS never reach them. A per-phase "you played an event this phase" flag (TWI_014 Asajj) must be set in OnPlayEvent (fires for every event), not the unit-play block. (Clear it at RegroupPhaseStart.) Same trap for any "played a [type] card this phase" flag whose type includes events.
  • Chained-attack SWU_CHAINED_ATTACK already supports a bare-TRAIT constraint. "0,0,0,{uid},Droid" (5th CSV field) restricts the chained attacker to Droid units (the resolver's final elseif does !HasTrait($o->CardID, $constraint)). So "attack with a Droid, then another Droid" (TWI_082) is ~15 lines — no new constraint code. (space/ground/costlt:/costle: are the other constraint forms.)
  • Nested play-from-HAND during an event resolution (TWI_225): TWO gotchas. (1) The chosen myHand-N mzID can go STALE by the time the #0 continuation runs (the event's FINISH_PLAY_CARD block-10 discard shifts the hand) — resolve the chosen object's CardID first, then re-find its CURRENT hand index. (2) The ActivateCard($p,$mz,false,$discount) discount floors at 0 but the off-aspect penalty still applies first (cost = SWUComputePlayCost − discount), so a played unit's cost can be higher than its printed cost minus the discount — budget printed + aspect_penalty − discount in the test (a Vigilance/Heroism unit under a Cunning leader is +4). Wrap the nested ActivateCard in the $gTurnPlayer/PASS save-restore.
  • GetBase($player) returns an ARRAY — use $base[0]->Damage, never $base->Damage (a $base->Damage read is null → a "15+ base damage" gate silently never fires).
  • A self-re-queuing budget/capture loop (Cad Bane TWI_187) re-offers with the ORIGINAL zone indices — the board does NOT compact mid-loop (DoCaptureUnit/SWUDefeatUnit mark removed but the next MZMAYCHOOSE's offered set already excludes them). So the re-offer lists theirGroundArena-1&2 (not -0&1) after capturing index 0 — a test answers the CURRENT offered index, not a recompacted one. (Same family as the resource multi-defeat re-scan.)
  • "Random unit or base" can't be deterministically collapsed (both bases are always in the pool, ≥2 targets) — implement with array_rand, AddGameLogEntry a spaceless tag (TWI202_HIT), assert LOGCONTAINS:TWI202_HIT, and smoke-verify a real target took the damage via TestSchemaStep+GetNextTurn.
  • Leaders — both sides, fast recipe. Front Action: = $leaderAbilities["CID"] (bare CardID; SWULeaderAction exhausts the leader BEFORE the closure — the closure only PAYS resources + does the effect + closes). Resource cost → $leaderActionResourceCosts["CID"]=N; per-card conditions ("if a friendly unit was defeated this phase", Coordinate, "needs a damaged enemy") → a case 'CID': in SWULeaderActionAffordable (a leader with NO front Action — pure passive + Epic, e.g. Nala Se TWI_001 — needs NO $leaderAbilities entry; the affordability guard's if (!isset($leaderAbilities[$cardID])) return false; suppresses the phantom glow). Epic deploy is 100% generic (threshold = leader's printed cost — no code). Deployed side = the normal registries under CID:0 ($onAttackAbilities/$whenPlayedAbilities for On Attack / When Deployed) + deployed passives as field-presence checks in ObjectCurrentPower/HP (_SWULeaderDeployed($ctrl,'CID')) or the HasConditionalKeyword_* fns; deployed-side keywords (Sentinel/Restore/Overwhelm) are auto-derived by the generator from deployTextData — no wiring. A deployed "each OTHER friendly unit gains X" is one line in HasConditionalKeyword_X gated on _SWULeaderDeployed. Test a front side with myLeader:CID:1 + UseLeaderAbility; test a deployed side with myLeader:CID + DeployLeader (Epic needs ≥ printed-cost resources) then attack, OR myLeader:CID:1:1 to seed it pre-deployed.
  • SWUDefeatUnit($player, $mzID) is 2 args (player FIRST) — a 1-arg SWUDefeatUnit($mz) fatals the whole action; grep an existing call before writing one.
  • Grant tokens 'SENTINEL'/'SABOTEUR'/'RAID'(+value)/'OVERWHELM' are ready-made registry rowsAddTurnEffect($mz, 'SENTINEL') grants Sentinel this phase; SWUMakeTurnEffect('RAID',[1],SWU_DUR_PHASE) grants Raid 1. No new registry row needed for a plain "give a unit [keyword] this phase" card.

TWI Phase 23-27 lessons (subsystem/complex cards; folded at the end-of-run retro):

  • SWUTakeControlOfUnit($newController, $mz) returns the new mzID in the NEW CONTROLLER's frame — it computes GetMzID() while $playerID = $newController, so the returned string (e.g. "myGroundArena-N") is relative to $newController, NOT your handler's $playerID. The single-controller "take control" idiom (LOF_189: $new = SWUTakeControlOfUnit($player,…); AddTurnEffect($new,'TEMPORARY_STEAL')) works because $newController === $player. But when you take control FOR someone else (a 2P control SWAP — TWI_204 has the OPPONENT take a unit), the returned mzID is in the opponent's frame: set $playerID = $opp before AddTurnEffect($new,…)/any use, else the marker lands on a slot in the caster's arena (wrong/absent unit). Reuse TEMPORARY_STEAL for "control until regroup" — RegroupPhaseStart auto-returns each such unit to its Owner (no revert code). Symptom that caught it: only one of the two swapped units reverted at regroup.
  • "Becomes a copy of X" (TWI_116 Clone) = CardID swap + an IsClone-style flag + leave-play revert. CR §2/§9.2: a card's printed attributes INCLUDE its text box / abilities — a "copy" shares name/cost/aspects/traits/arena/power/HP and abilities/keywords. So set the entering object's CardID to the copied card and ALL attribute/ability/keyword/arena/entry-trigger reads resolve for free (place via the copied CardID, run CollectEntryTriggers/SWUApplyPassiveEntryGrants with it so the copied When Played + entry keywords fire). Two must-dos: (1) mark it with a durable flag for the bits the dictionary can't give — the granted trait (TraitContains special-case), non-unique (SWUEnforceUniqueness skip); (2) revert CardID to the real card on EVERY leave-play or you corrupt deck/discard ownership — the choke is SWUAddToDiscard(..., $sourceObject) (reverts when $sourceObject->IsClone), but confirm the defeat path PASSES the object: SWUDefeatUnit did not originally (fixed to pass $obj); also patch capture (DoCaptureUnit) + bounce (SWUBounceUnit). The intercept for the play-time "may become a copy" choice goes in SWUBeginPlayCard (mirror the Piloting Unit/Pilot choice), stashing the chosen CardID in a global that ActivateCard consumes.
  • A durable per-object bit needs a REAL schema field — dynamic props aren't serialized. $obj->foo = true (an undeclared property) survives in-process but is DROPPED by the per-action gamestate round-trip in real games (Serialize() only writes declared fields). To add one: append Foo:boolean=false to the zone's line in Schemas/SWUSim/GameSchema.txt (append at END → backward-compatible via the count($arr) > N parse guard) AND hand-patch SWUSim/ZoneClasses.php for BOTH GroundArena and SpaceArena (property decl + parse at the new index + serialize) to match what the schema-driven generator would emit. Guard it with an UndoCycle test — that SaveVersion→LoadVersion round-trip is the only in-process test that exercises serialization (a non-persistent field passes every other test but breaks live).
  • LostAbilities($obj) is the central "this object has no abilities" cascade (~26 gates): combat On Attack/On Defense/combat-hit/attack-end triggers, keyword suppression (SWUKeywordSuppressed calls it), unit-action affordability, reactive-observer collectors, defeat-replacement. To make a whole CLASS lose all abilities (TWI_255 Brain Invaders: "each leader loses all abilities except epic actions"), extend LostAbilities with the discriminator (IsLeaderUnit($obj) && _SWUFieldPresence()) — put the CHEAP discriminator FIRST so the hot non-leader path short-circuits before any board scan. That covers the deployed side; the front (undeployed) leader Action is separate — gate SWULeaderActionAffordable (returning false there also stops the direct SWULeaderAction dispatch, which re-checks affordability). Epic deploy is gated by NEITHER (separate path) — so "except epic actions" is free. Gaps to note: "can't gain abilities" and initiative-/defeat-triggered FRONT reactive abilities aren't centrally gated; deployed-leader field-presence passives that buff OTHER units (keyed on _SWULeaderDeployed, not the leader's own abilities) aren't suppressed.
  • Field-wide GRANTED activated Action (TWI_047 Satine: "each unit incl. enemy gains Action […]") rides SWUGetUnitActionProvider's fallback: after the own-CardID/upgrade checks, if (_SWUGranterInPlay()) return 'GRANTER_CID', then register $unitAbilities['GRANTER_CID'] + $unitActionCostKind['GRANTER_CID']='exhaust'. The provider is object-based, so each unit's own controller activates it on their turn — enemy units work for their controller with NO $anyPlayerUnitActions (that registry is only for "ANY player uses THIS one unit's action", LAW_156). Affordability is auto-true (no SWUUnitActionAffordable case needed) unless the effect has a precondition. A unit with its OWN action keeps it (single-provider model surfaces one — the grant is shadowed for that unit; document it).
  • A novel COMBAT variant (Maul 2-defender) — write a dedicated pragmatic routine, don't refactor the 580-line SWUCombatDamage. Reuse the primitives: _SWUShieldOrReduceCombat (shield/reduction), _SWUConsumeAmidalaPrevent/_SWUConsumeAsh062Prevent/_SWUDamageUnpreventable (the prevention chain), the defeat block (discard/leader-return/SWU_*_DEFEATED flags/_SWUUnitDefeatReplacement), then SWUExpireTurnEffects(SWU_DUR_ATTACK) + CollectCombatStep3Triggers (batched When Defeated) + CleanupRemovedCards + SWUAfterAction (guarded by !_SWUInTriggerResumeMode()). Snapshot all powers up front so simultaneity holds (a defeated defender still deals its counter). Skip exotic per-card modifiers the card can't have and document the gap.
  • Official rulings can POST-DATE the set's CR (Maul/Clone are TWI; the bundled CR is v7.0). When the CR can't settle a timing/interaction, surface the specific ruling question — the user supplied the official rulings mid-implementation (Maul: base+unit forbidden, Sentinel forces ALL defenders, Overwhelm combined-excess; Clone: capture→rescue re-choose). Don't guess a novel interaction as if the CR covers it.

TS26 Phase 1-15 lessons (autonomous set, all 78 cards were mechanical mirrors of existing seams; folded at the autonomous→pair-programmed retro):

  • ⚠ This branch (main / OTMTCGE) has NO Twin Suns N-player helpers. OpponentsOf($p) / SWUChooseOpponent / NextLiveSeat etc. are UNDEFINED here (they live only on the twin-suns branch) — calling one is a fatal that kills the action. Use OtherPlayer($p) for the single 2-player opponent. "each opponent" / "an opponent" / "each player" all collapse to $player + OtherPlayer($player). (Cost a fatal on TS26_19 Coleman Trebor.)
  • ⚠ When a nested play silently no-ops, suspect OFF-ASPECT COST first, not a code bug. SWUPlayTopDeckCard / DISCOUNT_PLAY_FROM_HAND|N / SWUPlayDiscardUnitDiscounted / a nested ActivateCard all no-op when the effective cost exceeds ready resources — and a Villainy/off-side unit under a non-matching base/leader is +2 per unmatched pip (SEC_080 played "for 2" under a Villainy leader but costs 4 under a Cunning/Vigilance one). An UNDEPLOYED leader contributes NO aspects; a DEPLOYED leader unit DOES. When a "play the top card / play from hand" test shows an empty arena, bump myResources generously (or match aspects) before touching the handler. (Cost 2 debug cycles: Dooku's Palace, Ahsoka front.)
  • "Entered play this phase (incl. tokens and leaders)" = the new SWU_ENTERED_PHASE_{uid} flag (TS26_02 Anakin / TS26_04 Padmé). Set it at CollectEntryTriggers (the universal PLAYED-unit funnel — the deploy path calls it too, so deployed leaders are covered) AND at _SWUCreateOneToken (tokens do NOT go through CollectEntryTriggers). Cleared at RGS. Distinct from SWU_PLAYED_UNIT_{uid} (hand-plays only; Luke SOR_005 semantics — don't extend that one to tokens/leaders). Read: GlobalEffectCount($ctrl, 'SWU_ENTERED_PHASE_'.$uid) > 0.
  • Two-sided / multi-window leaders: a shared handler needs a close-flag param so the FRONT Action closes via SWUAfterAction while the DEPLOYED On-Attack / When-Attack-Ends lets combat own it. Pattern: "CARD#0|{arg}|{close}", and in the handler if ($close === 1) SWUAfterAction(...). Used by Maul TS26_03 (front+WhenDeployed+OnAttack) and Rex TS26_06 (front+OnAttack). Front-side leader passives ("while you control this undeployed leader, …") gate on GetLeader($ctrl) CardID + empty($l->Deployed); deployed-side on _SWULeaderDeployed; the two are mutually exclusive so both clauses can live in the same HasConditionalKeyword_*.
  • "Next EVENT/unit you play this phase costs N less" is a count-based flag SWU_<CARD>_DISCOUNT_NEXT: arm N copies, subtract GlobalEffectCount(...) in SWUComputePlayCost (gated on CardType Event/Unit), consume in ActivateCard (RemoveGlobalEffect for one-shot, or SWUClearGlobalEffectsByPrefix to consume all), clear at RGS. TWI_121 (unit) and TS26_35/TS26_06 (event) are the templates.
  • An undeployed-leader "when you play an event" reaction hooks OnPlayEvent directly (NOT SWUCollectOwnPlayReactions, which scans deployed units only). Guard on _SWULeaderReadyUndeployed($p, 'CARD'); queue the reactive YESNO at the TOP of OnPlayEvent before the switch. Exhaust the undeployed leader by setting $l->Ready = false in the GetLeader loop. (TS26_08 Ahsoka front.)
  • CHAINED_ATTACK now takes a noBases flag ("CHAINED_ATTACK|{bonus}|{noBases}") for "attack with another unit that can't attack bases" (TS26_04 Padmé). SWUQueueAnotherAttack filters READY units only + allows bases — roll a custom entered-units may-choose → CHAINED_ATTACK|0|1 when you need include-exhausted / noBases / an entered-this-phase filter.
  • Reminder-text keyword arithmetic: Grit is +1/+0 (power only) in this engine, NOT +1/+1 — a Grit unit's remaining HP is unchanged by damage, so it can still be killed normally (don't assume the Grit paradox). Raid N is a while-attacking power bonus (not a heal — that's Restore N). Read the parenthetical reminder in $textData; don't infer from the keyword name. (Cost the Dooku's Solar Sailer test — confused Raid 2 for Restore 2.)
  • Count-distinct-keywords helper (Darksaber TS26_22 / Maul TS26_03): build a set over the bool keywords via HasKeyword_{Kw}($obj) (Sentinel/Ambush/Overwhelm/Grit/Saboteur/Shielded/Hidden/Bounty) PLUS the VALUE keywords via GetKeyword_{Kw}_Value($obj) > 0 (Raid/Restore) — value keywords have no HasKeyword_ boolean. Count Experience tokens by scanning Subcards for SOR_T01.
  • The overwhelming majority of a set's cards are mechanical mirrors of an existing seam — before building anything, grep for the twin: opp-may-ready-resource=SEC_215, granted-WhenDefeated-give-Exp=SHD_104, look-top-play/discard/leave=SOR_192, deployed-each-other-Overwhelm=TWI_009, next-X-discount=TWI_121, may-pay-N-then-effect=TWI_212, play-a-hand-unit-then-act-on-it=SHD_013, count-capped-attack-loop=SHD_145, upgrade-granted On-Attack=$onAttackAbilities[UPGRADE:0] via the OnAttackFromUpgrade scan, granted-WhenDefeated=the CollectWhenDefeatedTriggers subcard scan + a DispatchTrigger case. The real work is finding the existing twin, not inventing plumbing.

TS26 Phase 16-18 lessons (pair-programmed, cross-player reactions & control transfer):

  • ⚠ A cross-player reaction queued as a raw AddDecision(nonActivePlayer, "CUSTOM", …) mid-combat SITS PENDING and never drains — the non-active player's queue isn't processed during the attacker's action (cost the Moralo TS26_73 "when your base is dealt combat damage: may deal 1" debug — the CUSTOM sat on P1's queue while P2 attacked). Route the base-owner reaction through the combat trigger bag: AddTrigger($baseOwner, 'CARD', 'CARD', '') in CollectCombatStep1Triggers (fire on the base-attack window — strpos($defenderMzID,'Base')!==false) + a DispatchTrigger case that builds the may-choose and sets SetSWUVar('SWU_PENDING_DEF_REACTION','1') (identical to Barriss TS26_78's On-Defense pause). It drains cross-player and the reactor answers via P2>AnswerDecision. Firing at base-attack rather than strictly post-damage is a benign timing simplification. Do NOT reach for the raw AddDecision path for a non-active reactor.
  • Mutual / cross-player discard where the OPPONENT also makes a choice (Reveal Intentions TS26_80 "each player discards from the hand of the player to their right"): build each decider's target list with $playerID set to that decider first (so ZoneSearch("theirHand") is relative to them), and use ONE handler keyed off $player (the decider) for both sides — the hand owner is always OtherPlayer($player). The opponent's decision drains inside the same resolution and is answered as P2>AnswerDecision:theirHand-N (cf. SEC_147 for own-hand discards). A trailing "then each draws" CUSTOM queued LAST drains after the cross-player decision (verified: both draws land).
  • ⚠ A relative-mzID universal handler must set $playerID = intval($player) (the decider) before resolving $lastDecisionAPPLY_PHASE_BUFF/DEBUFF/BOUNCE already do; DEAL_UNIT_DAMAGE did NOT (fixed), so a non-active decider's theirGroundArena-N target resolved under the wrong frame and hit the wrong unit (cost the C-3P0 TS26_15 ping debug). When a non-active player deals unit damage / buffs / bounces via a queued choice, confirm the handler re-sets the frame; if you write a new relative-mzID universal handler, set $playerID at the top.
  • A non-leader unit's activated Action usable by a NON-OWNER (C-3P0 TS26_15 "only opponents may use"): register $unitAbilities["CARD"] (default 'exhaust' cost kind — SWUUnitAction pays the exhaust; don't re-exhaust in the closure) and gate the restriction in SWUUnitActionAffordable (owner-block: intval($player) === intval($actor->Owner ?? $player) → false), which covers BOTH the clickable-action list and the activation. Permanent control transfer = SWUTakeControlOfUnit(OtherPlayer($player), $mzID) in the $whenPlayedAbilities["CARD:0"] handler (it strips TEMPORARY_STEAL → permanent; preserves Owner so the gate still identifies the original owner).
  • ⚠ Played units enter EXHAUSTED in this engine (not ready) — you CANNOT test a just-played unit's Action [Exhaust] the same round; advance a round so the ready phase readies it: …play… , P2>Pass, P1>Pass, P1>ResourcePass, P2>ResourcePass, <now the unit is ready>. Also: the unit-spec DSL is cid:ready:damage:turnEffects with no controller override, and WithGroundUnitForPlayer places a controlled unit in the OWNER's arena (not the controller's), so it does NOT model a transferred unit — drive a real play+transfer when you need owner≠controller.
  • Twin Suns multiplayer-politics cards degrade to a 2P reading on this branch — "an opponent takes control" (choose WHICH) / "only opponents may use" (any of several) assume 3-4 players; with no N-player helpers here, implement the single-opponent degenerate reading and FLAG it inline for the user rather than halting (C-3P0 TS26_15).

UI smoke test. The regression suite runs in a single PHP process and is blind to the transport layer: persistence, wire pieces, window.*Data assignment, glow flags. For any card whose behavior surfaces in the UI (logs, reveals, glows, decision menus), verify the real request roundtrip headlessly:

# Build a real game from a test schema (DEVENV bypasses auth in the container)
curl -s -X POST "http://localhost:3400/TCGEngine/SWUSim/TestSchemaSetup.php" \
  --data-urlencode "schema@SWUSim/Tests/Cases/sor/MyTest.md"
# → {"gameName":N,"whenSteps":[…]}; then fetch the rendered state ("<~>"-separated pieces)
curl -s "http://localhost:3400/TCGEngine/SWUSim/GetNextTurn.php?gameName=N&playerID=1&lastUpdate=0"

TestSchemaSetup.php applies only the GIVEN — it builds initial state and parses (does not execute) the WHEN steps, returning them in whenSteps. To actually drive the scenario — and crucially to reach and answer interactive decisions (popups, MZCHOOSE, OPTIONCHOOSE, glows) that the bare GetNextTurn snapshot can't surface — feed WHEN lines one at a time through TestSchemaStep.php, fetching GetNextTurn between steps:

# Execute one WHEN line through the REAL engine (ProcessInput path); repeat per step.
curl -s -X POST "http://localhost:3400/TCGEngine/SWUSim/TestSchemaStep.php" \
  --data-urlencode "gameName=N" --data-urlencode "step=- P1>PlayHand:0"
# → {"success":true,"autoResolved":..,"pending":[{"player":1,"type":"OPTIONCHOOSE","param":"…","tooltip":"…"}]}
curl -s -X POST "http://localhost:3400/TCGEngine/SWUSim/TestSchemaStep.php" \
  --data-urlencode "gameName=N" --data-urlencode "step=- P1>AnswerDecision:OK"

The pending array in the TestSchemaStep response is the authoritative view of the on-the-wire decision (type + param + tooltip) — e.g. it's what proved a snapshot popup param still carried a card that had already been discarded. This is the only smoke path that exercises decision menus end-to-end. This exercises state → WriteGamestateParseGamestateGetNextTurn — the path the suite never touches. For hidden-information abilities (private deck peeks, opponent-hidden reveals), fetch GetNextTurn as both playerID=1 and playerID=2 and confirm private entries only appear for the entitled seat. LOGCONTAINS cannot catch leaks — it reads the unfiltered in-process log; only the per-viewer GetNextTurn payload proves the filtering.


Step 4 — Update Set Implementation Tracker

After all cards in the batch pass and the regression is green, check for a per-set tracking file.

For each unique set in the batch (derive from card ID — e.g. SOR_189 → set SOR):

# Check if the tracker exists (lowercase set name)
ls SWUSim/docs/{lowercase_set}-implement.md 2>/dev/null

If the file does not exist: nothing to do.

If the file exists: append each implemented card ID to the ### Already Done comma-separated list. The list is on the line immediately after the ### Already Done heading.

# Example: add SOR_189 to the Already Done list in SWUSim/docs/sor-implement.md
# Read the current Already Done line, append the new IDs, write it back using the Edit tool.

Use the Read tool to fetch the current file content, locate the ### Already Done line, then use the Edit tool to append the new card ID(s) to the end of that comma-separated line. Do not reformat or reorder existing IDs.

If a card is in the batch but already appears in Already Done, skip it.

Skills similaires