name: swusim-implement-card description: Use when implementing SWU card abilities — looks up card text, writes all DSL tests first, then self peer-reviews against the CR + game logic + card data and implements; only stops for the user on a card it can't verify to 98% alone. Supports single cards and batches.
SWUSim Implement Card
Overview
TDD workflow for card ability implementation. Works for a single card or a batch of cards.
Three phases. After all tests are written, do a self peer-review rather than a blanket stop-and-wait.
Gate after Step 2 — self peer-review, then proceed (policy updated 2026-06-22): There is no tier-based hard stop. After writing tests and RED-checking, review your own work for flow correctness against three sources before implementing — then proceed straight through implementation:
- The CR (
.claude/SWUSim/refs/comprehensive-rules.md) — confirm the timing window, interaction order, and any rules-keyword semantics (e.g. "When this unit is attacked" = the On Defense window, CR 15.c). - Game logic — trace the actual code path the card will take (the combat-pause, the disclose flow, the trigger collection point) and confirm the tests drive the real execution path, not just a fixture stand-in.
- Card data — every stat/cost/aspect/trait in the tests derived from the dictionary arrays (not memory or a prose doc), for every fixture including the incidental ones.
If that self-review leaves you at ≥98% confidence the tests are correct and the implementation is clear, just implement it (show the tests + design in the eventual batch summary so the user can course-correct after).
Only STOP and ask when a card is too complex to verify confidently on your own — i.e. the self-review can't get you to 98%:
- an ambiguous ruling / interaction the dictionary + CR don't settle;
- new shared infrastructure with a real design choice the user alone should make (a mechanical mirror of an existing seam is NOT a real choice — verify and proceed);
- a scope/realism decision (which scenarios matter); or you simply can't reach confident correctness.
Flag that specific card/fork — don't gate the whole batch. Rationale: the mechanical test-writing (correct DSL, RED-by-construction, vetted fixtures/stats) is reliable, and a disciplined CR + game-logic + card-data self-review catches the flow errors a human review would; the residual human value is genuine design/ruling intent, which is rare and surfaced as a targeted question, not a blanket gate.
Confidence bar — 98% minimum on every new card (policy set 2026-06-15; 94% 2026-06-22; raised to 98% 2026-08-02)
No card is "Done" until you'd honestly rate correctness ≥ 98%. This is a per-card bar, not a batch average — one shaky card at 80% is not offset by four solid ones. Before marking a card Done, ask yourself: "If the user manually playtested this right now, what's the chance they find a wrong number or a broken interaction?" If that chance is over ~2%, the card is not done.
Why the bar is this high. A later independent validation pass over an already-"finished" set routinely finds a dozen behaviors nobody tested — and every one of those is a bug that shipped, or a bug that could ship on the next refactor with nothing to catch it. The target is that such a pass finds ZERO new behaviors. So the standard is not "my tests pass"; it is "an independent test-writer, working only from the printed card text, could not think of a scenario I haven't written." Write the tests you would want to find if you were auditing someone else's work.
The coverage matrix — derive it from the CARD TEXT, mechanically
Decompose the printed text into clauses first (see the clause-decomposition gate below), then walk both lists. Every cell is either a section or a written, specific reason it is N/A ("no cost, so no payment axis") — never a silent omission.
Per CLAUSE (do this for each clause independently, then once for all clauses firing together):
- Positive — the clause does its thing.
- Negative — prove the gate is load-bearing. Every
if/while/ "you control a X" / "that costs N or less" / "while defending" needs its FALSE case asserting the clause does NOT fire. This is the single most commonly missing test, and it is missing even when the code is right (Gold Leader JTL_054's aura was correct but neither "when IT attacks" nor "when another friendly unit defends" was tested). - Optional branch — take AND decline (
AnswerDecision:-/PASS). A decline that silently does the wrong thing is a classic latent bug. - No valid target — must no-op cleanly (no crash, no dangling decision) — and decide explicitly whether the sibling clauses still resolve. "Defeat X and do Y" is NOT gated by Y being possible; only an explicit "If you do," gates. Getting this backwards fizzles the whole card (Lightspeed Assault).
- Quantity discrimination — pick a value that separates the intended formula from a plausible wrong one ("distinct aspects" vs "card count" needs a same-aspect case that heals 1, not a 2-distinct case that heals 2), and include the zero case.
- Boundary — exactly-N vs N±1 for any threshold ("2 or less", "prevent all but 4", "6 or more power").
Per CARD (cross-cutting — these are where the deep bugs live):
7. Dispatch-path matrix — every way the ability can be REACHED is a different code path. Played from hand · played as an upgrade via Piloting · created as a token · put into play / played for free by another card · relocated or moved · leader FRONT side vs DEPLOYED side. Cross this with the trigger halves: a card with When Played and On Attack needs the condition tested on both halves (Fett's Firespray read "control Boba" correctly on When Played but On Attack was untested; Iden Versio's attach trigger fired on play but not on relocation).
8. Value-CLASS variants, not just different numbers. A cost-0 token upgrade is a different case from a cheap real upgrade; a token unit from a real unit; a leader unit from a normal unit; a deployed leader from an undeployed one. If the text says "an upgrade that costs 2 or less", a Shield/Experience token IS a legal target — test it.
9. Persistence across state transitions. Whatever the card writes must survive every transition it can experience: an arena move, a control change (owner ≠ controller), a host change, leaving and re-entering play, and the request boundary (see the transient-globals shape below). Assert the effect still applies after the transition, not just before.
10. Duration edges. A "for this phase" restriction must expire — test the next phase, where the restricted thing now works. A "once per round/game" must not re-fire on the second attempt. A delayed "at the start of the regroup phase" must still find its target after the unit has moved arenas.
11. Interaction with the standard modifiers. Shields absorbing damage (does the rider still fire?), "can't be defeated/damaged/captured by enemy card abilities", indirect/unpreventable damage, prevention caps — and for ANY cost, that Credit tokens / SEC_122 Droids can pay it (gate offers on SWUTotalPaymentCapacity, never a bare ready-resource count).
12. Scope exclusions — what the effect must NOT touch. An effect naming zones or sets must leave the adjacent ones alone: "search their deck and hand" must not hit units in play or a same-named deployed leader; "another" excludes self; "friendly" excludes enemy (and an unqualified "a unit" includes enemies); "a base" with no qualifier means EITHER base.
Before marking Done — the adversarial audit (mandatory)
Re-read the printed text as if you had never seen the implementation, list every scenario an independent auditor would write from that text alone, and diff that list against your sections. Anything unmatched becomes a section or a stated N/A. Two failure modes this catches, both seen repeatedly:
- A test NAME that doesn't match what it asserts — audit by reading assertions, never titles.
- "The code obviously does this" — that reasoning is exactly what leaves a correct behavior untested until a refactor breaks it silently.
- The REAL execution path, not just a fixture stand-in. A deployed-leader ability dropped into the arena via
WithP*GroundArenatests the handler but NOT the deploy→attack dispatch; add one test that actuallyDeployLeaders and acts. (And noteCommonSetup's leader codes map to a fixed leader per aspect-combo —bwis Luke SOR_005, not every Vigilance+Heroism leader; either override it with themyLeader:CARDIDopt or use explicitP1LeaderBase: <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._parseBaseSpecacceptsBASEID:damageto 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.mdclass. Mapping: On Attack →$onAttackAbilities["CID:0"]; When Deployed →$whenPlayedAbilities["CID:0"](NOTleaderAbilities[CID]— that's the front Action); attack-end/completes →$onAttackEndAbilities["CID:0"]; deployedAction [...]:→$unitAbilities[CID]+$unitActionCostKind/$unitActionResourceCosts(SWUUnitActiondoes not fall back toleaderAbilities); passive →ObjectCurrentPower/ObjectCurrentHPfield-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*GroundArenaplacement tests the handler closure but NOT the deploy→dispatch wiring — see the REAL-execution-path axis above. - Force-action exhaust nuance: a deployed
Action [use the Force](no[Exhaust]) must NOT exhaust the leader unit and must stay usable while exhausted — wire it with a non-exhaust costKind + a Force-token payment, never the default'exhaust'. (The front side of the same leader is often[Exhaust, use the Force]; the deployed side drops the exhaust.)
Step 0 — Triage: is there anything to implement at all?
"Implement a card" ≠ "write code for a card." Two whole classes of card resolve to verification only — no tests, no code, just confirm the generator already handled them and mark them Done. Run this triage on every card before any research, and drop the no-ops out of the batch up front (it prevents an unnecessary research + test cycle):
-
Vanilla (blank text box). If
$textDatahas no entry / empty text, the card is fully implemented by the dictionaries (a vanilla upgrade's +power/+HP flows through the existingObjectCurrentPower/ObjectCurrentHPupgrade loop). No tests ever. Mark Done. -
Keyword-only text, keyword(s) already implemented. If
$textDatais nothing but keyword(s) + their reminder text — one OR more keywords, e.g. justGrit (…), orAmbush (…) Overwhelm (…), orSaboteur (…) Raid 2 (…)— confirm all three and then mark Done — write nothing:$textDatais 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/(orsor/). 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 inGeneratedKeywordCode.phpare$Foo_Cards = [ … ];(match_Cards = \[…\];). Don't reuse the dictionary pattern on the registries. If$VAR-in-awkever 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 bycards/_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.phpwas deleted (itsOnPlayEventevent-play logic was inlined intoActivateCardinGameLogic.php). Shared helpersSWUOfferUnitTarget/SWUOfferBaseTarget/SWUOfferDiscard/GiveTokenUpgradelive inCardHelpers.php; the object-aware trait check isTraitContains($obj,$trait)(_SWUUnitHasTraitwas deleted — don't re-add it). Because file names areTitleSubtitle(not derivable from the CardID), resolve a card by grepping its registration key (grep -rln "'<CID>'" SWUSim/Custom/cards/) or viacards/_index.generated.php(regen withphp SWUSim/DevTools/regen-card-index.phpif stale). Always grep/scan recursively (SWUSim/Custom/or…/**/*.php), never a bareCustom/*.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.phpentry 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/OnPlayEventhandler 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. SoHasWhenPlayedAbility(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-playerMZSPLITASSIGN: useWithActivePlayer: 1(notP1OnlyActions) and answer as the damaged playerP2>AnswerDecision:myBase-0:N(assigner's own frame, comma-sepmz: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/HasWhenPlayedAbilityabsent → the trigger silently no-ops even with a handler registered). Confirm the stub exists; if not, hand-add thecase 'CARD':to the rightHas…Abilityswitch inGeneratedAbilityStubs.phpand 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 onSWU_SNEAK_DEFEAT) or via theSWUQueueFalconRegroupTriggerspattern; tests reach the ready step withP1>Pass,P1>ResourcePass,P2>ResourcePassand need 6-card decks.
JTL Phase 22-24 gotchas (folded at the end-of-run retro):
- ⚠ Inside the ATTACKER's
OnAttack, a mandatory multi-targetMZCHOOSE(SWUQueueChooseTarget) auto-resolves to nothing and presents NO decision.OnAttackTriggerrestores$playerIDto its pre-trigger value beforeMZCountChoicesruns, so the count comes back 0 and the choice is silently skipped (the answer never lands — your WHEN'sAnswerDecisionthen mis-feeds the next decision). UseSWUQueueMayChooseTarget(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-targetSWUQueueChooseTargetis fine because it emitsPASSPARAMETER(auto, no answer). Symptom: a unit-target effect that works fine as aWhenPlayedsilently no-ops as anOnAttack, and aP1HASDECISIONprobe 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 (becauseOnAttackTriggerrestores$playerIDright after the closure returns, beforeMZCountChoices). A relative-mzIDMZCHOOSEqueued from a later CONTINUATION (aCUSTOMhandler reached mid-combat — e.g. step 2 of a multi-pick flow) is SAFE:ExecuteStaticMethodsdoes NOT restore$playerIDaround 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 aMZMAYCHOOSEin the closure (fine), and the destinationMZCHOOSEis queued from theMOVE_UPGRADEcontinuation (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'), noSWUSwapTurnPlayer) so the same player acts again — vsSWUAfterActionwhich 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 (
BeginSWUAttackfrom a DQ handler) withnoBases=falsequeue an unanswered targetMZCHOOSEwhenever both an enemy unit AND the base are legal targets (2+ targets → no auto-resolve). The test must supplyAnswerDecision: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
#0CUSTOM continuation must callSWUAfterActionitself 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 → callSWUAfterActiononly on the decline branch, mirror JTL_017#0) andDISCOUNT_PLAY_FROM_HAND(owns it via ActivateCard/decline — queue it with NO trailingSWU_AFTER_ACTION). To reuse a universal handler (DEAL_UNIT_DAMAGE etc.) in an action, append a trailingCUSTOM "SWU_AFTER_ACTION"decision (LOF_134/LOF_178 pattern). For a nested play-from-hand inside an action (LOF_016/LOF_018), wrapActivateCardwith$gTurnPlayer/PASSsave-restore then callSWUAfterActiononce (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 beforeActivateCardto 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 inSWULeaderActionAffordable) so it's unavailable without the Force token; the closure then callsUseTheForce(). Resource-cost leaders go in$leaderActionResourceCosts.UseTheForcenow bumps a per-phaseSWU_FORCE_USED_THIS_PHASEcounter (LOF_007 Epic deploy reads it); per-phase attack/play flags follow theSWU_ATTACKED_<TRAIT>/SWU_PLAYED_<X>family (add one inBeginSWUAttack/ActivateCard+ clear atRegroupPhaseStart). - 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
⚠ DEFERREDone-liner naming the missing seam; don't burn a long session forcing them. - DSL/fixture notes:
discardCardIds/deckCardIds/WithP1Force:trueare CommonSetup opts;WithP1Deck:lines are top-first (assertP1DECKTOPCARD:); leader tests use explicitP1LeaderBase: LEADER/BASE+SkipPreGame: trueand assertP1LEADER: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");MZMULTICHOOSEparam ismin|max|list(2|2|…= exactly two, answermz0&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" hooksOnWhenPlayed(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 withTheForceIsWithYou(NOTUseTheForce); LOF_197RemoveGlobalEffects 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/YESNOwith 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+ theTEMPORARY_STEALturn-effect marker (SOR_224) — RegroupPhaseStart already returns those to their owner; do NOT reuse JTL_235'sSWU_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) = theNAMECARDdecision 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/OnAttackTriggerrestore$playerID, the CUSTOM path doesn't); encode the caster in the handler token, set$playerIDto whichever player owns the next step. The test answers as that player (P2>AnswerDecision:NOworks even underP1OnlyActions).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.CollectWhenPlayedAsUpgradeTriggersroutes aHasWhenPlayedAbilityupgrade through theWhenPlayedwindow with mzID = the host unit, so register$whenPlayedAbilities["UP:0"]and read the host viaGetZoneObject($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_Valueonly contributes the conditional part; the generatedGetKeyword_Raid_Value=base_max + conditional. To make the final 2×, have the conditional function add(base_max + amount)— recomputingbase_maxwith 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
$turnEffectRegistryas['kind'=>'LOSE_ABILITIES'], add it to theLostAbilities()check inKeywordEffects.php, andAddTurnEffect($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::QueuePregameSetupvia shared helpers (SWUStartingHandModifier/SWUBaseSuppressesMulligan); the harness bypasses pregame (Option B) so extend its_buildInitialStatedrew-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 sidegains: "On Attack:...") was wrongly excluded bystrpos($combined,'"On Attack:')===false. The generator now usespreg_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 thecaseto 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). UseMZMAYCHOOSEfor multi-target picks (the OnAttack mandatory-MZCHOOSE skip), and combat owns the after-action (noSWUAfterAction). Test byP1>DeployLeader(free — threshold only, resources persist) thenP1>AttackGroundArena, withWithInitiativePlayer:2+WithInitiativeClaimed:trueso P1 acts freely. ⚠ A deployed "defender gets −X/−0" (LOF_014) must addSWU_DEF_DEBUFF_Nsynchronously inExecuteSWUAttack(like SOR_212), NOT via the deferred OnAttack trigger —SWUCombatDamagereads/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 —SWUConsumeShieldTokenhas no>0guard), 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
TPFmodifier:SWUAddToDiscard($p, $cid, 'DECK'|'PLAY', 'TPF').TPF= "this-phase free play-from-discard" (cleared bySWUClearDiscardModifiersat the phase turn;TPP= play-at-cost;OTPF/OTPP= from an opponent's discard). The player then uses the existingPlayFromDiscardaction (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_topDeckPutRemainingToBottomfor 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'sSWU_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 inObjectCurrentPower(stat) and the relevantHasConditionalKeyword_X(keyword). - ⚠ A unit's When-Played that offers HAND cards must
DecisionQueueController::CleanupRemovedCards()BEFORE building themyHand-Nlist. 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 offeredmyHand-Nindex shifts and the chosen mz resolves to NULL at handler time (symptom: the effect silently no-ops; probe showsup=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 untilFINISH_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 inZoneSearch("myHand")and would wrongly offer it. Exclude one instance of the playing$cardIDfrom the targets (foreach $hand … if (!$excluded && CardID===$cardID) {$excluded=true; continue;}). (Contrast LOF_150: a unit's When-Played hand list needsCleanupRemovedCardsfirst; 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-baseMZCHOOSE. ADEAL_BASE_DAMAGEchoice 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 asP1OnlyActions+ the IBH unitAttackGroundArena:0:…into a bigger body so it dies to the counter; P1 is active and the WhenDefeated resolves inline (IBH_015 Tauntaun, IBH_082 Ozzel). Pair with the existing skill #9 note (WhenDefeated collects after cleanup → survivors reindex). - To force a base attack with enemy units on the board, the WHEN target token is
BASE—AttackGroundArena:0:BASE— NOT:theirBase-0(which, with enemy units present, is ignored and the unit attacks an enemy instead). (Reconfirmed across IBH OnAttack tests; already in the GIVEN/DSL notes.) ⚠ Likewise the UNIT target is a bare INDEX, not a full mzID:AttackGroundArena:0:1attacks the defender at idx 1. Writing:theirGroundArena-1intval()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-casesBASE/S<n>(cross-arena space) /G<n>(cross-arena ground); everything else isintval'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-Nsuffix 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 ownwhenPlayedAsUpgrade. To honor a host restriction anyway, filter the offer within_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 beforeBeginSWUAttack, then inSWUCombatDamagejust after$attackPower = ObjectCurrentPower(...)+ Raid, override$attackPowerwhenin_array('…', $attacker->TurnEffects). Marker auto-expires at attack-end viaSWUExpireTurnEffects(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 ownsSWUAfterAction). - Per-instance trait suppression — "each enemy unit loses the <Trait> trait this phase" (LOF_033 Nameless Terror On Attack):
HasTraitis CardID-keyed (static dictionary) so it CANNOT do per-instance. Use the existing object-awareTraitContains($obj, $trait)(in GameLogic, next to HasTrait — it already returnsfalsewhenin_array('NO_TRAIT_'.strtoupper($trait), $obj->TurnEffects), and otherwise honors upgrade grants /HasTrait($obj->CardID, $trait);_SWUUnitHasTraitwas the old name for this and is deleted — don't re-add it). RegisterNO_TRAIT_FORCEas a phase-duration MARKER (['kind'=>'MARKER','label'=>…]— phase is the registry default). The handler snapshots the affected units in play now (ZoneSearch('theirGroundArena'/'theirSpaceArena')) andAddTurnEffect($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 safelyreplace_allevery object readHasTrait($obj->CardID…, 'Force')→TraitContains($obj, 'Force')across files (~25 sites). ⚠ NEVER route a bare-CardID read (HasTrait($cardID/$c/$cid, …)) — the helper wouldGetZoneObject(a CardID string)→ null → wrong; those are hand/deck/play-time reads that correctly stayHasTrait(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 isActivateCard($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, expectDAMAGE:0after replay). ⚠ Test gotcha: a double-pip OFF-aspect card (LOF_185 = Cunning,Villainy) costs +2 per off-aspect pip — if the test'smyResourcescan'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) inGetField. 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 (
HasOnDefenseAbilitystub →$onDefenseAbilities["X:0"], dispatched under the defender's controller). BUT historically the defender's reaction decision (a non-active-player YESNO) raced and lost:ExecuteStaticMethodsdrains ONE player's queue fully, so the active player's block-20SWU_TRIGGER_RESUMEcommittedSWUCombatDamagebefore the defender's block-1 YESNO was ever processed. The fix (in GameLogic, generic for the whole cluster): (1)OnDefenseTriggersetsSWU_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) theSWU_TRIGGER_RESUMEempty-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 queuesSWUCombatDamage|aMz|tMz|uid|{activePlayer}onto the CURRENT drain's$player(not always the active player) and the handler re-derives the attacker frame fromparts[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 thecasetoHasOnDefenseAbilityif 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'sDamageis incremented — this is the ONE central base-damage point (combat line ~890, Overwhelm overflow ~1041, andOnDamageBasefor 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 viaOnHealBase($p, $p, 2). Test: attack the base (P1>AttackGroundArena:0:BASE), thenP2>AnswerDecision:YES— assert netP2BASEDMG= dealt − healed. - "When you draw THIS card during the action phase: …" (LOF_148). Hook in
DoDrawCardafter 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$drawnfor the CardID. Gate onGetCurrentPhase() === '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 teststrpos(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 viaSWUDealDamageToUnit($lastDecision, 2, $p)thenSWUQueueChooseTarget($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 viaWithP1Deck: 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/WithP2Deckuse BRACKET-space syntax, NOT comma:WithP1Deck: [SOR_095 SOR_046](space-separated inside[ ]). A comma listSOR_095,SOR_046is 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 underP1OnlyActions— 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 SECONDmyTempZone-Npick even when the host has a single matching upgrade — it does NOT auto-defeat. Tests must answer BOTH the host pick ANDmyTempZone-0(ASH_165 cost a cycle). Onlymin:1auto-defeats a lone upgrade (one answer). Friendly-scoped defeat ("defeat a FRIENDLY upgrade" — ASH_171/ASH_246):SWUQueueDefeatUpgrade/SWUGetUnitsWithUpgradesspan 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 theDefeatUpgThencontinuation fires after the defeat (gets the host mzID; pass extra state via a separate DQ variable, e.g. ASH_171 storesASH171SelfUIDto ready the just-played unit). - An UPGRADE's "When Played" fires via the
CollectWhenPlayedAsUpgradeTriggersFALLBACK (when the card has onlyHasWhenPlayedAbility, noWhenPlayedAsUpgrade): 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 anMZMULTICHOOSE, 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 addLASTLOGCONTAINS: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
costDatabefore 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 bySWUGetUnitActionProvider) and set$unitActionCostKind["X"] = 'none'— no exhaust, no ready requirement (right when the unit is meant to act while exhausted). The handler pays withUseTheForce($p). (2) Availability/zone-gating goes inSWUUnitActionAffordable(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 consumedSWU_CANT_READYflag): block BOTH theReadyPhaseSPACE loop (regroup, setsStatusdirectly) ANDOnReadyCard(explicit "Ready a unit" effects) with aCardID === 'X' && in-spacecheck. 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 —
$targetis 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 withstrpos((string)$targetMzID, 'Base') === falseas well (cost the SHD_007 Moff Gideon "+1 while attacking a unit" a wrong base-attack buff). The existing SHD_138$shd138VsBountyonly 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
useLeaderAbilitywraps the call inob_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 anerror_log("...")in the offer/handler and running withphp -d display_errors=stderr … 2>&1 | grep PROBE. - Epic deploy is fully GENERIC — no per-leader wiring.
SWUDeployLeadergates onSWUResourceCount($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"]; deployedAction:→$unitAbilities["CID"]+$unitActionCostKind["CID"](+$leaderActionResourceCostsfor the FRONT resource cost); deployed passive →ObjectCurrentPower/HP(leader-presence buffs mirror SOR_001: scanGetLeader($controller)— works undeployed AND deployed since the leader entry persists) or a keyword registry (deployed Restore/Overwhelm/Saboteur/Grit auto-fire from the generated*_Cardslists). Test a leader withmyLeader:CARDID+SkipPreGame:true; front =UseLeaderAbility; deployed =DeployLeaderthenAttackGroundArena:<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
$gPlayGrantTurnEffectfindable-marker pattern (SEC_018). Setglobal $gPlayGrantTurnEffect; $gPlayGrantTurnEffect = 'MARKER';thenActivateCard($player, $handMz, false, $discount)(save/restore$gTurnPlayer+ thePASSSWUVar around it), null the marker, then scan all arenas for the unit whoseTurnEffectscontains'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.
SWUCollectOwnPlayReactionsscans 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)
UPGRADECOUNTcounts shield tokens (SOR_T02 are Subcards) — after granting a shield,UPGRADECOUNT= upgrades + shields; assertSHIELDCOUNTfor the shield and don't expectUPGRADECOUNT:1. (2) A top-deck search "choose none" is an EMPTYAnswerDecision:(blank), NOTPASS— answeringTOPDECKSEARCHwithPASSdrains 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:Nare 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 usesSWURampResourceReady.
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 earlyreturnwas 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_SWUCountActiveUnitsWithCardIDand 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 BEFORESWUDiscardHostSubcards),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_SWUShdXXXCheckObservefield observer). The base-attack observer is inline at thestrpos($targetMzID,'Base')point in ExecuteSWUAttack (next to ASH_160). Control exchange = LAW_170'sSWUTakeControlOfUnit(twice); modal "an opponent chooses one" = LAW_080 (OPTIONCHOOSE queued forOtherPlayer, branch in the#0CUSTOM). - ⚠ 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 viaFlushEntryTriggerBag. The recipe (SHD_172 Krayt):SWUCollectOpponentPlayReactionsAddTrigger($opp, …)→DispatchTrigger→ a reaction fn that queues an intermediate CUSTOM (CardID#0) → that continuation (whichExecuteStaticMethodsdoes NOT $playerID-restore) builds the target list under$playerID=reactor+ queues the MZMAYCHOOSE. ⚠ Test-drive note: a cross-player single reaction needs an extraAnswerDecision:EffectStack-0step (theRESOLVE_NEXT_TRIGGERorchestration) 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 acost~countpayload and LOOP the effect once per copy (CR-equivalent for identical triggers), instead of N identicalAddTriggers (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):
_SWUOnUnitDamagedgained abool $isCombatparam (combat call sites passtrue, the effect-damage site staysfalse) 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 BOTHDoDiscardCard(self-chosen discards, viaMZMove) ANDSWUAddToDiscardwhen$from==='HAND'(forced discards —SWUDiscardCards/DISCARD_FROM_OWN_HANDbypassDoDiscardCard); 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') <= 0gate then reflects actual use. (Clear everySWU_SHDxxx_USEDat 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 —SWUFindMzByUIDreturns a mzID string; don't passGetZoneObject(...)). Opponent-play-reaction tests need the TWI_210 turn setup:WithActivePlayer:1+WithInitiativePlayer:1, thenP1>PassBEFOREP2>PlayHand:0, thenP1>AnswerDecision:…; and remember the played card's aspect penalty for the OPPONENT (an off-aspect card needs printed cost + penalty intheirResources, or it silently fails to play and the reaction never fires).
Use the dictionary's $nameData name verbatim in comments and test filenames — the CardID is the source of truth, the printed name comes from $nameData, never from memory or an inherited comment. A wrong name propagates fast and silently: SOR_139 is Force Choke, but had been labeled the non-existent "Swift Strike" across its comments, three code files, two test filenames, a doc, and memory. Registry/handler keys are CardID-keyed so a misnomer is only cosmetic — but it misleads every future reader, so get the name right once, here.
The dictionary is authoritative for stats over any prose doc (e.g. sor-implement.md). A doc once listed Protector (SOR_057) as +0/+2 while upgradePowerData/upgradeHpData said +1/+1 — trusting the doc produced two wrong test expectations. SOR_049 Obi-Wan is 4/6 in the dictionary but "3/6" in the prose doc — seeding a POWER expectation from the doc reddened two tests. Always derive expected combat/heal numbers from the array lookups — for EVERY unit in the test, including the ability's own subject/chosen unit, not just the targets. It's easy to rigorously look up the targets while eyeballing the "incidental" fixture (the unit being buffed, the leader, the attacker); that incidental unit is exactly where a doc-vs-dictionary stat drift slips a wrong expected number through. (Reminder, see 3c-stats: combat lethality uses ObjectCurrentHP, so an upgrade's/buff's +HP DOES keep a unit alive in combat; and a Sentinel unit force-redirects an attack onto itself rather than rejecting it as a no-op.)
The dictionary uses PHP $name = array ( ... ); syntax with a leading-space indent — so the anchored /^\$costData /,/^\];/ form matches nothing and returns empty. Match = array \( … ^\); as above. If a section ever comes back empty, first confirm the array headers with grep -nE "= array \(" … and slice by line range (awk "NR>=START && NR<=END").
This is critical — power and HP are easy to swap when reading combined grep output. Always use the array-specific lookup. When extracting the bare number, strip the SET_NNN digits first (e.g. grep -oE '=> [0-9]+') — a naive grep -oE '[0-9]+' also captures the card number ('SOR_049' => 4 → 049 and 4).
Leaders are double-sided — read BOTH ability arrays. A Leader's textData holds only the leader-side text (the action ability + the "Epic Action: deploy" line). The deployed Leader Unit's abilities (On Attack, etc.) live in a separate array, deployTextData. Implementing only the textData side silently misses half the card (this bit SOR_017 Han Solo, whose ramp-from-deck On Attack is deploy-side only). The leader's unit-side power/HP share the normal powerData/hpData arrays.
awk '/\$deployTextData = array \(/,/^\);/' SWUSim/GeneratedCode/GeneratedCardDictionaries.php | grep "'CARD_ID'"
Collect for each card: name, ability text ($textData, plus $deployTextData for leaders), type, cost, power, HP, arena, aspects, traits, unique flag.
Before deciding a card "needs new infrastructure" (or splitting it out as harder-than-Simple), verify the engine doesn't already support the mechanism. Twice, cards were over-tiered for missing infra the engine already had: bases ARE valid MZCHOOSE targets via myBase-0/theirBase-0 (MZZoneCount→GetZone accepts any zone name — units, bases, resources, hand, deck), and DQ variables persist across the request boundary (StoreVariable writes the DecisionQueueVariables gamestate zone). Grep the actual primitive (MZZoneCount, GetZone, GetKeyword_*_Value which reads granted keyword values via SWUTurnEffectKeywordValue, SWUApplyPhaseBuff/Debuff, the $turnEffectRegistry CardID-token convention) before assuming it's absent.
Then for each card identify:
- Trigger type: Epic Action / WhenPlayed / OnAttack / WhenDefeated / passive / etc.
- Effect: what it does (damage, shield, draw, exhaust, discard, etc.)
- Target restriction: "non-leader unit", "friendly", "enemy base", "Vehicle", etc.
- Whether it requires player input (choice = MZCHOOSE; automatic = no choice)
- Dependencies: does this card's test rely on another card in the batch being implemented first?
Step 2 — Survey DSL Capabilities & Write All Tests (RED)
Before writing any tests, check what commands and assertions already exist:
grep -n "case '" SWUSim/Tests/Framework/SchemaTestRunner.php | grep -v "//"
grep -n "P1LEADER\|P1BASE\|P[12]GROUND\|SHIELDCOUNT\|EPICUSED" SWUSim/Tests/Framework/SchemaTestRunner.php
Identify all new DSL commands and assertions the batch will need. List them explicitly — these are what make the tests RED by design.
⚠ Clause decomposition — ONE test per clause/branch (do this FIRST)
Before writing any test, decompose the card text into every INDEPENDENT clause and conditional branch, and enumerate them explicitly. Then write at least one test per enumerated clause. A card is not "done" until every clause it prints has a passing test that OBSERVES it. This is the #1 source of shipped bugs: a multi-clause card gets some clauses implemented + tested and one silently absent — invisible to a happy-path test that only exercises the present clause (real examples: LOF_073 Mythosaur — protection clause wired, "friendly leaders gain Mandalorian" clause missing; LOF_261 Constructed Lightsaber — Villainy→Raid 2 and Heroism→Restore 2 wired, the third branch "neutral host → Sentinel" missing).
What counts as a separate clause/branch to enumerate:
- Each sentence / each trigger — When Played, When Defeated, On Attack, Action, Epic Action, and every standalone passive sentence is its own clause. A card with "When Played: X. When Defeated: Y." needs a test for X and Y (they're often the same handler wired to two triggers — verify BOTH fire).
- Each
If … / If …branch — a card with "If attached unit is Heroism, gains Restore 2. If Villainy, gains Raid 2. If neither, gains Sentinel." is THREE branches → three tests (one host per branch). - Each keyword in a granted list — "gains Ambush, Grit, Sentinel, …" — treat as one test that exercises the distinct ones (especially numeric grants like Raid N / Restore N vs boolean keywords).
- Each half of a multi-part passive — "Friendly upgraded units can't be exhausted OR returned to hand … AND friendly leaders gain Mandalorian" = protection (2 verbs) + trait grant = separate tests.
The cross-function grant trap (why branches go missing): conditional keyword/trait grants live in DIFFERENT engine functions per keyword — Raid in HasKeyword_Raid, Restore in the Restore fn, Sentinel in HasConditionalKeyword_Sentinel, a granted trait in TraitContains (the object-aware trait check; _SWUUnitHasTrait was deleted). A multi-branch card is therefore N edits in N different files; it's easy to land N-1 and miss the last. After implementing (Step 3e verify), grep the CardID and confirm the number of grant hits equals the number of grant branches — grep -rn '<CardID>' SWUSim/Custom/ (recursive, so it descends cards/<set>/); 2 hits for a 3-branch card is the tell.
Observability — a GRANT clause is only testable through a CONSUMER that reads it via the object-based path (TraitContains($obj,$trait) for traits, HasKeyword_X / the HASKEYWORD assertion for keywords — NOT bare-CardID HasTrait). For a trait grant, find a card whose behavior depends on the granted trait (e.g. SHD_073 Mandalorian Armor gives a Shield "if attached unit is a Mandalorian" — attach it to a leader that Mythosaur made Mandalorian). Pick a probe host that exercises the SPECIFIC branch, and avoid a host that already has the keyword/trait PRINTED (SOR_049 Obi-Wan has Sentinel printed → a Sentinel test on it false-passes regardless of the grant).
Write all test files before implementing anything.
Recurring bug shapes beyond missing clauses (JTL validate-port, 2026-07-23) — decompose the TARGET SET and the OPTIONALITY, not just the clauses
A clause can be present and still wrong. Four shapes cost real bugs this port — enumerate each explicitly when decomposing:
- "another X unit" with NO "friendly" qualifier → ANY unit, friendly OR enemy. SWU targeting defaults to "any" unless the text says "friendly"/"enemy". Restricting to friendly is a bug (JTL_088 Phasma "+2/+2 to another First Order unit" targets an ENEMY too; JTL_120 Dorsal Turret "Attach to a Vehicle unit" attaches to an enemy Vehicle; JTL_129 Focus Fire; JTL_078 Direct Hit "non-leader Vehicle"). Always add a test that the enemy side IS (or a leader/other-arena is NOT) selectable — build the target list from all four arenas, then filter ONLY by the printed restriction.
- "Heal/deal UP TO N" and "You may" → support doing LESS and DECLINING. A mandatory single-target choose (
SWUQueueChooseTarget, which AUTO-RESOLVES a lone target) is wrong for "you may" / "up to" effects — useSWUQueueMayChooseTargetso 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
_SWUUnitHardCantAttackin 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 anOpponentHandEmpty_NoPromptsection (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 aCannotPay_AutoResolvesection (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, butZoneSearch("myHand")STILL returns the removed entry — filterempty($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 aSelfDiscard_InFlightNotSelectablesection. - A deferred When-Defeated target-choose that reads
$selfvia the positional mzID is STALE. By dispatch time the defeated unit is cleaned up and a survivor shifts into its slot, soGetZoneObject($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 aWhenDefeatedByCombat_<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.
SWUApplyPhaseBuffemits an identicalSWUBUFF-X-Ystring thatAddTurnEffectDE-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$stackIdxcounts existing^<TAG>_tokens on the unit. Add aBuffStackssection (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
caselist inSWUGetUpgradeValidTargets(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.
SWUExhaustResourcesis 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=trueflag (SEC_235 The Wrong Ride). Add aFewerThanNReady_ExhaustsAvailablesection.
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
#1handler + a_SWULeaderDeployed(...)hook beside the_SWULeaderReadyUndeployed(...)one — mirror ASH_005 Luke), and add aDeployed_<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
SWUCollectCombatHitTriggersbelow theif ($attacker===null||removed) return;attacker-survival gate, so it's skipped on a trade. Fix: (a) capture the marker into$combatCtxat combat START (beforeSWUExpireTurnEffects(SWU_DUR_ATTACK)), and (b) place theAddTriggerabove that survival gate (like the Boba LAW_007/252 hooks). Add a<effect>_FiresWhenAttackerTradessection (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=falseparam + 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 (SWUReturnUpgradeToHandmust 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$hostin 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_SecondPaysFullsections. - 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 downstreamintval($damager)(SOR_175 flag) — normalizeis_object($damager) ? $damager->Controller : $damagerat 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>_LostWhenBecomesLeaderUnitsection (attach ASH_135 / a leader-pilot). - Support LENDS the supported unit's When-Attack-Ends abilities too, not just On-Attack. The
SUPPORT_GRANTmarker is stripped bySWUExpireTurnEffects(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 aSupport_LendsAttackEnd<effect>section. - A deck-search with NO valid pick must RETURN the peeked cards, not mill them. The shared
_topDeckSearchBeginauto-skips a no-target search, which skipped the finalize → the array_spliced cards were lost (ElzarMann ASH_224). The finalize now hasdontSkipOnPass. Add aNoMatch_CardsReturnToDecksection (assert deck count unchanged). - A base-damage cap / "capped to N" and a "prevent all but N" both need
> N, not>= N, and aDAMAGE:N-exactly control (a defender/base at exactly the threshold takes full). Cheap to get wrong; add anExactly<N>Unaffectedsection.
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:DoCaptureUnitand the base-capture_SWUBaseCaptureUnit(SEC_195) discarded the captured unit's upgrades viaSWUAddToDiscardWITHOUT 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_161OR-clause covers Zeb's OWN upgrades as he's captured. When ANY new leave-play/upgrade-defeat primitive is added, grep that it calls_SWUOnUpgradeDefeatedfor 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.
SWUTakeControlOfUnitcopied subcards verbatim without updatingController, 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: setController = $newControlleron 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 testLukeSkywalker_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_GATEinSWUDealDamageToBase) 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 printedCardType 'Leader'. A unit made a leader unit by The Darksaber ASH_135 or a deployed Pilot leader has printed type "Unit". Pellaeon ASH_093'sSWU_LEADER_DEFEATED_PHASEflag 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 inSWUDeployLeader— the default branch gates on flatSWUResourceCount < 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
$leftCardsbatch (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'sRAMPART_SAVEhad the identical shape. Fix by riding the payload on the CUSTOM decision's ownParam(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 beforeAddDecisionand read in the handler behind it, it MUST be serialized. ⚠ The suite CANNOT see this — it runs one process. Guard every such flow with aSimulateRequestBoundarysection, and when you add a new transient continuation global, add it tosimulateRequestBoundary()inGameTestAdapter.phpor 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$onAttachedAbilitiesnever ran (JTL_036 Iden didn't re-shield her new host) becauseCollectOnAttachedTriggershad exactly ONE call site —_SWUFinalizeUpgradeAttach— despite its own doc-comment claiming it fires "for any source"; and (b)_SWUFireHostPilotAttachReactionsonlyAddTriggers 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_SWUCreateOneTokentoken-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.phpjoins all fields into one space-delimited constructor line and only special-casedType == "json".TurnEffectsisarray[string], so handing it a real array produced the literal string"Array"(+ anArray to string conversionwarning) 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 (anarraybranch emitting the sameimplode('~')the zone class parses with) then regenerate — never hand-editZoneAccessors.php. AnArray to string conversionwarning 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 sharedSWUTotalPaymentCapacity($player)(ready resources + defeatable Credits + SEC_122 Droids) for the offer, and route the payment throughSWUOfferAltPaymentso the player picks the mix. ⚠ ~65 card sites and ~38 shared sites still callSWUResourceCount; 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 fromSWUAfterAction, 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 viaProcessGoldfishAutomation()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>_FirstClauseStillResolvessection. - 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 atGameLogic.php:15;$playCostModifiers = []is initialized around line 2331, so a$playCostModifiers["X"] = …written incards/<set>/X.phpis 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 inzzCardCodeGenerator.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 rememberGeneratedAbilityStubs.phpis 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 aremovedflag, 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.) P1OnlyActionshands P2 the CLAIMED initiative, so after a regroup P2 is the turn player and P1 cannot act until aP2>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 inSWULeaderActionAffordable, the leader must not exhaust without a sacrifice, and there is no decline) butOn 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}Deckin every section whose card can draw. - ⚠ FIXTURE TRAP: CommonSetup's
myResources:Nand an explicitWithP{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
1where the correct answer is2), 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.mdfor 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 becomeMoffGideon_IndomitableWarlord.md(ASH_008) andMoffGideon_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'sSWU_*backend flags aren't set this way.WithP1Resources: NorWithP1Resources: N:CARD_ID:statusorN:CARD_ID:status,M:CARD_ID:status— status 1=ready, 0=exhaustedWithP1Hand: 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-lists —WithP1Deck: A,B,Ccreates 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 beSOR_014:1:1:1(ready:deployed:epicUsed bits). ⚠ The:1:1:1deployed 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 (SWUControlsLeaderUnitreads the flag), but a test where the deployed unit must attack (AttackGroundArena:idx) or be counted byGetUnitsInPlay(a deployed-passive like IG-88's Raid grant) must deploy viaDeployLeaderin 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 explicitP1LeaderBase/P2LeaderBaseunless 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 aliaseshandCardIds/theirHandCardIdsstill 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 forP1LeaderBase. Optional inline params:ready1/0 ·deployed1=deploy as a REAL linked ground-arena leader unit, deployMode='unit' ·epicUsed1 ·damageon that deployed UNIT ·indexOverride(6th) = ground-arena index to insert the deployed unit at, shifting the otherWithP{n}GroundArenaunits up (plain deploy appends it LAST) — somyLeader: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 separateWithP1GroundArenaline.damage/indexOverrideonly take effect whendeployed=1. BaremyLeader:CARDIDis unchanged; leader READY is this 2nd inline field, not a standalone opt. ⚠WithP{n}GroundArenaUpgrade: N:CARDindices 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 atindexOverride0, useWithP{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 standaloneLeaderReady/LeaderIndexOverrideopts — a deployed leader is a real,DeployedUniqueID-linked unit placed via the inlinemyLeader:…:deployed:…:indexform (ormyLeaderDeployed/myLeaderDeployedPilot). The opts block may be written multiline (onekey: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 code —nn/nnn("no leader") is rejected withunknown leader code 'nn'; for an auto-passing opponent just use any real code (e.g.rrk). ImpliesSkipPreGame— so CommonSetup is incompatible with the pregame draw-6 simulation (SkipPreGame: false+ResourceHandpregame actions that derive hand/resource/deck counts); the 3 such tests (jtl/Colossus_DrawsOneFewer,jtl/NabatVillage_DrawsThreeMore,win_con/SimpleFullGame) stay on explicitP1LeaderBase. 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 NOmyResourcesopt 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 aPlayHand, set{myResources:N}≥ the card's cost (Sidon JTL_213 cost real debug —TestSchemaStepshowed the card still in hand andpending:[]).P1OnlyActions: true— P1 gets back-to-back actions; P2 auto-passes after each P1 action. Expands toWithInitiativePlayer: 2+WithInitiativeClaimed: true+WithActivePlayer: 1. Use this when the test only needs P1 to act and P2 should never interject. Do NOT useWithInitiativePlayer: 2+WithInitiativeClaimed: truewithout also settingWithActivePlayer: 1— otherwise P1's firstPlayHandis 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). SetWithActivePlayer: 1and leave initiative unclaimed (omitWithInitiativeClaimed), 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 exactP1>… / P2>…sequence yourself (each action swaps the turn player), and end with consecutive passes to reach regroup. Verify the alternation throughTestSchemaStepbefore trusting the WHEN block — a mis-ordered turn just silently no-ops.
WHEN commands cheat sheet:
UseLeaderAbility/UseBaseAbility/DeployLeaderPlayHand:idx/AttackGroundArena:idx:target/AttackSpaceArena:idx:targetPass/Claim/ResourceHand:idx/ResourcePassSmuggleResource:idxAnswerDecision:value— feeds$lastDecisionto 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, orAnswerDecision:-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) — theDecisionQueueconstructor doesexplode(" "), so a multi-wordParamis 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.
FlushEntryTriggerBagfirst queues a "choose trigger to resolve"MZCHOOSE— answer it withAnswerDecision:EffectStack-N(the entries are added WhenPlayed-first, soEffectStack-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 withAmbush + WhenPlayedplayed into an empty enemy board has only ONE entry trigger and goes STRAIGHT to the WhenPlayed decision (no ordering MZCHOOSE). Verify the real sequence viaTestSchemaStepbefore adding anEffectStack-Nanswer — feedingEffectStack-0to 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:NP1GROUNDARENACOUNT:N/P1SPACEARENACOUNT:NP1GROUNDARENAUNIT:idx:CARDID:X/:SHIELDCOUNT:N/:DAMAGE:N/:POWER:N/:HP:N/:READY/:EXHAUSTED/:UPGRADECOUNT:N/:UPGRADE:n:CARDID:X/:HASKEYWORD:Kw/:NOTKEYWORD:KwUPGRADECOUNTcounts ALL subcards — Shield (SOR_T02) and Experience (SOR_T01) tokens too, not just "real" upgrades. A unit with a Lightsaber + a Shield isUPGRADECOUNT:2. To assert upgrades on a unit that also has tokens, useSHIELDCOUNT(filtersSOR_T02) and/or a specificUPGRADE:idx:CARDID.HASKEYWORD/NOTKEYWORDdispatch to the realHasKeyword_<Kw>($obj)(incl. suppression/grant/conditional layers).
P1SPACEARENAUNIT:idx:...— same fieldsP1LEADER:READY/:EXHAUSTED/:DEPLOYED/:NOTDEPLOYED/:EPICUSED/:EPICAVAILABLEP1BASE:EPICUSED/P1BASE:EPICAVAILABLEP1RESCOUNT:N/P1RESAVAILABLE:N/P1HANDCOUNT:N/P1DISCARDCOUNT:NP1DISCARDUNIT:idx:CARDID:X/:MODIFIER:X/:FROM:XP1DECKCOUNT:N/P1DECKTOPCARD:CARD_IDP1NODECISION/P1HASDECISIONPHASE:X/PHASEISNOT:X/INITIATIVECOUNTER:XEFFECTSTACKCOUNT:N/EFFECTSTACKHAS:TriggerTypeLOGCONTAINS:text/LASTLOGCONTAINS:text— substring match against game log entry textP1WIN/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:
-
Stats from the dictionary, not memory. Derive every expected
P1BASEDMG/P2BASEDMGfrom the array-specific awk lookups above. Never eyeball power values — they are easy to swap. -
Units that die must not be in
HasWhenDefeatedAbility. Even if the card's handler is not implemented,FlushTriggerBagwill queue aRESOLVE_TRIGGERDQ entry, makingAllQueuesEmpty()return false. That silently blocksPlayHandfor both players via ActionMap. CheckGeneratedAbilityStubs.php'sHasWhenDefeatedAbilityswitch 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_189Leia is also safe to kill, but she carries aWhenPlayed, so only drop her in viaWithP1GroundArena, neverPlayHand.) -
"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. UseSWUQueueMayChooseTarget(...)(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 thetooltip:, NOT the param —AddDecision($p, "YESNO", "-", $block, tooltip:"Attack_with_X?"). The client rendersdecision.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?",…)) leavesTooltipempty → the player sees the useless generic text. Build a unit-specific prompt from the CardID withCardTitle($obj->CardID)+str_replace(' ','_',$title)(e.g."Attack_with_" . str_replace(' ','_',CardTitle($cid)) . "?"→ "Attack with Obi-Wan Kenobi?"). Verify viaTestSchemaStep— thependingentry shows the on-the-wiretooltip. (Regression is blind to this — it never renders the popup.) - "Either X or Y" (mandatory branch between two effects) →
OPTIONCHOOSEwith 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.
- "You may [deal/give/exhaust/defeat/return/heal a unit]" (optional single-target) →
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).
-
Leader deploy is free. Leaders deploy as their epic action — no resource cost. Never add
SWUExhaustResourcesto the deploy path.P1RESAVAILABLEafter a solo deploy should equal the pre-deploy resource count. -
Any test where a unit leaves play must assert arena COUNTs for both players, not just indexed fields. Per-index assertions (
P1GROUNDARENAUNIT:0:CARDIDetc.) structurally cannot detect a phantom extra entry sitting at a higher index — a real bug once survived every indexed assertion and was only caught byP1GROUNDARENACOUNT:1. Count assertions are one line each; add them whenever a defeat, sacrifice, or bounce is involved. -
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(orEPICAVAILABLE), resources unchanged,P1NODECISION— the player keeps their action. Reference:GrandAdmiralThrawn_LeaderAction_Unaffordable.md,Palpatine_Deploy_BelowThreshold_NoOp.md. -
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,PlayHandis a silent no-op (arena count stays 0, no error message). PreferCommonSetupwith a base/leader whose aspect codes match the card (e.g. a Cunning+Heroism card →ywHan Solo leader), so the cost stays printed. Only reach for explicitP1LeaderBasewhen the test genuinely needs specific leaders; if you do use a mismatched leader, budget the +2-per-off-aspect intoWithP1Resources. ⚠ 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 intoWithP1Resources. Symptom of underpaying:PlayHandsilently no-ops and your modal/effect never starts (the first decision never appears). -
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. -
A
WhenDefeatedtrigger 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 ismyGroundArena-0, not-1). For attacker-sideOnAttacktriggers the attacker is still present, so no shift there. -
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_randover a single index is fixed. -
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 itcouples 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, thenP2>AttackSpaceArena:0:0. Assert the leave-play result (host gone, leaderNOTDEPLOYED, counts). Reference:LeaderPilot_Asajj_HostDefeatedInCombat_ReturnsToZone.md. ⚠ Fixture subcards are NOT pilots by default:WithP*ArenaUpgradebuilds subcards viaGameStateBuilder::Upgrade, which hard-setsIsPilot => false. So any leave-play / capacity logic gated onIsPilotwon'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.) -
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:
P2RESAVAILABLEshows 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':toSchemaTestRunner.phpswitch +public function commandName()toGameTestAdapter.php - New EXPECT assertion → add
elseif (preg_match(...))block toSchemaTestRunner.phpassertion section + expose the property on the relevant Accessor inGameTestAdapter.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 fileSWUSim/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 inGameLogic.php'sObjectCurrentPower/HP, combat hooks inCombatLogic.php, keyword-grant switches inKeywordEffects.php, cost math inSWUComputePlayCost, newDo*/SWUOffer*helpers, and theAddTrigger/DispatchTriggerreactive 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 inCardHelpers.php; useTraitContains($obj,$trait)for object-aware trait checks (_SWUUnitHasTraitis 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_088→JTL_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 units — SWUQueueDefeatUpgrade(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 (SWUFindMzByUID → SWUDefeatUnit) 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<=1 → MZCHOOSE (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>1 → MZMULTICHOOSE (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.php — ReadyPhase, 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 CollectCombatStep3Triggers → CollectAfterAttackTriggers → SWUCollectCombatHitTriggers, 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 extraParams → DispatchTrigger'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) | MZCountChoices→MZZoneCount 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 flag — ActivateCard 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.php — SWUComputePlayCost 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 CardID — AddGlobalEffects($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 → autoPASSPARAMETER; 2+ →MZCHOOSE; thenCUSTOM $handler. No-ops on[](so drop theif (!empty(...))guard).$handlermay carry args ("DEAL_UNIT_DAMAGE|4"). Pass$blockto match surrounding decisions (older handlers sometimes use block0).SWUQueueMayChooseTarget(int $player, array $targets, string $yesTooltip, string $chooseTooltip, string $handler, int $block = 1)— "You may" choose. Emits ONEMZMAYCHOOSE(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.$yesTooltipis retained for call-site readability;$chooseTooltipis the popup prompt. (Earlier versions queued aYESNO+ aMAY_CHOOSEfollow-up — that indirection is gone; it's a singleMZMAYCHOOSEnow.)- The
$handlerMUST no-op on a'-'decline (if (!$lastDecision || $lastDecision === '-' || $lastDecision === 'PASS') return;) —MZMAYCHOOSEruns 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 callSWUQueueMayChooseTargetwhen 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 viaSWUQueueDefeatUpgrade+ TempZone staging; see the "Pick among a unit's upgrades / any subcard" row).
- The
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 unitDEFEAT_UNIT— defeat the chosen unitEXHAUST_UNIT/READY_UNIT— exhaust/ready the chosen unitREADY_RESOURCE— ready the chosen resourceBOUNCE_UNIT— return the chosen unit to handGIVE_SHIELD— give a Shield token to the chosen unitGIVE_EXPERIENCE|N— give N Experience tokens to the chosen unitHEAL_TARGET|N— heal N from the chosen unit or base (handlesmyBase-0/theirBase-0mzIDs)DEAL_BASE_DAMAGE|N— deal N to the chosen base (myBase-0/theirBase-0); no-ops on a-declineDRAW_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$turnEffectRegistryrow ('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 unregisteredsrcfalls 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 / SWUDealSplitDamage → SWUQueueDamageAnim; OnHealUnit / OnHealBase → SWUQueueHealAnim; 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].@durationisattack|phase|perm; omitted ⇒ phase (the default).-paramscarries 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_154is 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, orTEMPORARY_STEAL/NO_ABILITIES). - Registry:
$turnEffectRegistryinGameLogic.php, keyed by base token →['kind'=>…, 'value'=>…, 'amount'=>…, 'duration'=>…, 'label'=>…]. Only setdurationwhen 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 generatedHasKeyword_*/GetKeyword_*_Valuecall these — if you touch them, patch the generatorData/ProcessKeywordsSWU.phptoo. - Producers:
SWUApplyPhaseBuff/Debuff($mz,$p,$h,$source)emitCARDID#p_h; theGRANT_PHASE_KEYWORD|CARDIDhandler tags a CardID grant. - Expiry is central:
SWUExpireTurnEffects('phase')(inRegroupPhaseStart) dropsattack+phasetokens;SWUExpireTurnEffects('attack')(at the end ofSWUCombatDamage, after damage resolves) dropsattacktokens when the attack ends;permis 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 byCardDisplayEffects) 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, notCardCurrentEffects/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(aSTAT_BUFFviaSWUApplyPhaseBuff(...,'SOR_217'), or a bare marker likeSHOOT_FIRST) —SWUExpireTurnEffects('attack')at the end ofSWUCombatDamagedrops it when the attack resolves, and the phase sweep is the fizzle-safety net. Still legacy (NOT in this system): the inline one-shotsSWUAddAttackPowerBonus(SWU_ATK_POWER_*) andSWU_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 aphasegrant: play it → assert the effect is present →P2>Pass/P1>Passto end the action phase (regroup runsSWUExpireTurnEffects) → assert it's gone (NOTKEYWORD:Sentinel,NOTKEYWORD:Raid, or stat back to base). Forperm, 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 tokenCARDID-power-hp) and add a$turnEffectRegistryrow (['kind'=>'STAT_BUFF'|'STAT_DEBUFF']).ObjectCurrentPower/HPalready fold these in viaSWUTurnEffectStatBonus($obj,'power'|'hp')(STAT_BUFF − STAT_DEBUFF) andreturn max(0, $base)— a printed stat is floored at 0 (clamp the final result). Expiry is central (SWUExpireTurnEffects); do not hand-add acase/string-match or touchRegroupPhaseStart. 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
TurnEffectis 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 ≤ 0→SWUDefeatUnit) and call it at every board-change point: after applying a shrink, and inActivateCard'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 byUniqueIDbefore 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 printedCardHp—SWUCombatDamageinCombatLogic.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 printedCardHp; 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:RegroupPhaseStartrunsSWUCheckShrinkDefeats()afterSWUExpireTurnEffects('phase')strips the STAT_BUFF/STAT_DEBUFF tokens, defeating any unit now at/over its un-buffed HP. Test BOTH the in-phase survival (assertP…GROUNDARENACOUNT:1while the buff is up) AND the post-expiry defeat (Passto 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 ≤ 0→SWUCheckShrinkDefeats), 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 bonusSWUCombatDamageadds to attack power and consumes (mirrors Raid), soObjectCurrentPoweris identical before/after, only the dealt damage rises. Do NOT useSWUApplyPhaseBufffor it — that persists toRegroupPhaseStartand was a real bug (lingering +3). ReserveSWUApplyPhaseBufffor genuine "for this phase". Test BOTH: the dealt damage (proves it applied) AND post-attackPOWER:<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 callSWUAfterActionafterBeginSWUAttack(the trigger-resume owns it); only the event form (SOR_220) calls it on the null/decline path.
3d. Gotchas
$playerIDbeforeGetZoneObject—GetZoneObject("myGroundArena-0")resolves"my"against$playerID. If it's not set, the object comes back null. Alwaysglobal $playerID; $savedPID = $playerID; $playerID = $player;before callingGetZoneObjectwith a relative mzID, then restore after.$playerIDwhen QUEUEING a relative-mzID decision — do NOT restore before returning. A queuedMZMULTICHOOSE/MZCHOOSEwhose param uses relative mzIDs (myGroundArena-N) is validated by the harness'sMZCountChoicesimmediately after the queuing handler returns, resolving those zones under the global$playerID. If the handler restored$playerIDto anything other than the decision's player (common in CUSTOM / phase handlers that save+restore),MZCountChoicescounts 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 reasonActivateCard's event branch deliberately doesn't restore). This is the OPPOSITE of the synchronousGetZoneObjectcase above (where restoring after is correct). Diagnostic signature: a live step-through works (the player clicks the menu) but the regression's scriptedAnswerDecision"doesn't land" — the resolver receives-instead of the pick. That UI-passes / regression-skips split ⇒ suspect$playerIDatMZCountChoicestime. (Caught 2026-06-16: the SEC_122 Falcon-regroup DroidMZMULTICHOOSE, queued fromSOR193_REGROUPwhich 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 inSWUOfferDroidPayment, which leaves$playerIDset.)- Indices shift the moment a unit is defeated mid-ability.
SWUDefeatUnitcallsCleanupRemovedCards()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_SACRIFICEinCardDQHandlers.phpis the canonical pattern (defeat first, thenZoneSearch); (b) any DQ param that references a unit across a potential mutation must carry the UniqueID alongside the mzID and re-resolve by UID. UseSWUFindMzByUID($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, thenSWUFindMzByUIDper hit). - A deployed leader defeated by an ENEMY effect/combat must be removed under the CALLER's
$playerIDcontext, not the leader-owner's.SWUReturnLeaderToZoneonce set$playerID = $ownerPlayerbefore fetching the leader-unit's mzID, so when P1's Takedown defeated P2's deployed leader, the relativetheirGroundArena-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,Deployednever cleared). Fix: remove the unit under the incoming$playerIDfirst, then set$playerID = $ownerPlayeronly 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
GetZoneObjecton 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 anullelement to the zone array, corrupting arena counts far from the call site. The generatedGetZoneObjectnow 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 isfalse, so!isset($obj->removed)wrongly evaluates to FALSE on a live unit whoseremovedwas initialized tofalse— silently SKIPPING the block it guards. (Caught 2026-06-18: the combat defender-defeat branch used!isset($target->removed)while the attacker branch andSWUDefeatUnitused truthy checks; a leave-play path was skipped for certain hosts.) The correct "not removed" idiom across the engine isempty($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+SWUResourceCountinGameLogic.php, costs registered in$leaderActionResourceCostsinLeaderAbilities.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.
SWUDeployLeaderonce 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 (
$gGameLogdid exactly this — green tests, empty UI log). Counters, logs, and flags that survive between actions belong inGameSchema.txt. Zone values must stay single-line (the gamestate format is line-based); encode embedded newlines as<NL>. GlobalEffects flags must be SPACE-FREE — theGlobalEffectsconstructor doesexplode(" "), 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/GetSWUVarvalues live in a PIPE-delimitedKEY=VALUEstring — 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|…, CUSTOMParam). Symptom of getting it wrong is silent + downstream: the value reads back truncated, so a parsed flag/amount comes out as0/empty (amayDeclineflag becamefalse→ an optional choice couldn't be declined; a+1bonus 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
GlobalEffectsflag (a persisted schema zone — count-based so multiple arms stack) when the source resolves, then consume it (andRemoveGlobalEffect) in the target phase handler. Critically, do NOT add the flag toRegroupPhaseStart'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 inActionPhaseStart.) - Deployed leaders:
"Unit"filter EXCLUDES them,"Leader Unit"filter INCLUDES them. A deployed leader'sCardType()is"Leader"(not"Unit"), soZoneSearch(["Unit"])skips it — perfect for "non-leader unit" restrictions (no extra filter needed).ZoneSearchnow maps a deployed leader to also match the"Leader Unit"token (fixed 2026-06-15 — one line inZoneSearchadds'Leader Unit'to the type list whenIsLeaderUnit($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 bareZoneSearchenumeration. 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 theZoneSearchmapping 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-PASSPARAMETERinto a 2-targetMZCHOOSE(audit rule #8 applies to deployed leaders too). ZoneSearchexact-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), soZoneSearch($zone, $types, null, null, ['Droid'])silently matches NOTHING (returns 0). Filter by trait withGetField($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 useHasTrait. (Caught 2026-06-16: SEC_122's "−1 per friendly Droid" first tried the$cardSubtypesarg and counted 0 discount.)- "If you have the initiative" = the
InitiativeCounterprefix, not a separate flag:$holder = strpos((string)GetInitiativeCounter(), 'P1') === 0 ? 1 : 2;(it returnsP1_CLAIMED/P1_UNCLAIMED/P2_…). Test it via GIVENWithInitiativePlayer: N+WithInitiativeClaimed: true(P1OnlyActionsgives P2 the initiative). - A played event is already in its own discard when
OnPlayEventruns.ActivateCardmoves the event to discard before callingOnPlayEvent, 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()beforeZoneSearch. OtherwiseZoneSearch(skips removed) andGetZoneObject(raw array index) disagree:ZoneSearchreturnsmyHand-0for 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 this —SWUQueueDisclose(the disclose card/event that triggered it was just removed) callsCleanupRemovedCards()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 aMZMULTICHOOSEparam'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 fromwindow.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
SWUAfterActionbehind a SWUVar flag rather than fightingActivateCard's lifecycle. ActivateCard owns its terminal After Action (sync / async SWU_TRIGGER_RESUME / upgrade_SWUFinalizeUpgradeAttach— all funnel throughSWUAfterAction). Set aSWU_*_IN_PROGRESSflag; inSWUAfterAction, if set, redirect to your orchestrator (re-offer / next step) instead ofSWUSwapTurnPlayer; 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) —CleanupRemovedCardskeeps 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 (runsExecuteSWUAttackimmediately) and queues aMZCHOOSEonly for 2+. For "attack with a unit" event/leader tests, give the opponent only a base so the attack target is deterministic with no extraAnswerDecision. It also handles exhausting the attacker and the combat continuation — don't callSWUAfterActionafter 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"),ExecuteSWUAttackexposes it viaSetSWUVar('SWU_CURRENT_DEFENDER', $targetMzID); read it withGetSWUVar('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:HealBaseisHealBase($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 fatalcannot redeclare functionthat 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
$hasShootFirstbranch inSWUCombatDamage(attacker hits first; if the defender is defeated it deals NO counter-damage). The marker is theSHOOT_FIRSTTurnEffect (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 registrySTAT_BUFF(tokenSOR_217), folded intoObjectCurrentPower, 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 → attackerDAMAGE: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 thetheirBasebranch ofSWUCombatDamage. (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_onceinGameLogic.php. MZCHOOSEtarget string must be pre-computed before theAddDecisioncall; 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
CollectEntryTriggersor a post-play hook. Tag the unit with the granting card's CardID token (e.g.AddTurnEffect($mz,'SOR_100')) and add a$turnEffectRegistryrow (['kind'=>'GRANT_KEYWORD','value'=>'AMBUSH','duration'=>SWU_DUR_PERM]for while-in-play, omitdurationfor "this phase"). See "Turn-effect registry & durations". - Leader deploy is a free epic action.
SWUDeployLeadermust NOT exhaust resources. Never addSWUExhaustResourcesto the deploy path. WhenDefeatedunits blockPlayHandeven when unimplemented. If a unit is inHasWhenDefeatedAbility,FlushTriggerBagqueues aRESOLVE_TRIGGERDQ entry regardless of whether$whenDefeatedAbilitieshas a handler. That entry makesAllQueuesEmpty()return false, blocking ActionMap for all players. Choose test-fixture units that are NOT inHasWhenDefeatedAbility.WithInitiativePlayer: 2alone breaksPlayHand. ActionMap checks$playerID == $turnPlayer. If P2 holds initiative andWithActivePlayer: 1is not also set, TurnPlayer defaults to P2 and P1'sPlayHandsilently does nothing. UseP1OnlyActions: trueinstead 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 (TestSchemaSetup → TestSchemaStep → GetNextTurn), 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*()→ missingDo*implementationexpected X, got 0on SHIELDCOUNT / DAMAGE →$playerIDnot set beforeGetZoneObjectUnknown schema command→ WHEN command not wired in SchemaTestRunnerUnknown EXPECT assertion→ assertion regex not added to SchemaTestRunner
Debugging rules learned the hard way:
-
Reproduce a "known / deferred engine bug" via
TestSchemaStepBEFORE 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.phpclaimed 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.phpfor 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 acaseexists only in the dead GA-fallbackObjectCurrentPower/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
LASTLOGCONTAINSinstead 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$ois never even iterated (e.g.ZoneSearchdidn't return it), anerror_loginsideImmune()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. (PHPerror_logmay 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 isAddGameLogEntry('ABILITY', 'TAG '.$var…)at the point of interest, then read it back viacurl …/GetNextTurn.php?gameName=N&playerID=1 | grep -o 'TAG[^"<~]*'(drive the scenario withTestSchemaSetup+TestSchemaStepfirst). Remove the temp log line when done.) -
TestSchemaStep.php'spendingarray 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 anMZCHOOSEeven for a single target (no1→PASSPARAMETERoptimization, so a test must answer it). When reusing an existing card in a multi-step scenario, drive it throughTestSchemaStepfirst to learn its actual decision shape before writing the WHEN block. -
GetNextTurn's per-cardCurrentPower/CurrentHPrender fields are unreliable for deployed leaders (often show-1/0for 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/:DAMAGEchecks (readDamage,Status,TurnEffectsfrom 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
removedflags and element count) at assertion time. A phantomnullzone 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 ingit 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.*Dataread 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.phprewrites 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 withCall 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 itsModule: Versions=entry, and regen again. (Caught 2026-06-17: adding theTempZonezone forced a regen that surfaced a missingMacroGameIndexscalar — the generator always emitsGetMacroGameIndexArray()→GetMacroGameIndex(), but SWU's schema had onlyMacroTurnIndex; 16 capture/shield tests fatal'd. GA declaredMacroGameIndex - 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.phpre-fetches the live card API and rewrites the WHOLEGeneratedCardDictionaries.phpfrom 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 innon‑leader), reworded triggers, and a corrected stat — silently breaking 5 unrelated tests. Prefer hand-editingGeneratedAbilityStubs.phpdirectly to add ONE card's trigger — but 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 touchingzzCardCodeGenerator.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$manualStubAdditionsmade 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 againstcards.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.phpis 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 andHas<Trigger>Ability(cardID)returns false despite matching card text, add the phrasing to the generator's Phase-7 detection (zzCardCodeGenerator.php) — noteHas<Trigger>Abilitystubs come fromzzCardCodeGenerator.php(the API-refetch one), so to avoid a dictionary regen, hand-editGeneratedAbilityStubs.phpto add the onecaseAND 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 (whenPlayedAsUpgradedispatches toOnWhenPlayedAsUpgrade; the whenPlayed fallback dispatches toOnWhenPlayed— 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 inCollectCombatStep1Triggers); a plan that proposes a brand-newWhenAttackedtrigger 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 Defensewas exactly this until SOR_196 —HasOnDefenseAbilityhad detectedLAW_121/TS26_24but 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 theAddTriggersite (preg_replace('/^their/','my',$mzID)— the defender always sits in the attacker'stheirzone). (2) single-trigger orchestration owner —FlushCombatTriggerBag's 1-trigger branch queuedRESOLVE_NEXT_TRIGGER/SWU_TRIGGER_RESUMEon 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'sController, 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>PassthenP1>Passreaches regroup), then resolve the regroup-resourceMZMAYCHOOSEfor both players withP1>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
answerDecisiondiverges from the liveProcessInputpath for TWO consecutive opponent (P2) decisions after a turn-swap in the same WHEN block.P1OnlyActionsmakes P2 auto-pass (it eats P2's decisions); but even without it, the in-process runner only applies the FIRST of two consecutiveP2>AnswerDecisions when a turn-swap (P1>Attack→P2>Pass→P1>Play…) preceded them — the liveTestSchemaSteppath applies both correctly (verify there: bothMZCHOOSEs appear and resolve, final state correct). A single-turn opponent-multi-decision (P1>PlayHand→P2>Answer→P2>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>PlayHand→P2>AnswerDecision(opponent MZCHOOSE — SOR_041; or YESNO — SOR_233), ANDP1>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 consecutiveP2decisions after a turn-swap — NOT "any opponent decision." Write the cross-player test normally (driveP1>…thenP2>…); 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 pendingRESOLVE_TRIGGERthe runner does NOT auto-drain → the whenDefeated effect never applies in the snapshot (cost SEC_055's heal-on-defeat; the live step harness showedRESOLVE_TRIGGER|WhenDefeated|… player:1still pending afterP2>Attack). Fix: test a whenDefeated by having the unit die as the ATTACKER (P1>AttackGroundArena:0:0into a bigger/defended unit, or pre-damage it withCARDID:1:Nso 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
MZCHOOSEdecisions 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 liveTestSchemaSteppath (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 realMZCHOOSE, then answerP2>AnswerDecision:myHand-0explicitly (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-sepcount:cardID:statusgroups); assert ready count withP{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 withP1LEADER:READY/P1LEADER:EXHAUSTED. - Cost-modifier patterns (Phase 9): subject-keyed one-shots →
$playCostModifiers[$cardID]; armed "next X costs less" → aSWU_…_DISCOUNT_NEXTglobal set in the trigger, read inSWUComputePlayCost, consumed inActivateCard, cleared atRegroupPhaseStart(SEC_110/261); "first X each phase" → aSWU_…_USEDflag set at the play/attach site (SEC_064 marks atATTACH_UPGRADE). Keyword-grant-to-others (Raid/Restore/Overwhelm/Hidden/Sentinel "each other friendly…") → add a case/loop in the matchingGetConditionalKeyword_X_Value/HasConditionalKeyword_X(SEC_047/099/104/140/201/203) rather than a handler.
- Cross-player
-
SEC Phase 12 — combat reactions, ability-loss, interactive prevention (9 new reusable seams):
onDefenseFromUpgradeseam (SEC_052) — mirror ofonAttackFromUpgrade: scan the DEFENDER's upgrades inCollectCombatStep1Triggers,AddTrigger($defController,'OnDefenseFromUpgrade',$up->CardID,$defMzForDef);OnDefenseFromUpgradeTriggerdispatches$onDefenseFromUpgradeAbilities[CardID]AND setsSWU_PENDING_DEF_REACTIONso 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(...). - ⚠
PlayerCanDisclosecross-context — it now restores$playerIDAFTER 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 = $discloserbeforeSWUQueueDisclose. - Defender UID re-validation in combat (SEC_187) —
ExecuteSWUAttackcapturesSWU_CURRENT_DEFENDER_UID;SWUCombatDamagere-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 staletheirGroundArena-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,SWUMakeTurnEffectemits a BARE'CARDID'token →LostAbilities's rawin_arraydetects it ANDSWUExpireTurnEffects('attack')drops it at attack-end. Set it inBeginSWUAttack(own, pre-target so line-492 own-OnAttack is suppressed) or inCollectCombatStep1Triggers(defender, before the OnDefense collection). The OnDefense collection now gates on!LostAbilities($defender). - ⚠ VALUE keywords don't honor suppression —
GetKeyword_Raid_Value/Restoreread the registry directly (noSWUKeywordSuppressed). 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_LOOPvar holds the exclude-UID CSV;_SWUMonMothmaOfferMAY-offers the remaining OTHER units (ready or exhausted) →BeginSWUAttack(noBases=true). Rides the chained-attack hooks:CollectAfterAttackTriggersqueues a resume while the var is set; the SWU_TRIGGER_RESUME stack-empty branch re-offers after each attack. NOSWUAfterAction(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 fromCollectCombatStep3Triggers(capture$combatCtx['attackerTookDmg']at the counter-damage points; defender viadealtToUnit; each gated on survived + >0) AND fromSWUDealDamageToUnit(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
CollectCombatStep1Triggersand 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 leavesFlushCombatTriggerBag=0, soExecuteSWUAttackqueuesSWUCombatDamagedirectly 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 markerSWUCombatDamageconsumes 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_SWUConsumeAmidalaPreventin CombatLogic to enumerate the live set before editing. Ability path: add$skipPreventtoSWUDealDamageToUnit, defer + offer, re-apply (skipPrevent) on decline. Indirect is exempt for free — it writesDamagedirectly, never throughSWUDealDamageToUnitorSWUCombatDamage'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 (_SWURevertSec192StealsfromSWUAfterAction, next toSWUFlushDeferredReplacements) that reverts/cleans any entry whosesrcUIDis 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_SWUBaseCaptureUnitmirrorsDoCaptureUnit's CR 8.34 steps but stores the captive inSWU_BASECAPTIVE|{cardID}|{owner}, and_SWURescueBaseCaptives(RegroupPhaseStart) rescues viaDoRescueUnit— noteDoRescueUnit($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 inExecuteSWUAttackwhen the target is a base (SWU_ACTION_BASEATK=base owner), then FINALIZE inSWUAfterActionintoSWU_LAST_ACTION="{actor},BASEATK,{owner}"or"{actor},OTHER"(every action overwrites it → it always holds the previous action; reset at RegroupPhaseStart). The reader checksopp,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 inActivateCardonstrpos($mzID,'Hand')!==false(so the Plot/resource pathmyResources-Nis unaffected) + suppress the hand glow inCanAffordActivationReserve(confirmed hand-only caller). Don't reuseSWUCardPlayBlocked(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 theSWU_PLOT_K/Pwindow gate; MZMAYCHOOSE → guarded nestedActivateCard(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 inSWUPlayFromOpponentDiscard(cost = CardCost, noSWUAspectPenalty). Combat base-hit (SWUCollectCombatHitTriggerscasegated ondealtToBase) mills the defender's deck (SWUMillTopCard) and stamps the milled discard entry. "For this phase" rides the existingSWUClearDiscardModifiers(RegroupPhaseStart). ⚠ Pre-existing limit:SWUPlayFromOpponentDiscardonly 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 useTraitContains(not bareHasTrait) 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_shiftoff the deck (SOR_223 seam), trait union viaCardTraitsplit, 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 aLostAbilities($obj)-only approach can't reach. "Loses ALL abilities" means gating EVERY surface, and they split two ways: (1) already covered byLostAbilities— anything that flows throughSWUKeywordSuppressed(innate/granted keywords incl. Sentinel/Shielded-on-entry) or already has aLostAbilities($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 haveOwnerset (it does). (2) need an EXPLICIT gate with_SWUGalenSuppressesCard($player,$cardID)(orLostAbilities($obj)where an in-arena object is in hand) wherever the surface keys on a CardID / hand card / resource (no reliable objectOwner): When Played (CollectEntryTriggers), When Defeated (CollectWhenDefeatedTriggers), Plot (_SWUEligiblePlotResources+PlayerHasPlotsToPlay), Smuggle (SWUSmuggleResource), Piloting (the Unit/Pilot offer — the hand object'sOwneris unset, so gate on$playernotHasKeyword_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, gateLostAbilities($attacker)), combat-hit "deals combat damage" (SWUCollectCombatHitTriggersearly-return), When-Played-as-Upgrade (CollectWhenPlayedAsUpgradeTriggers), play-cost modifiers (subject-keyed + the field-source loop inSWUComputePlayCost), and reactive observers (Gideon/own-/opp-play — use a LostAbilities-filtered count_SWUCountActiveUnitsWithCardID, NOT the plain_SWUCountUnitsWithCardIDwhich 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; grepgrep -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 inObjectCurrentPower/HPcorrectly ignoresLostAbilities. 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 inActionPhaseStart. A "each enemy unit, its controller pays 1 or it's exhausted" is ONE cross-player MZMULTICHOOSE (set$playerID=targetbefore 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>PlayHand→P2>Pass→P1>Pass(→regroup) →P1>ResourcePass+P2>ResourcePass(→ next action phase, the queued decision appears) → answer it (probe the exact sequence withTestSchemaStepfirst — thependingarray 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 assertsP1WIN/P2WINviastate->winner()). Arena-control win = armSWU_CONFIDENCE|{caster}|{arena}at play (OPTIONCHOOSE Ground/Space), check at RegroupPhaseStart (caster controls units in that arena viaGetGroundArena/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_PHASEflag set inSWUAfterAction(guarded so it doesn't stack) and cleared inActionPhaseStart; gate the card inSWUCardPlayBlocked($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.
- 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
-
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;SWULeaderActionexhausts 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") → acaseinSWULeaderActionAffordable. Deploy side = the NORMAL trigger registries underSEC_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=GetLeaderCardID===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=falsein theGetLeaderloop, then the effect). Fire it viaAddTriggerat the event site so it rides the flush — e.g. SEC_013 at the combat attacker-death site (next toSWU_ATTACKER_DEFEATED), SEC_017 inSWUCollectCombatHitTriggers. ⚠ The deploy side and front side use distinct trigger types when both can be armed from the same site: bareSEC_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. _SWUFinalizeUpgradeAttachgained$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)exhaustscost−prepaid). Caller owns the After Action: callSWUAfterActiononly when$triggered===0(a triggered upgrade's own flush owns it). Host-filter viaSWUGetUpgradeValidTargets ∩ <friendly non-Vehicle>.- ⚠
SWUReturnResourceToHandOwner==0 latent-bug FIX (SEC_008 Bail) — resourceOwneris often 0 (set, not null) sointval($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 — anyAddHand($obj->Owner …)on a resource/zone object needs the<=0 → controllerguard. - 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-unitSWU_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 BOTHSWUGetValidAttackTargetsloops with a Sentinel exception (SOR_142 pattern). Leader-front passive ⇒ gate on…CardID==='SEC_012' && empty($l->Deployed). SWUQueueAnotherAttackconstraint extended withcostlt: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" armscostlt:{firstCost}beforeBeginSWUAttack; 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-multisetSWUQueueDisclosecovers. 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_intersectempty). Shared offer fn takes a$mayflag (leader mandatory / deploy On-Attack MAYCHOOSE — safe in OnAttack) + a$closeActionflag (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$turnEffectRegistryrow) before the nestedActivateCard(with$gTurnPlayer/PASS save-restore neutralizing the inner after-action), then scan all 4 arenas for the unit bearing that token. SEC_018 thenDoCaptureUnit($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" = gateDoRescueUnit'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 withWithActivePlayer:1(NOTP1OnlyActions, which makes P2 auto-pass and EATS the YESNO) +P2>AnswerDecision:YES/NO. - DSL/fixture notes:
AttackGroundArena:idx:BASEattacks the base directly even with enemy units present (forcestheirBase-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 viaWithP*GroundArena: SEC_XXX:1:0+ the:1:1:1deployed 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 asattackerDmgAmt/defenderDmgAmtinSWUCombatDamage) for "deal THAT MUCH" effects.
- The two-sided-leader recipe. Front ability =
-
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, theRESOLVE_TRIGGERCUSTOM 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>Passdrains 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 feedAnswerDecision:-— 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 separateChooseMyGroundUnitstep. Adding a phantomChooseMyGroundUnit:0then desyncs the very next decision (it gets eaten as that answer). The host-choice prompt only appears with ≥2 legal hosts. Step the flow viaTestSchemaStepto see whether the pending afterPlayHandis the host-choice or already the When-Played decision. (LAW_187 Staccato Repeater.) UPGRADECOUNT:Ncounts Shield (SOR_T02) and Experience (SOR_T01) token subcards too, not just real upgrades — a host that gets +1 upgrade AND a granted shield isUPGRADECOUNT:2.SHIELDCOUNT:Ncounts 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-resolveSWUUsableCreditTokenMzIDseach iteration; hand discards → sort mzIDs DESCENDING by index). Dealing/defeating by the originalmyZone-Nstrings mis-targets after the first lethal hit reindexes the zone — the same discipline as the SOR_043 mass-defeat row, but it bitesMZMULTICHOOSE-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 inSWUCollectLeavePlayReactionswhen a defeated unitHasTrait 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) needsSWULeaderActionAffordable → falseso the engine doesn't offer a phantomUseLeaderAbility(the affordability check does NOT verify$leaderAbilities[cardID]exists). Same→falseguard for any DEFERRED leader's front (LAW_014/LAW_015). - Self-defeat-after-attack = grant the
LAW_062attack-duration marker (unconditional;SWUMakeTurnEffect('LAW_062',[],SWU_DUR_ATTACK)→ setscombatCtx['law062SelfDefeat']→ reuses theLAW_205/HeroicSacrificeDefeatTriggerdispatcher) alongsideSWUAddAttackPowerBonus+ anOVERWHELMmarker for "attack with a unit, +X/+0 + Overwhelm, then defeat it" leaders (LAW_001 Saw). Granted When-Defeated via an upgrade subcard → add to theCollectWhenDefeatedTriggerssubcard scan (SEC_156/JTL_073 pattern); pass needed payload (host cost, ready-flag) via the mzID slot sinceFlushTriggerBagdropsextraParams(LAW_141, LAW_201).
- ⚠ Cross-player auto When-Defeated triggers don't self-drain — the test needs a trailing
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-exploitmt_randtiebreak), 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
SWUDefeatResourcerunsCleanupRemovedCardsand 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/…Readyonly mark the SOURCE cardremoved— they do NOT compact it out, so a loop that replaces N-from-deck must re-scan for the first non-removedmyDeckcard each pass rather than blindly takingmyDeck-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 bracketed —
WithP2Deck: [SEC_080 SOR_095 SOR_100], NOT comma-separated (A,B,Cparses as ONE CardID"A,B,C"and silently seeds a 1-card deck)._parseDeckListtrims[]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.).SWUCollectOpponentPlayReactionslikely 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 ofActivateCard/CollectEntryTriggers(the$isPlayown/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'sMZCHOOSE. 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_VILLAINYand the other play-flags atActivateCard~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 inOnPlayEvent(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_ATTACKalready supports a bare-TRAIT constraint."0,0,0,{uid},Droid"(5th CSV field) restricts the chained attacker to Droid units (the resolver's finalelseifdoes!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-NmzID can go STALE by the time the#0continuation runs (the event'sFINISH_PLAY_CARDblock-10 discard shifts the hand) — resolve the chosen object's CardID first, then re-find its CURRENT hand index. (2) TheActivateCard($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 — budgetprinted + aspect_penalty − discountin the test (a Vigilance/Heroism unit under a Cunning leader is +4). Wrap the nestedActivateCardin the$gTurnPlayer/PASSsave-restore. GetBase($player)returns an ARRAY — use$base[0]->Damage, never$base->Damage(a$base->Damageread 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/SWUDefeatUnitmarkremovedbut the next MZMAYCHOOSE's offered set already excludes them). So the re-offer liststheirGroundArena-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,AddGameLogEntrya spaceless tag (TWI202_HIT), assertLOGCONTAINS:TWI202_HIT, and smoke-verify a real target took the damage viaTestSchemaStep+GetNextTurn. - Leaders — both sides, fast recipe. Front
Action:=$leaderAbilities["CID"](bare CardID;SWULeaderActionexhausts 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") → acase 'CID':inSWULeaderActionAffordable(a leader with NO front Action — pure passive + Epic, e.g. Nala Se TWI_001 — needs NO$leaderAbilitiesentry; the affordability guard'sif (!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 underCID:0($onAttackAbilities/$whenPlayedAbilitiesfor On Attack / When Deployed) + deployed passives as field-presence checks inObjectCurrentPower/HP(_SWULeaderDeployed($ctrl,'CID')) or theHasConditionalKeyword_*fns; deployed-side keywords (Sentinel/Restore/Overwhelm) are auto-derived by the generator fromdeployTextData— no wiring. A deployed "each OTHER friendly unit gains X" is one line inHasConditionalKeyword_Xgated on_SWULeaderDeployed. Test a front side withmyLeader:CID:1+UseLeaderAbility; test a deployed side withmyLeader:CID+DeployLeader(Epic needs ≥ printed-cost resources) then attack, ORmyLeader:CID:1:1to seed it pre-deployed. SWUDefeatUnit($player, $mzID)is 2 args (player FIRST) — a 1-argSWUDefeatUnit($mz)fatals the whole action; grep an existing call before writing one.- Grant tokens
'SENTINEL'/'SABOTEUR'/'RAID'(+value)/'OVERWHELM'are ready-made registry rows —AddTurnEffect($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 computesGetMzID()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 = $oppbeforeAddTurnEffect($new,…)/any use, else the marker lands on a slot in the caster's arena (wrong/absent unit). ReuseTEMPORARY_STEALfor "control until regroup" —RegroupPhaseStartauto-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'sCardIDto the copied card and ALL attribute/ability/keyword/arena/entry-trigger reads resolve for free (place via the copiedCardID, runCollectEntryTriggers/SWUApplyPassiveEntryGrantswith 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 (TraitContainsspecial-case), non-unique (SWUEnforceUniquenessskip); (2) revertCardIDto the real card on EVERY leave-play or you corrupt deck/discard ownership — the choke isSWUAddToDiscard(..., $sourceObject)(reverts when$sourceObject->IsClone), but confirm the defeat path PASSES the object:SWUDefeatUnitdid not originally (fixed to pass$obj); also patch capture (DoCaptureUnit) + bounce (SWUBounceUnit). The intercept for the play-time "may become a copy" choice goes inSWUBeginPlayCard(mirror the Piloting Unit/Pilot choice), stashing the chosen CardID in a global thatActivateCardconsumes. - 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: appendFoo:boolean=falseto the zone's line inSchemas/SWUSim/GameSchema.txt(append at END → backward-compatible via thecount($arr) > Nparse guard) AND hand-patchSWUSim/ZoneClasses.phpfor BOTHGroundArenaandSpaceArena(property decl + parse at the new index + serialize) to match what the schema-driven generator would emit. Guard it with anUndoCycletest — thatSaveVersion→LoadVersionround-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 (SWUKeywordSuppressedcalls 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"), extendLostAbilitieswith 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 — gateSWULeaderActionAffordable(returning false there also stops the directSWULeaderActiondispatch, 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 (noSWUUnitActionAffordablecase 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_*_DEFEATEDflags/_SWUUnitDefeatReplacement), thenSWUExpireTurnEffects(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/NextLiveSeatetc. are UNDEFINED here (they live only on the twin-suns branch) — calling one is a fatal that kills the action. UseOtherPlayer($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 nestedActivateCardall 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, bumpmyResourcesgenerously (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 atCollectEntryTriggers(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 fromSWU_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
SWUAfterActionwhile the DEPLOYED On-Attack / When-Attack-Ends lets combat own it. Pattern:"CARD#0|{arg}|{close}", and in the handlerif ($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 onGetLeader($ctrl)CardID +empty($l->Deployed); deployed-side on_SWULeaderDeployed; the two are mutually exclusive so both clauses can live in the sameHasConditionalKeyword_*. - "Next EVENT/unit you play this phase costs N less" is a count-based flag
SWU_<CARD>_DISCOUNT_NEXT: arm N copies, subtractGlobalEffectCount(...)inSWUComputePlayCost(gated onCardType Event/Unit), consume inActivateCard(RemoveGlobalEffect for one-shot, orSWUClearGlobalEffectsByPrefixto 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
OnPlayEventdirectly (NOTSWUCollectOwnPlayReactions, which scans deployed units only). Guard on_SWULeaderReadyUndeployed($p, 'CARD'); queue the reactive YESNO at the TOP ofOnPlayEventbefore the switch. Exhaust the undeployed leader by setting$l->Ready = falsein theGetLeaderloop. (TS26_08 Ahsoka front.) CHAINED_ATTACKnow takes a noBases flag ("CHAINED_ATTACK|{bonus}|{noBases}") for "attack with another unit that can't attack bases" (TS26_04 Padmé).SWUQueueAnotherAttackfilters READY units only + allows bases — roll a custom entered-units may-choose →CHAINED_ATTACK|0|1when you need include-exhausted / noBases / an entered-this-phase filter.- Reminder-text keyword arithmetic:
Gritis +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 Nis a while-attacking power bonus (not a heal — that'sRestore 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 viaGetKeyword_{Kw}_Value($obj) > 0(Raid/Restore) — value keywords have noHasKeyword_boolean. CountExperiencetokens by scanningSubcardsforSOR_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=theCollectWhenDefeatedTriggerssubcard scan + aDispatchTriggercase. 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', '')inCollectCombatStep1Triggers(fire on the base-attack window —strpos($defenderMzID,'Base')!==false) + aDispatchTriggercase that builds the may-choose and setsSetSWUVar('SWU_PENDING_DEF_REACTION','1')(identical to Barriss TS26_78's On-Defense pause). It drains cross-player and the reactor answers viaP2>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
$playerIDset to that decider first (soZoneSearch("theirHand")is relative to them), and use ONE handler keyed off$player(the decider) for both sides — the hand owner is alwaysOtherPlayer($player). The opponent's decision drains inside the same resolution and is answered asP2>AnswerDecision:theirHand-N(cf. SEC_147 for own-hand discards). A trailing "then each draws"CUSTOMqueued 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$lastDecision—APPLY_PHASE_BUFF/DEBUFF/BOUNCEalready do;DEAL_UNIT_DAMAGEdid NOT (fixed), so a non-active decider'stheirGroundArena-Ntarget 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$playerIDat 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 inSWUUnitActionAffordable(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 stripsTEMPORARY_STEAL→ permanent; preservesOwnerso 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 iscid:ready:damage:turnEffectswith no controller override, andWithGroundUnitForPlayerplaces 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 → WriteGamestate → ParseGamestate → GetNextTurn — 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.
Expert Next.js App Router
Developpement
Un skill qui transforme Claude en expert Next.js App Router.
Générateur de README
Developpement
Crée des README.md professionnels et complets pour vos projets.
Rédacteur de Documentation API
Developpement
Génère de la documentation API complète au format OpenAPI/Swagger.