---
name: swusim-implement-card
description: Use when implementing SWU card abilities — looks up card text, writes all DSL tests first, then self peer-reviews against the CR + game logic + card data and implements; only stops for the user on a card it can't verify to 98% alone. Supports single cards and batches.
---

# SWUSim Implement Card

## Overview

TDD workflow for card ability implementation. Works for a single card or a batch of cards.

Three phases. After all tests are written, do a **self peer-review** rather than a blanket stop-and-wait.

**Gate after Step 2 — self peer-review, then proceed (policy updated 2026-06-22):**
There is **no tier-based hard stop**. After writing tests and RED-checking, review your own work for **flow correctness** against three sources before implementing — then proceed straight through implementation:
- **The CR** (`.claude/SWUSim/refs/comprehensive-rules.md`) — confirm the timing window, interaction order, and any rules-keyword semantics (e.g. "When this unit is attacked" = the On Defense window, CR 15.c).
- **Game logic** — trace the actual code path the card will take (the combat-pause, the disclose flow, the trigger collection point) and confirm the tests drive the real execution path, not just a fixture stand-in.
- **Card data** — every stat/cost/aspect/trait in the tests derived from the dictionary arrays (not memory or a prose doc), for *every* fixture including the incidental ones.

If that self-review leaves you at **≥98% confidence** the tests are correct and the implementation is clear, just implement it (show the tests + design in the eventual batch summary so the user can course-correct *after*).

**Only STOP and ask** when a card is **too complex to verify confidently on your own** — i.e. the self-review can't get you to 98%:
- an ambiguous ruling / interaction the dictionary + CR don't settle;
- new shared infrastructure with a **real design choice** the user alone should make (a *mechanical mirror* of an existing seam is NOT a real choice — verify and proceed);
- a scope/realism decision (which scenarios matter); or you simply can't reach confident correctness.

Flag that *specific* card/fork — don't gate the whole batch. Rationale: the mechanical test-writing (correct DSL, RED-by-construction, vetted fixtures/stats) is reliable, and a disciplined CR + game-logic + card-data self-review catches the flow errors a human review would; the residual human value is genuine design/ruling intent, which is rare and surfaced as a targeted question, not a blanket gate.

---

## Confidence bar — 98% minimum on every new card (policy set 2026-06-15; 94% 2026-06-22; **raised to 98% 2026-08-02**)

**No card is "Done" until you'd honestly rate correctness ≥ 98%.** This is a per-card bar, not a batch average — one shaky card at 80% is not offset by four solid ones. Before marking a card Done, ask yourself: *"If the user manually playtested this right now, what's the chance they find a wrong number or a broken interaction?"* If that chance is over ~2%, the card is not done.

**Why the bar is this high.** A later independent validation pass over an already-"finished" set routinely finds a dozen behaviors nobody tested — and every one of those is a bug that shipped, or a bug that *could* ship on the next refactor with nothing to catch it. **The target is that such a pass finds ZERO new behaviors.** So the standard is not "my tests pass"; it is *"an independent test-writer, working only from the printed card text, could not think of a scenario I haven't written."* Write the tests you would want to find if you were auditing someone else's work.

### The coverage matrix — derive it from the CARD TEXT, mechanically
Decompose the printed text into **clauses** first (see the clause-decomposition gate below), then walk both lists. Every cell is either a section or a **written, specific reason it is N/A** ("no cost, so no payment axis") — never a silent omission.

**Per CLAUSE (do this for each clause independently, then once for all clauses firing together):**
1. **Positive** — the clause does its thing.
2. **Negative — prove the gate is load-bearing.** Every `if` / `while` / "you control a X" / "that costs N or less" / "while defending" needs its FALSE case asserting the clause does NOT fire. This is the single most commonly missing test, and it is missing even when the code is right (Gold Leader JTL_054's aura was correct but neither "when IT attacks" nor "when another friendly unit defends" was tested).
3. **Optional branch** — take AND decline (`AnswerDecision:-` / `PASS`). A decline that silently does the wrong thing is a classic latent bug.
4. **No valid target** — must no-op cleanly (no crash, no dangling decision) — and decide explicitly whether the *sibling* clauses still resolve. "Defeat X **and** do Y" is NOT gated by Y being possible; only an explicit "**If you do,**" gates. Getting this backwards fizzles the whole card (Lightspeed Assault).
5. **Quantity discrimination** — pick a value that separates the intended formula from a plausible wrong one ("distinct aspects" vs "card count" needs a same-aspect case that heals 1, not a 2-distinct case that heals 2), and include the **zero** case.
6. **Boundary** — exactly-N vs N±1 for any threshold ("2 or less", "prevent all but 4", "6 or more power").

**Per CARD (cross-cutting — these are where the deep bugs live):**
7. **Dispatch-path matrix — every way the ability can be REACHED is a different code path.** Played from hand · played as an upgrade via Piloting · created as a token · put into play / played for free by another card · **relocated or moved** · leader FRONT side vs DEPLOYED side. Cross this with the trigger halves: a card with When Played *and* On Attack needs the condition tested on **both** halves (Fett's Firespray read "control Boba" correctly on When Played but On Attack was untested; Iden Versio's attach trigger fired on play but not on relocation).
8. **Value-CLASS variants, not just different numbers.** A cost-0 **token** upgrade is a different case from a cheap real upgrade; a token unit from a real unit; a leader unit from a normal unit; a deployed leader from an undeployed one. If the text says "an upgrade that costs 2 or less", a Shield/Experience token IS a legal target — test it.
9. **Persistence across state transitions.** Whatever the card writes must survive every transition it can experience: an **arena move**, a **control change** (owner ≠ controller), a host change, leaving and re-entering play, and the **request boundary** (see the transient-globals shape below). Assert the effect still applies *after* the transition, not just before.
10. **Duration edges.** A "for this phase" restriction must **expire** — test the next phase, where the restricted thing now works. A "once per round/game" must **not re-fire** on the second attempt. A delayed "at the start of the regroup phase" must still find its target after the unit has moved arenas.
11. **Interaction with the standard modifiers.** Shields absorbing damage (does the rider still fire?), "can't be defeated/damaged/captured by enemy card abilities", indirect/unpreventable damage, prevention caps — and for ANY cost, that **Credit tokens / SEC_122 Droids can pay it** (gate offers on `SWUTotalPaymentCapacity`, never a bare ready-resource count).
12. **Scope exclusions — what the effect must NOT touch.** An effect naming zones or sets must leave the adjacent ones alone: "search their deck and hand" must not hit units in play or a same-named **deployed leader**; "another" excludes self; "friendly" excludes enemy (and an unqualified "a unit" includes enemies); "a base" with no qualifier means EITHER base.

### Before marking Done — the adversarial audit (mandatory)
Re-read the printed text **as if you had never seen the implementation**, list every scenario an independent auditor would write from that text alone, and diff that list against your sections. Anything unmatched becomes a section or a stated N/A. Two failure modes this catches, both seen repeatedly:
- **A test NAME that doesn't match what it asserts** — audit by reading assertions, never titles.
- **"The code obviously does this"** — that reasoning is exactly what leaves a correct behavior untested until a refactor breaks it silently.
- **The REAL execution path, not just a fixture stand-in.** A deployed-leader ability dropped into the arena via `WithP*GroundArena` tests the handler but NOT the deploy→attack dispatch; add one test that actually `DeployLeader`s and acts. (And note `CommonSetup`'s leader codes map to a *fixed* leader per aspect-combo — `bw` is Luke SOR_005, not every Vigilance+Heroism leader; either override it with the `myLeader:CARDID` opt or use explicit `P1LeaderBase: <CARDID>/<BASE>:<dmg>` when you need a specific leader. For a *pre-deployed* leader, `myLeaderDeployed:true` (as a unit) / `myLeaderDeployedPilot:true` (as a Pilot on the first friendly unit) set it up without a DeployLeader step. `_parseBaseSpec` accepts `BASEID:damage` to pre-damage a base for heal assertions.)

**When in doubt, ASK — don't guess and don't silently ship at 70%.** If a card's ruling is ambiguous, an interaction is unclear, or you can't get a scenario to a confident green, **stop and ask the user** a specific question (ruling? intended scenario? acceptable to defer this edge?), OR propose the extra tests you'd write to close the gap and let them confirm. Surfacing "I'm at ~80% on card X because edge Y is untested — want me to add tests A/B or is that out of scope?" is always correct; quietly marking it Done is not. A confidence self-review at the end of a batch (per-card %, with anything <98% flagged for the user) is a good habit — the user may opt to manually playtest the flagged ones.

**Leaders are two-sided — the 98% bar applies to EACH side independently, never averaged.** A leader card has a *leader (front) side* (its Epic deploy + any "Action:" / "When you take the initiative:" ability it has **while undeployed**) AND a *leader unit (deployed) side* (`deployTextData[CID]` — On Attack, When Deployed, attack-end / "completes an attack", passives, a deployed `Action [...]:`, and granted keywords). These are **separate ability sets dispatched by different code**, so a rock-solid front side tells you *nothing* about the deployed side. Treat them as two cards: a leader is not Done until you'd honestly rate **both** sides ≥98% on their own — a 99% front side does not offset an unimplemented deployed side (that's two verdicts, and the deployed one fails). Before marking any leader Done:
- **Read `deployTextData[CID]` separately from the front text** and enumerate every deployed-side ability.
- **Confirm a real handler is registered for each.** A generated `Has<Trigger>Ability(CID)` detector returning true with **no** matching `$*Abilities["CID:0"]` handler is a **silent in-game no-op**, not a false positive — this is the ASH_011 / `SWUSim/docs/leader-gaps.md` class. Mapping: On Attack → `$onAttackAbilities["CID:0"]`; When Deployed → `$whenPlayedAbilities["CID:0"]` (**NOT** `leaderAbilities[CID]` — that's the *front* Action); attack-end/completes → `$onAttackEndAbilities["CID:0"]`; deployed `Action [...]:` → `$unitAbilities[CID]` + `$unitActionCostKind`/`$unitActionResourceCosts` (`SWUUnitAction` does **not** fall back to `leaderAbilities`); passive → `ObjectCurrentPower`/`ObjectCurrentHP` field-presence or keyword-grant code.
- **Add at least one test that actually `DeployLeader`s and exercises the deployed ability** (cf. `Tests/Cases/ash/CadBane_PingLeaders.md`). `WithP*GroundArena` placement tests the handler closure but NOT the deploy→dispatch wiring — see the REAL-execution-path axis above.
- **Force-action exhaust nuance:** a deployed `Action [use the Force]` (no `[Exhaust]`) must NOT exhaust the leader unit and must stay usable while exhausted — wire it with a non-exhaust costKind + a Force-token payment, never the default `'exhaust'`. (The front side of the same leader is often `[Exhaust, use the Force]`; the deployed side drops the exhaust.)

---

## Step 0 — Triage: is there anything to implement at all?

**"Implement a card" ≠ "write code for a card."** Two whole classes of card resolve to *verification only* — no tests, no code, just confirm the generator already handled them and mark them Done. Run this triage on **every** card before any research, and drop the no-ops out of the batch up front (it prevents an unnecessary research + test cycle):

1. **Vanilla (blank text box).** If `$textData` has no entry / empty text, the card is fully implemented by the dictionaries (a vanilla upgrade's +power/+HP flows through the existing `ObjectCurrentPower`/`ObjectCurrentHP` upgrade loop). No tests ever. Mark Done.

2. **Keyword-only text, keyword(s) already implemented.** If `$textData` is *nothing but* keyword(s) + their reminder text — **one OR more** keywords, e.g. just `Grit (…)`, or `Ambush (…) Overwhelm (…)`, or `Saboteur (…) Raid 2 (…)` — confirm all three and then mark Done — write nothing:
   - `$textData` is keyword-only (no other sentence/ability; multiple keyword lines are fine),
   - **each** keyword's card ID is in the matching registry in `SWUSim/GeneratedCode/GeneratedKeywordCode.php` — `$Grit_Cards`, `$Sentinel_Cards`, `$Shielded_Cards`, `$Restore_Cards` (value), `$Saboteur_Cards`, `$Ambush_Cards`, `$Overwhelm_Cards`, `$Raid_Cards` (value), etc.,
   - each keyword already has a generic behavior test under `SWUSim/Tests/Cases/keywords/` (or `sor/`).
   A per-card test here would be GREEN on first RED-check — the Step 2 scope rule says drop it. Membership is auto-derived from card text by the generator, so it's guaranteed correct.

   **⚠ "Keyword + rider" is NOT a no-op.** The keyword-only fast-path applies only when the text is *exclusively* keyword reminder lines. A keyword plus **any** other sentence — "Ambush. When Played: return a unit from your discard" (SOR_101), "Raid 1. If you control a Trooper, this costs 1 less" (SOR_248) — has a real ability the keyword wiring does not cover. The keyword half is free, but the rider is genuine work (often Medium): continue to Step 1 for the rider. A card is only fully Simple/no-op when its *entire* text reduces to already-built primitives.

```bash
DICT=SWUSim/GeneratedCode/GeneratedCardDictionaries.php
KW=SWUSim/GeneratedCode/GeneratedKeywordCode.php
# text (is it keyword-only / empty?)
awk '/\$textData = array \(/,/^\);/' "$DICT" | grep "'CARD_ID'"
# registry membership — note the [ ... ]; delimiter here is DIFFERENT from the dictionary's = array ( … ^);
awk '/Sentinel_Cards = \[/,/\];/' "$KW" | grep 'CARD_ID'   # swap in the right registry name
# generic coverage
grep -rilE 'sentinel|grit|shielded|restore|saboteur|ambush|overwhelm|raid' SWUSim/Tests/Cases/keywords/
```

> **awk delimiter gotcha:** the dictionary arrays are `$foo = array ( … );` (match `= array \(` … `^\);`). The keyword registries in `GeneratedKeywordCode.php` are `$Foo_Cards = [ … ];` (match `_Cards = \[` … `\];`). Don't reuse the dictionary pattern on the registries. If `$VAR`-in-`awk` ever misbehaves, use the literal path.

**Keyword-*granting* cards** (an upgrade/passive that gives a keyword to *another* unit — "Attached unit gains Sentinel", "each other friendly unit gains Raid 1") are NOT auto-wired by the registries, but they're **often already implemented** in `SWUSim/Custom/KeywordEffects.php`'s `HasConditionalKeyword_*` switches. Before treating one as new work: grep that file for the card ID, and check whether the grant *mechanism* already has generic coverage (e.g. `core/UpgradeSaboteur_Grant.md` covers Saboteur-via-upgrade). If the `case` exists AND the mechanism is tested for that keyword → mark Done. If the `case` exists but that grant path has **no** test (e.g. Sentinel-/Restore-via-upgrade) → add ONE behavioral guard test (it'll be GREEN since implemented — that's fine, it's a regression guard for hand-maintained switch code, not a redundant test). Only if no `case` exists is it genuine new implementation → Step 1.

**3. Already implemented but unmarked.** Cards are frequently *already coded* yet missing from the Done list. **Always grep the card ID across the whole `Custom/` tree before treating it as new work:**

```bash
grep -rn "CARD_ID" SWUSim/Custom/     # -r descends cards/<set>/ (split cards) AND the monoliths + KeywordEffects.php
# ⚠ Hits in CardMocks.php / CardTraitSupplement.php are DATA, not an implementation — those files
# list CardIDs for preview cards and API-gap traits. They carry a SCAFFOLD-IGNORE marker; ignore
# them when judging "is this card already implemented?" (a mocked card usually is NOT).
```

> **⚠ Card code layout (since the session-95 split).** A card's ability/DQ registrations now live in its own file **`SWUSim/Custom/cards/<set>/<TitleSubtitle>.php`** (reprints consolidated into the earliest printing's file), loaded by `cards/_loader.php`. The monoliths (`CardDQHandlers.php`, `LeaderAbilities.php`, `BaseAbilities.php`) keep only shared helper families, generic utilities, engine glue, and a few load-order-coupled cards; **`CardEffects.php` was deleted** (its `OnPlayEvent` event-play logic was inlined into `ActivateCard` in `GameLogic.php`). Shared helpers `SWUOfferUnitTarget`/`SWUOfferBaseTarget`/`SWUOfferDiscard`/`GiveTokenUpgrade` live in `CardHelpers.php`; the object-aware trait check is **`TraitContains($obj,$trait)`** (`_SWUUnitHasTrait` was deleted — don't re-add it). Because file names are `TitleSubtitle` (not derivable from the CardID), resolve a card by grepping its registration key (`grep -rln "'<CID>'" SWUSim/Custom/cards/`) or via `cards/_index.generated.php` (regen with `php SWUSim/DevTools/regen-card-index.php` if stale). **Always grep/scan recursively (`SWUSim/Custom/` or `…/**/*.php`), never a bare `Custom/*.php`** — the latter misses every split card.

If the effect already exists (e.g. SOR_172 Open Fire was already complete in `cards/sor/OpenFire.php`), just add a test (if none) and mark Done — don't re-implement. Note the **dead-code caveat** from the passive row: a `case` in the GA-fallback `ObjectCurrentPower`/`HP` (~line 10555 of `GameLogic.php`) is NOT live.

> **⚠ The INVERSE trap — a `GeneratedAbilityStubs.php` entry is NOT evidence the card is implemented.** The stub only *declares* that the card has a WhenPlayed/OnAttack/WhenDefeated/etc. trigger (the generator detected trigger text); the actual effect lives in a `$whenPlayedAbilities`/`$onAttackAbilities`/`$whenDefeatedAbilities`/`$customDQHandlers` / `OnPlayEvent` handler that may never have been written. A card with a stub but **no** matching Custom handler **silently no-ops in-game** — the trigger fires and dispatches to nothing. So `HasWhenPlayedAbility(CARD)` returning true means "wired to fire," not "implemented." Always confirm via the four-ability-file grep above; an empty result with a non-empty stub = genuine unimplemented work, not a done card. (This is how a whole band of SOR cards — tier-classified but never batched — was found silently broken.)

After triage: mark every no-op / already-done card per Step 4, and carry only the cards with genuine *unimplemented* behavior into Step 1.

**Modifying an ALREADY-implemented card (behavior/UX change, not net-new).** Some tasks aren't "implement a blank card" — they tweak a card that already works (e.g. "show the opponent's hand only when the discard auto-resolves"). The triage above still applies (grep the four ability files to find the existing handler), but two extra habits matter:
- **Capture a baseline regression BEFORE writing or editing any test** (`curl …/zzRegressionSWUSim.php`, note pass/fail counts and any already-red tests). A modification routinely touches existing passing tests and can sit next to a pre-existing failure — the baseline is what lets you tell *your* new RED from breakage that was already there. (Real case: a sibling test was already failing for an unrelated reason; without the baseline it'd have looked like collateral from the change.)
- **The card usually already has tests** — read them first; your change may need to *edit* their WHEN/EXPECT, which trips the "ask before modifying confirmed tests" rule. Surface those diffs at the Step 2 review gate.

---

## Step 1 — Research the Cards

For each card in the batch, look up its data:

```bash
# 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:

```bash
awk '/\$costData = array \(/,/^\);/'  SWUSim/GeneratedCode/GeneratedCardDictionaries.php | grep "'CARD_ID'"
awk '/\$powerData = array \(/,/^\);/' SWUSim/GeneratedCode/GeneratedCardDictionaries.php | grep "'CARD_ID'"
awk '/\$hpData = array \(/,/^\);/'    SWUSim/GeneratedCode/GeneratedCardDictionaries.php | grep "'CARD_ID'"
# For UPGRADES, the +power/+HP modifier lives in separate arrays — read these, not powerData/hpData:
awk '/\$upgradePowerData = array \(/,/^\);/' SWUSim/GeneratedCode/GeneratedCardDictionaries.php | grep "'CARD_ID'"
awk '/\$upgradeHpData = array \(/,/^\);/'    SWUSim/GeneratedCode/GeneratedCardDictionaries.php | grep "'CARD_ID'"
```

**⚠ PILOTS contribute their `upgradePower`/`upgradeHp` to the host, NOT their unit power/hp (JTL Phase 17 gotcha).** A Piloting card has BOTH unit stats (powerData/hpData, used when played as a unit) AND upgrade stats (upgradePowerData/upgradeHpData, used when attached as a pilot) — and the two usually differ (JTL_046 is a 3/2 unit but a +2/+0 pilot). When a test attaches a pilot to a Vehicle, the host's expected power = host base + pilot's **upgradePower** + any Experience/Grit, never the pilot's unit power. Seeding the pilot's unit power into a POWER expectation reddens the test (real case: JTL_046 host expected 6, actual 5 — pilot upgradePower 2 not unit power 3).

**More JTL Phase 11-21 test gotchas (folded at the autonomous→pair-programmed retro):**
- **Indirect damage to a player auto-resolves to the base ONLY when that player controls no units** (1 spec → no popup) — give the damaged player NO units and assert `P{n}BASEDMG:X`. If they DO control units it's a cross-player `MZSPLITASSIGN`: use `WithActivePlayer: 1` (not `P1OnlyActions`) and answer as the damaged player `P2>AnswerDecision:myBase-0:N` (assigner's own frame, comma-sep `mz:amt`). whenPlayed/whenDefeated **AND mid-combat onAttack** indirect-splits all work cleanly now (the old JTL_227 "onAttack indirect-split mis-resolves" bug was fixed by the session-50 indirect-funnel rework — the assignment rides the decision PARAM so it survives the request boundary; guard: `SuperheavyIonCannon227_OnAttack_ExhaustIndirect`).
- **The ability-stub generator misses some dual-mode / Piloting cards** (`HasOnAttackAbility`/`HasWhenPlayedAbility` absent → the trigger silently no-ops even with a handler registered). Confirm the stub exists; if not, hand-add the `case 'CARD':` to the right `Has…Ability` switch in `GeneratedAbilityStubs.php` and note it (JTL_187 Bossk unit-side On Attack, JTL_210 Mandalorian as-unit WhenPlayed).
- **A Piloting card used as a generic "played a card" fixture prompts a Unit/Pilot OPTIONCHOOSE** when a friendly Vehicle host is present (or even no host in some flows) — it desyncs your WHEN. Use a NON-Piloting card (e.g. a vanilla keyword unit like SHD_147) when you just need "played a card of trait X" (JTL_186 test fix).
- **Off-aspect cost stacks per pip:** a double-same-pip card (e.g. Cunning/Cunning) is **+4** off-aspect — give ≥ printed cost + 4 resources, or match the leader/base aspect, or the play silently fails and the rest of the WHEN misaligns (JTL_210).
- **Per-card regroup-phase effects** (when-regroup-starts / ready-step taxes) go in `RegroupPhaseStart` (drain-loop on a marker, modeled on `SWU_SNEAK_DEFEAT`) or via the `SWUQueueFalconRegroupTriggers` pattern; tests reach the ready step with `P1>Pass`, `P1>ResourcePass`, `P2>ResourcePass` and need 6-card decks.

**JTL Phase 22-24 gotchas (folded at the end-of-run retro):**
- **⚠ Inside the ATTACKER's `OnAttack`, a *mandatory* multi-target `MZCHOOSE` (`SWUQueueChooseTarget`) auto-resolves to nothing and presents NO decision.** `OnAttackTrigger` restores `$playerID` to its pre-trigger value before `MZCountChoices` runs, so the count comes back 0 and the choice is silently skipped (the answer never lands — your WHEN's `AnswerDecision` then mis-feeds the next decision). **Use `SWUQueueMayChooseTarget` (MZMAYCHOOSE) instead** — it's the proven in-combat OnAttack choose (JTL_151 Red Five). **MZMULTICHOOSE also works** in OnAttack (JTL_018 Kazuda's "any number"). Single-target `SWUQueueChooseTarget` is fine because it emits `PASSPARAMETER` (auto, no answer). Symptom: a unit-target effect that works fine as a `WhenPlayed` silently no-ops as an `OnAttack`, and a `P1HASDECISION` probe after the attack shows none pending. (Cost the whole JTL_250 Sabine's Masterpiece debug — Vigilance/Command/Aggression branches.) Reference: JTL_250, JTL_151. **⚠ The skip applies ONLY to a decision queued DIRECTLY in the OnAttack closure** (because `OnAttackTrigger` restores `$playerID` right after the closure returns, before `MZCountChoices`). A relative-mzID `MZCHOOSE` queued from a *later CONTINUATION* (a `CUSTOM` handler reached mid-combat — e.g. step 2 of a multi-pick flow) is SAFE: `ExecuteStaticMethods` does NOT restore `$playerID` around a CUSTOM, so as long as that handler leaves `$playerID` = the decider, the count is correct. Pattern (JTL_056 Hondo "move an upgrade" On Attack): the FIRST pick is a `MZMAYCHOOSE` in the closure (fine), and the destination `MZCHOOSE` is queued from the `MOVE_UPGRADE` continuation (fine, mandatory MZCHOOSE and all). So you don't have to force every mid-combat pick to MAY/MULTI — only the closure-level one.
- **"Take an extra action"** (JTL_018 Kazuda) = finish the action via **`SWUAfterActionExtra($player)`** (cleanup + `SetSWUVar('PASS','0')`, **no `SWUSwapTurnPlayer`**) so the same player acts again — vs `SWUAfterAction` which swaps. Test it by having the player take a SECOND action right after (e.g. attack) and asserting it landed (`P2BASEDMG>0`); if the turn had swapped, the second action would be illegal.

**LOF Phase 12-14 gotchas (folded at the autonomous→pair-programmed retro):**
- **⚠ Some units are hard-coded "can't attack" in `BeginSWUAttack`** — LOF_044 (Loth-Wolf, never), LOF_063 (Oggdo Bogdo, only while damaged), JTL_059 (never). When you need a unit as an **attacker** fixture (esp. a Creature for "attack with a Creature"), check it isn't one of these — a no-op attack reads as "my handler is broken" (cost a Pounce/LOF_224 iteration). `LOF_044`'s dictionary text only shows "Sentinel" — the can't-attack rule is engine-side, not in the text.
- **Event/leader-driven attacks (`BeginSWUAttack` from a DQ handler) with `noBases=false` queue an unanswered target `MZCHOOSE`** whenever both an enemy unit AND the base are legal targets (2+ targets → no auto-resolve). The test must supply `AnswerDecision:theirGroundArena-N` (or set up exactly one legal target — e.g. `noBases=true` + one enemy unit — to auto-resolve, like LOF_124). Symptom: handler provably runs (probe shows it) but zero combat happens.
- **Leader-action after-action convention:** the leader closure exhausts the leader; **the `#0` CUSTOM continuation must call `SWUAfterAction` itself** on BOTH the decline and effect paths — EXCEPT when it delegates to something that already owns the after-action: `BeginSWUAttack` (owns it once it actually attacks → call `SWUAfterAction` only on the decline branch, mirror JTL_017#0) and `DISCOUNT_PLAY_FROM_HAND` (owns it via ActivateCard/decline — queue it with NO trailing `SWU_AFTER_ACTION`). To reuse a **universal handler** (DEAL_UNIT_DAMAGE etc.) in an action, append a trailing `CUSTOM "SWU_AFTER_ACTION"` decision (LOF_134/LOF_178 pattern). For a nested **play-from-hand** inside an action (LOF_016/LOF_018), wrap `ActivateCard` with `$gTurnPlayer`/`PASS` save-restore then call `SWUAfterAction` once (the inner play's own swap is neutralised — LOF_076 pattern).
- **`$gPlayGrant{TurnEffect,Shield,Exp}` entry seams** (GameLogic.php, consumed once at unit entry right after `$newCardMzID`): set before `ActivateCard` to give the entering unit a phase keyword / Shield / Experience. Composes — LOF_225 "play a unit; Hidden + Exp + Shield" sets all three at once. Add a new seam there for any other "play X and give it Y" grant.
- **Leader/Force-cost gates:** a leader Action whose cost includes "use the Force" needs an entry in **`$leaderActionForceCost`** (gated in `SWULeaderActionAffordable`) so it's unavailable without the Force token; the closure then calls `UseTheForce()`. Resource-cost leaders go in `$leaderActionResourceCosts`. `UseTheForce` now bumps a per-phase `SWU_FORCE_USED_THIS_PHASE` counter (LOF_007 Epic deploy reads it); per-phase attack/play flags follow the `SWU_ATTACKED_<TRAIT>` / `SWU_PLAYED_<X>` family (add one in `BeginSWUAttack` / `ActivateCard` + clear at `RegroupPhaseStart`).
- **Defer these card classes (need pair-programmed seams, not Hard-tier grind):** "an opponent chooses…" cross-player input mid-action (LOF_177, LOF_015), continuous-prevention "can't be defeated / prevent N damage" passives (LOF_043, LOF_220), interactive non-active-player decisions during combat (OnDefense combat-pause — LOF_067/047/252), and on-attack multi-bounce/grant combat seams (LOF_205). Note them with a `⚠ DEFERRED` one-liner naming the missing seam; don't burn a long session forcing them.
- **DSL/fixture notes:** `discardCardIds`/`deckCardIds`/`WithP1Force:true` are CommonSetup opts; `WithP1Deck:` lines are top-first (assert `P1DECKTOPCARD:`); leader tests use explicit `P1LeaderBase: LEADER/BASE` + `SkipPreGame: true` and assert `P1LEADER:EXHAUSTED`/`:EPICUSED`, `P1NOFORCE`/`P1HASFORCE`; a played **event sits in its caster's discard** so discard-count includes it (don't expect 0 after "return a card from discard"); `MZMULTICHOOSE` param is `min|max|list` (`2|2|…` = exactly two, answer `mz0&mz1`).

**LOF Phase 15-21 gotchas (new-mechanic seams; folded at the end-of-run retro):**
- **Reactive windows = hook the core helper + queue a YESNO/continuation.** "When you use the Force" reactions hook `UseTheForce` (`_SWUQueueUseForceReactions`); "repeat the next When-Played" hooks `OnWhenPlayed` (mirror of JTL Thrawn's When-Defeated reuse). **Guard recursion:** the reaction must not re-enter the same hook — LOF_260 re-creates the Force with `TheForceIsWithYou` (NOT `UseTheForce`); LOF_197 `RemoveGlobalEffect`s its flag BEFORE re-dispatching; LOF_105's keyword-mirror EXCLUDES other copies of itself.
- **Variable-count selects without new client UI = a self-re-queuing continuation.** "Exhaust any number with combined power/cost ≤ N" (`_SWUCombinedBudgetOffer` + `SWU_BUDGET_EXHAUST`) and "pay up to N for a per-resource effect" (LOF_255) both loop: do one pick/payment, subtract from the budget, re-`SWUQueueMayChooseTarget`/`YESNO` with the reduced budget, stop on decline / empty / budget<0. No MZSPLITASSIGN, no new decision type. Carry the running budget + UID through the handler token (`HANDLER|budget|metric|…`); re-resolve the unit by UID (`SWUFindMzByUID`) each round since mzIDs shift.
- **Always grep for an existing marker/helper before building a "new" seam.** Temporary take-control (LOF_189) reuses `SWUTakeControlOfUnit` + the `TEMPORARY_STEAL` turn-effect marker (SOR_224) — RegroupPhaseStart already returns those to their owner; do NOT reuse JTL_235's `SWU_JTL235_RETURN_` (that bounces to HAND). Look-at-opponent-hand + discard (LOF_226) = `SWULookAtOpponentHand($p, $filter)` + `DISCARD_FROM_OPP_HAND` + `SWUQueueShowOpponentHand` (SOR_201). Name-a-card (LOF_204) = the `NAMECARD` decision type (SOR_185); the answer is the card TITLE string (`P1>AnswerDecision:Zeb Orrelios`), read it in a CUSTOM continuation (safe vs the OnAttack `$playerID`-restore gotcha).
- **Cross-player "opponent chooses/decides" DOES work now — stop blanket-deferring it.** Queue the decision for the opponent (`AddDecision($opp, "YESNO"/"MZCHOOSE", …)`) from a **CUSTOM continuation** (NOT inline from a trigger closure — `DispatchTrigger`/`OnAttackTrigger` restore `$playerID`, the CUSTOM path doesn't); encode the caster in the handler token, set `$playerID` to whichever player owns the next step. The test answers as that player (`P2>AnswerDecision:NO` works even under `P1OnlyActions`). `SWUOpponentChoosesOwnUnit($caster, $nonLeader, $tooltip, $handler)` is the ready-made "opponent picks one of THEIR units" seam (GameLogic.php). LOF_222 proves the YESNO form; this un-blocks the old LOF_177/LOF_015 "opponent chooses" deferrals.
- **A non-pilot upgrade's When-Played ability receives the HOST as `$mzID`.** `CollectWhenPlayedAsUpgradeTriggers` routes a `HasWhenPlayedAbility` upgrade through the `WhenPlayed` window with mzID = the host unit, so register `$whenPlayedAbilities["UP:0"]` and read the host via `GetZoneObject($mzID)` (e.g. `CardTitle(...) === 'Qui-Gon Jinn'` for LOF_201's "if attached unit is X").
- **Doubling a VALUE keyword (LOF_186 "Raid is doubled") must double the GRAND total, not the conditional slice.** `GetConditionalKeyword_Raid_Value` only contributes the conditional part; the generated `GetKeyword_Raid_Value` = `base_max + conditional`. To make the final 2×, have the conditional function add `(base_max + amount)` — recomputing `base_max` with the same max-branch logic (`max(printed, TurnEffectValue, granted?1)`). Keeps it in hand-editable KeywordEffects.php (no generated-file/generator edit).
- **"Loses all abilities for this round"** reuses the LostAbilities token system: register the source CardID in `$turnEffectRegistry` as `['kind'=>'LOSE_ABILITIES']`, add it to the `LostAbilities()` check in `KeywordEffects.php`, and `AddTurnEffect($mz, 'CARD')`. Test via an innate-keyword fixture (SOR_063 Sentinel) + `P{n}{...}ARENAUNIT:idx:NOTKEYWORD:Sentinel`.
- **Starting-hand-size bases** (JTL_021/028) live in `CreateGame.php::QueuePregameSetup` via shared helpers (`SWUStartingHandModifier`/`SWUBaseSuppressesMulligan`); the harness bypasses pregame (Option B) so extend its `_buildInitialState` drew-count to call the SAME helper, then test through the **non-SkipPreGame** flow (`P{n}HANDCOUNT:X`). Mulligan-suppression isn't exercisable there (Option B doesn't simulate the mulligan DQ) — verify by inspection.
- **Generator quote-guard for dual own+granted triggers:** a card whose text has BOTH an own and a granted (quoted) `"On Attack:"` (e.g. JTL_018's deploy side `gains: "On Attack:..."`) was wrongly excluded by `strpos($combined,'"On Attack:')===false`. The generator now uses `preg_match('/(?<!")On Attack:/', $combined)` (an UNQUOTED occurrence). Same fix would apply to the When-Defeated/On-Defense quote-guards if a dual card surfaces there; hand-add the `case` to the stub file too (the generator isn't re-run mid-session).

**Set-validation gap-fix lessons (folded card-by-card while clearing the LOF deferral backlog):**
- **A leader's DEPLOYED side has its own abilities in `$deployTextData` — Method-B catches these as "stub w/o handler" even when the leader-side Action is done.** The deployed unit's On Attack just needs `$onAttackAbilities["LEADER:0"]` (same registry as any unit; the deployed leader's CardID is the key). Use **`MZMAYCHOOSE`** for multi-target picks (the OnAttack mandatory-MZCHOOSE skip), and combat owns the after-action (no `SWUAfterAction`). Test by `P1>DeployLeader` (free — threshold only, resources persist) then `P1>AttackGroundArena`, with `WithInitiativePlayer:2` + `WithInitiativeClaimed:true` so P1 acts freely. ⚠ A deployed "**defender gets −X/−0**" (LOF_014) must add `SWU_DEF_DEBUFF_N` **synchronously in `ExecuteSWUAttack`** (like SOR_212), NOT via the deferred OnAttack trigger — `SWUCombatDamage` reads/consumes the marker before the trigger fires. ⚠ A deployed leader with **Shielded** masks its own counter-damage observably (the shield absorbs the whole counter regardless of size — `SWUConsumeShieldToken` has no `>0` guard), so a −X/−0 debuff is unobservable on it; verify via the leader-side/SOR_212 test instead.
- **"Play those from your discard for FREE this phase" is NOT Hard — it already exists.** Discard the card with the `TPF` modifier: `SWUAddToDiscard($p, $cid, 'DECK'|'PLAY', 'TPF')`. `TPF` = "this-phase free play-from-discard" (cleared by `SWUClearDiscardModifiers` at the phase turn; `TPP` = play-at-cost; `OTPF`/`OTPP` = from an *opponent's* discard). The player then uses the existing **`PlayFromDiscard`** action (DSL: `P{n}>PlayFromDiscard:liveIdx`). Combined-cost search = `_topDeckSearchBegin($p, $n, $filter, "cost:N", $finalize)`; the finalize can server-side-validate the budget (greedy keep-while-`runningCost ≤ N`) before discarding the kept and `_topDeckPutRemainingToBottom` for the rest. (LOF_117 Sifo-Dyas: was deferred "Hard"; the whole thing is ~15 lines.) **Lesson: before deferring a card as Hard, grep for the affordance — `TPF`/`Modifier`, `PlayFromDiscard`, `SWUPlayDiscardUnitDiscounted`, `_topDeckSearchBegin` — the seam is often already built.**
- **Per-source/per-target continuous aura (LOF_191 "chosen unit gets +1/+0 + Saboteur while this in play"):** link source→target with a global effect `SWU_<CARD>_{srcUID}_{tgtUID}` (mirror JTL_047's `SWU_YULAREN_{uid}_{kw}`). A `_SWU…HasBuff($obj)` helper loops the controller's in-play SOURCE cards and checks the link to `$obj`'s UID — so the buff ends automatically when the source leaves play (loop finds no source); no leave-play cleanup needed (UIDs never repeat). Hook it in `ObjectCurrentPower` (stat) and the relevant `HasConditionalKeyword_X` (keyword).
- **⚠ A unit's When-Played that offers HAND cards must `DecisionQueueController::CleanupRemovedCards()` BEFORE building the `myHand-N` list.** The just-played unit is still in the hand array (removed flag) when its When-Played fires, and it's cleaned up before the player answers — so an offered `myHand-N` index shifts and the chosen mz resolves to NULL at handler time (symptom: the effect silently no-ops; probe shows `up=NULL`). Compact first, then index. (LOF_150 Cin Drallig.)

**IBH Phase 1-9 lessons (autonomous set; folded at the end-of-run retro):**
- **⚠ An EVENT is still physically in the caster's hand during `OnPlayEvent`** — it isn't discarded until `FINISH_PLAY_CARD` (block 10), which runs AFTER the block-1 effect. So a "discard / put / choose **a card from your hand**" event (IBH_074 I Want Proof) sees ITSELF in `ZoneSearch("myHand")` and would wrongly offer it. Exclude one instance of the playing `$cardID` from the targets (`foreach $hand … if (!$excluded && CardID===$cardID) {$excluded=true; continue;}`). (Contrast LOF_150: a *unit's* When-Played hand list needs `CleanupRemovedCards` first; an *event's* does not — the event lingers by design.)
- **"On Attack: deal N to a base" → deal to the ENEMY base directly (`SWUDealDamageToBase(N, OtherPlayer($p))`), NOT a 2-base `MZCHOOSE`.** A `DEAL_BASE_DAMAGE` choice queued from an OnAttack continuation survives ONLY when there's a combat pause (the attacker hit a *unit*); when the attacker hits the *base* directly there's no pause, so the OnAttack `$playerID`-restore drops the mandatory pick and the rider silently no-ops (base takes combat only). Enemy-base is the only meaningful target for an attacker anyway (IBH_006 Y-Wing, IBH_053 Vader deployed; mirror LOF_163).
- **Test a unit's WhenDefeated by having IT attack into lethal (attacker self-defeat), NOT by an enemy killing it.** A DEFENDER defeated in cross-player combat leaves its `RESOLVE_TRIGGER|WhenDefeated|…` *pending* in the regression (the active player is the attacker, so the defender's queue isn't flushed before EXPECT) → the effect reads as not-firing. Drive it as `P1OnlyActions` + the IBH unit `AttackGroundArena:0:…` into a bigger body so it dies to the counter; P1 is active and the WhenDefeated resolves inline (IBH_015 Tauntaun, IBH_082 Ozzel). Pair with the existing skill #9 note (WhenDefeated collects after cleanup → survivors reindex).
- **To force a base attack with enemy units on the board, the WHEN target token is `BASE` — `AttackGroundArena:0:BASE`** — NOT `:theirBase-0` (which, with enemy units present, is ignored and the unit attacks an enemy instead). (Reconfirmed across IBH OnAttack tests; already in the GIVEN/DSL notes.) **⚠ Likewise the UNIT target is a bare INDEX, not a full mzID: `AttackGroundArena:0:1` attacks the defender at idx 1.** Writing `:theirGroundArena-1` `intval()`s to **0** → it silently hits idx 0 (the WRONG unit), and the attack still "works" so only a value mismatch reveals it. The parser only special-cases `BASE` / `S<n>` (cross-arena space) / `G<n>` (cross-arena ground); everything else is `intval`'d to a same-arena index. **Assert `…UNIT:<idx>:CARDID:<id>` at the attacked index** so a mis-resolved target reds the test loudly. (Cost 2 debug cycles on ASH_062 + the Grogu tests — the `-N` suffix looked like an index but parsed to 0.)
- **Heavy intra-set reprints wire identically:** group duplicate CardIDs (same name/effect) into one batch, register them on the same closure (`$X["IBH_006:0"] = $X["IBH_024:0"] = $X["IBH_032:0"] = fn`), and write one full behavioral test for the canonical + a one-line reprint guard per duplicate. ~30 IBH needs-work IDs collapsed to ~27 unique effects this way.
- **Force an upgrade onto a SPECIFIC host for free** (LOF_150 "play a Lightsaber on this unit for free"): `_SWUFinalizeUpgradeAttach($p, $upgradeCardID, $upgradeHandMz, $hostMz, 0, ignoreCost:true, isPilot:false)` — host is forced (no choice), cost ignored, and it still fires the upgrade's own `whenPlayedAsUpgrade`. To honor a host restriction anyway, filter the offer with `in_array($hostMz, SWUGetUpgradeValidTargets($p, $upgradeCardID), true)`.
- **"Deals combat damage = X instead of its power" for one attack** (LOF_206 "damage equal to its remaining HP instead of power"): register an attack-duration MARKER in `$turnEffectRegistry` (`'SWU_HP_AS_DAMAGE' => ['kind'=>'MARKER','duration'=>SWU_DUR_ATTACK,…]`), `AddTurnEffect($attackerMz, 'SWU_HP_AS_DAMAGE')` in the handler right before `BeginSWUAttack`, then in `SWUCombatDamage` just after `$attackPower = ObjectCurrentPower(...)` + Raid, override `$attackPower` when `in_array('…', $attacker->TurnEffects)`. Marker auto-expires at attack-end via `SWUExpireTurnEffects(SWU_DUR_ATTACK)` — no cleanup. "Remaining HP" = `ObjectCurrentHP - Damage`, measured at damage-deal (pre-counter; combat damage is simultaneous). For a "granted attack with a friendly X unit" action, clone **JTL_146** (scan both arenas for ready trait-X units → `SWUQueueChooseTarget` → continuation does the marker + `BeginSWUAttack`; combat owns `SWUAfterAction`).
- **Per-instance trait suppression — "each enemy unit loses the <Trait> trait this phase"** (LOF_033 Nameless Terror On Attack): `HasTrait` is CardID-keyed (static dictionary) so it CANNOT do per-instance. Use the existing object-aware **`TraitContains($obj, $trait)`** (in GameLogic, next to HasTrait — it already returns `false` when `in_array('NO_TRAIT_'.strtoupper($trait), $obj->TurnEffects)`, and otherwise honors upgrade grants / `HasTrait($obj->CardID, $trait)`; `_SWUUnitHasTrait` was the old name for this and is **deleted** — don't re-add it). Register `NO_TRAIT_FORCE` as a phase-duration MARKER (`['kind'=>'MARKER','label'=>…]` — phase is the registry default). The handler **snapshots** the affected units in play now (`ZoneSearch('theirGroundArena'/'theirSpaceArena')`) and `AddTurnEffect($mz, 'NO_TRAIT_FORCE')` each — units entering later this phase are NOT marked (it's a per-instance marker, not a continuous aura). **General rule the user gave: any "units lose/gain X this phase" effect counts only the units in play when it resolves.** Then route the trait CONSUMERS: because the fallback is identical until a marker exists (and the marker only ever lands on specific units), you can safely `replace_all` every **object** read `HasTrait($obj->CardID…, 'Force')` → `TraitContains($obj, 'Force')` across files (~25 sites). ⚠ NEVER route a bare-CardID read (`HasTrait($cardID/$c/$cid, …)`) — the helper would `GetZoneObject(a CardID string)` → null → wrong; those are hand/deck/play-time reads that correctly stay `HasTrait` (trait-loss is in-play-only).
- **"Return a unit to its owner's hand; then its owner may play it for free" — cross-player bounce + free-replay** (LOF_185 Baylan Skoll): `SWUBounceUnit($player, $mz)` returns the unit to `$obj->Owner`'s hand (APPENDED — defeats its upgrades, rescues captives, returns bool). The replayed card is therefore the LAST in the owner's hand: `$idx = count(GetHand($owner)) - 1`. Hand the OWNER the optional free play (LOF_015 cross-player pattern): set `$playerID = $owner`, `AddDecision($owner,'YESNO','-',1)` + `AddDecision($owner,'CUSTOM',"H|myHand-{$idx}",1)` — `myHand-{idx}` resolves to the owner because the continuation runs with `$player=$owner` (works even when owner == opponent). The free play itself is `ActivateCard($owner, $handMz, true)` wrapped in the **JTL_089#1 turn/PASS save-restore** (`$savedTP=$gTurnPlayer; $savedPass=GetSWUVar('PASS','0'); …; $gTurnPlayer=$savedTP; SetSWUVar('PASS',$savedPass)`) so the nested play doesn't double-advance the outer action; When-Played fires on this path. Assert a fresh-copy tell (e.g. pre-damage the unit, expect `DAMAGE:0` after replay). ⚠ **Test gotcha:** a double-pip OFF-aspect card (LOF_185 = Cunning,Villainy) costs +2 per off-aspect pip — if the test's `myResources` can't cover the penalty the play silently fails and the When-Played never fires (probe shows nothing). Bump resources or match the CommonSetup aspects.
- **Source-conditional damage prevention — "if a friendly <X> would deal damage to a friendly unit, prevent it"** (LOF_108 Malakili, the Bendu combo): the ability-damage funnel `SWUDealDamageToUnit($unitMz, $amount, $player)` does NOT know the source card, so it gained an optional 4th param `?string $sourceMzID = null`. AoE/multi-unit damage handlers pass their own mz as the source (e.g. Bendu LOF_170: `SWUDealDamageToUnit($mz, 3, $player, $mzID)`); the funnel then runs `_SWULof108PreventsCreatureDamage($sourceMzID, $unit)` and **returns early** (no Damage/anim/defeat) when the source has the gating trait (Creature), source.Controller == target.Controller (friendly→friendly), and that controller has the source card (LOF_108) in `GetField`. Only callers that PASS source get checked — fine here because combat damage is a separate path and friendly units can't attack friendly units (user-confirmed), so the ability funnel is the only relevant case. Default-null keeps all other callers unchanged.
- **Combat-pause: a DEFENDER's On Defense reaction that must resolve BEFORE combat damage** (LOF_047 give-Exp, LOF_067 Force→attacker -2/-0 — "when this unit is attacked, before damage is dealt"). The On Defense seam already exists (`HasOnDefenseAbility` stub → `$onDefenseAbilities["X:0"]`, dispatched under the defender's controller). BUT historically the defender's reaction decision (a non-active-player YESNO) raced and lost: `ExecuteStaticMethods` drains ONE player's queue fully, so the active player's block-20 `SWU_TRIGGER_RESUME` committed `SWUCombatDamage` before the defender's block-1 YESNO was ever processed. The fix (in GameLogic, generic for the whole cluster): (1) `OnDefenseTrigger` sets `SWU_PENDING_DEF_REACTION='1'` after dispatching (only true On Defense triggers — On-Attack opponent decisions like indirect-damage/Watto do NOT set it, so their old timing is preserved); (2) the `SWU_TRIGGER_RESUME` empty-stack COMBAT branch, when that flag is set AND `_SWUPlayerHasBlockingDecision($other)` (the non-active player still has a non-static/input decision), hops the resume onto the DEFENDER's queue instead of committing damage — so combat waits until the reaction resolves; (3) the commit queues `SWUCombatDamage|aMz|tMz|uid|{activePlayer}` onto the CURRENT drain's `$player` (not always the active player) and the handler re-derives the attacker frame from `parts[3]`, so damage runs in whichever drain commits it (the defender's, when paused) without stranding. Flag is cleared at attack start (ExecuteSWUAttack) and on commit. To ADD an On Defense card now, just register `$onDefenseAbilities["X:0"]` (+ hand-add the `case` to `HasOnDefenseAbility` if the generator hasn't been re-run) — the pause is automatic. Test: defender reacts (`P2>AnswerDecision:YES`) and the effect (Exp/+HP, attacker debuff) is reflected in the SAME attack's damage/counter numbers.
- **Base-damage reaction — "when damage is dealt to your base: …"** (LOF_252 The Daughter, may-use-Force → heal 2). Hook a collector in `SWUDealDamageToBase` (CombatLogic) right after the base's `Damage` is incremented — this is the ONE central base-damage point (combat line ~890, Overwhelm overflow ~1041, and `OnDamageBase` for effect damage all route through it). The reaction is owned by the BASE OWNER (`$targetPlayer`) — often the non-active player in combat — but it is POST-damage, so NO combat-pause is needed: it sits on their queue and resolves after the damage event (contrast the pre-damage On Defense cluster). Guard with `$damage > 0 && base.Damage < CardHp(base)` (skip if the base was just defeated). `SWUQueueMayUseTheForce($targetPlayer, …)` no-ops when they don't hold the Force; the handler heals via `OnHealBase($p, $p, 2)`. Test: attack the base (`P1>AttackGroundArena:0:BASE`), then `P2>AnswerDecision:YES` — assert net `P2BASEDMG` = dealt − healed.
- **"When you draw THIS card during the action phase: …"** (LOF_148). Hook in `DoDrawCard` after it builds `$drawn` (the drawn cards' hand mzIDs). `_SWUOnPlayerDrew($p, $count)` already exists but only gets a COUNT (for "any draw" reactions like JTL_111) — for "draw this specific card" you need the identities, so add a parallel `_SWUOnDrawLof148($p, $drawn)` that scans `$drawn` for the CardID. Gate on `GetCurrentPhase() === 'MAIN'` (the action phase; the regroup draw is a different phase) plus the card's condition. **Leader/base aspect condition** ("control an Aggression leader or base"): iterate `[GetLeader($p), GetBase($p)]` and test `strpos(CardAspect($c->CardID), 'Aggression') !== false`. **"deal 2 to a unit and 2 to a base"** = the JTL_010#0 chain: `SWUQueueChooseTarget($p, units, …, "H#1")` → handler deals via `SWUDealDamageToUnit($lastDecision, 2, $p)` then `SWUQueueChooseTarget($p, ['myBase-0','theirBase-0'], …, "DEAL_BASE_DAMAGE|2")`. **Test it** by playing a "When Played: Draw a card" unit (SOR_111) with the card seeded on top via `WithP1Deck: LOF_148` (first deck entry = top) while on an Aggression CommonSetup (`rrk/…` gives P1 an Aggression base+leader).

**ASH Phase 7-8 lessons (deck/hand + bounce/control/targeted-defeat; folded at the autonomous→pair-programmed retro):**
- **⚠ Multi-card `WithP1Deck`/`WithP2Deck` use BRACKET-space syntax, NOT comma:** `WithP1Deck: [SOR_095 SOR_046]` (space-separated inside `[ ]`). A comma list `SOR_095,SOR_046` is parsed as ONE invalid CardID → deck size 1 → a "draw 2" lands only 1 and the test is silently off by a card (cost a cycle on ASH_185). Single card stays bare (`WithP1Deck: SOR_095`).
- **⚠ `SWUDiscardCards($player, N)` makes the OPPONENT discard, and with >1 card in their hand it queues an OPPONENT choice that does NOT auto-resolve under `P1OnlyActions`** — the discard never completes and the test reads as "the effect didn't fire" (ASH_162 opp-discards-on-base-hit). Seed the opponent's hand to **exactly 1 card** (`theirHandCardIds:SOR_095`) so the discard auto-resolves, or drive the opponent's pick explicitly. Same shape as IBH_082 ("auto when they hold exactly 1").
- **⚠ `SWUQueueDefeatUpgrade(..., may:true, min:0)` (the "may" path) stages a SECOND `myTempZone-N` pick even when the host has a single matching upgrade — it does NOT auto-defeat.** Tests must answer BOTH the host pick AND `myTempZone-0` (ASH_165 cost a cycle). Only `min:1` auto-defeats a lone upgrade (one answer). **Friendly-scoped defeat** ("defeat a FRIENDLY upgrade" — ASH_171/ASH_246): `SWUQueueDefeatUpgrade`/`SWUGetUnitsWithUpgrades` span BOTH sides (the filter is upgrade-property only), so collect friendly hosts yourself, `StoreVariable("DefeatUpgParams","1|1|")` + `StoreVariable("DefeatUpgThen", "<then>")`, queue the host pick (PASSPARAMETER if 1, else MZCHOOSE/MZMAYCHOOSE) + `AddDecision(CUSTOM,"DEFEAT_UPGRADE")`. `min=1` → a lone upgrade auto-defeats (no temp-zone answer) and the `DefeatUpgThen` continuation fires after the defeat (gets the host mzID; pass extra state via a separate DQ variable, e.g. ASH_171 stores `ASH171SelfUID` to ready the just-played unit).
- **An UPGRADE's "When Played" fires via the `CollectWhenPlayedAsUpgradeTriggers` FALLBACK** (when the card has only `HasWhenPlayedAbility`, no `WhenPlayedAsUpgrade`): the closure `$whenPlayedAbilities["X:0"]($player, $mzID)` receives **`$mzID` = the HOST unit's mz**, not the upgrade. Use it to read the host's other upgrades (ASH_199 "return any number of OTHER upgrades on attached unit" — stage them in TempZone for an `MZMULTICHOOSE`, exclude the card's own CardID + tokens, `SWUReturnUpgradeToHand($hostMz, $cid, $player)` each). In a test, playing an upgrade from hand auto-attaches when there is exactly ONE valid host (no host-choice decision).
- **Debug trick — surfacing a computed value through the regression:** `AddGameLogEntry($type, $text, $visibility)` — the message must go in **arg 2 (`$text`)**; `LOGCONTAINS:`/`LASTLOGCONTAINS:` match the log entry's TEXT (`parts[2]`), so a message put in arg 1 (`$type`) is never found. To PRINT a computed value, log it as text then add `LASTLOGCONTAINS:ZZ_NOMATCH` — the failure message echoes the actual last-log text (e.g. `'ASH163DBG cost=2 tgcount=0 ld=myHand-1'`, which revealed a wrong fixture, not a code bug).
- **A "deal N to / affect a unit costing MORE/LESS than X" filter that silently finds no target → suspect the FIXTURE's cost FIRST, not the handler** (ASH_163: SEC_080 is cost **2**, equal-not-greater, so "costs more than the discarded 2-cost card" correctly excluded it — I'd assumed cost 3). Re-confirms the per-fixture stat rule, sharpened for cost-threshold filters: verify the candidate's `costData` before concluding the comparison logic is broken. Note `_SWUIsUpgraded($obj)` is the canonical "is this unit upgraded?" predicate; played units enter **exhausted (Status 0)**, so "ready this unit" on play is meaningful.
- **Zone-gated unit Action with a "use the Force" cost + arena move + can't-ready** (LOF_098 — while in the SPACE arena: "Action [use the Force]: move to the ground arena and give each friendly Heroism unit +2/+2 this phase"). (1) Cost: register `$unitAbilities["X"]` (auto-detected as a provider by `SWUGetUnitActionProvider`) and set `$unitActionCostKind["X"] = 'none'` — no exhaust, no ready requirement (right when the unit is meant to act while exhausted). The handler pays with `UseTheForce($p)`. (2) Availability/zone-gating goes in `SWUUnitActionAffordable` (`case 'X': if (!PlayerHasTheForce($p) || strpos($mzID,'SpaceArena')===false) $ok=false;`). (3) Arena move: `SWUMoveUnitBetweenArenas($mz, 'GroundArena')` preserves damage/upgrades/UID and returns the new mz. (4) AoE aspect buff: loop friendly units, `strpos(CardAspect($c), 'Heroism')`, `AddTurnEffect($mz, SWUMakeTurnEffect('SWUBUFF',[2,2],SWU_DUR_PHASE))` — "friendly" (not "another friendly") INCLUDES self, so move first then buff. (5) **"While in the space arena, can't ready"** is continuous (not a consumed `SWU_CANT_READY` flag): block BOTH the `ReadyPhase` SPACE loop (regroup, sets `Status` directly) AND `OnReadyCard` (explicit "Ready a unit" effects) with a `CardID === 'X' && in-space` check. Per the user: this blocks effects that say "ready a unit" but NOT "enters play ready" (a separate entry path that never calls these) — so hooking the ready FUNCTIONS is exactly the right granularity.

**SHD Phase 3-12 lessons (upgrades/passives/deck-search + two-sided leaders; folded at the autonomous→pair-programmed retro):**
- **⚠ A "while attacking a UNIT" combat conditional must exclude the base explicitly — `$target` is NON-null for base attacks.** `GetZoneObject("theirBase-0")` returns the base object, so a check like `$target !== null && empty($target->removed)` is TRUE when attacking a base too. Gate combat-time "vs unit" buffs/keyword-grants with `strpos((string)$targetMzID, 'Base') === false` as well (cost the SHD_007 Moff Gideon "+1 while attacking a unit" a wrong base-attack buff). The existing SHD_138 `$shd138VsBounty` only avoids this by accident (a base has no Bounty).
- **⚠ Leader-front "play a unit from your hand" tests: an UNDEPLOYED leader's aspects do NOT reduce the played unit's aspect penalty.** The player's aspects for cost = the BASE (+ a deployed leader unit), NOT the undeployed leader card. So a unit that's on-aspect *to the leader* but off-aspect *to the base* gets the full +2/+4 penalty → the leader-action's affordability filter silently finds 0 valid units and the action reads as "nothing happened." Pick a fixture on-aspect **to the CommonSetup base letter**, or pad resources to cover the penalty (and note the discount is proven by "affordable only because of the −1"). This is invisible because **the schema runner's `useLeaderAbility` wraps the call in `ob_start()/ob_end_clean()` — it SWALLOWS all output AND PHP warnings**, so a silent no-op inside a leader Action shows nothing. Debug by dropping an `error_log("...")` in the offer/handler and running with `php -d display_errors=stderr … 2>&1 | grep PROBE`.
- **Epic deploy is fully GENERIC — no per-leader wiring.** `SWUDeployLeader` gates on `SWUResourceCount($player) < intval(CardCost($cardID))`, i.e. the deploy threshold IS the leader's printed cost (SHD_001 cost 6 = "6+ resources", etc.). Just implement the front + deployed abilities; the "Epic Action: if you control N resources, deploy" needs zero code. Deployed-side dispatch map: On Attack → `$onAttackAbilities["CID:0"]` (combat keys on the attacker's CardID = the leader's); When Deployed → `$whenPlayedAbilities["CID:0"]`; deployed `Action:` → `$unitAbilities["CID"]` + `$unitActionCostKind["CID"]` (+`$leaderActionResourceCosts` for the FRONT resource cost); deployed passive → `ObjectCurrentPower/HP` (leader-presence buffs mirror SOR_001: scan `GetLeader($controller)` — works undeployed AND deployed since the leader entry persists) or a keyword registry (deployed Restore/Overwhelm/Saboteur/Grit auto-fire from the generated `*_Cards` lists). Test a leader with `myLeader:CARDID` + `SkipPreGame:true`; front = `UseLeaderAbility`; deployed = `DeployLeader` then `AttackGroundArena:<deployed-idx>` / `UseUnitAbility:myGroundArena-<deployed-idx>` (the deployed leader lands at the NEXT arena index after any pre-placed units).
- **"Play a unit from hand then act on THE PLAYED unit" (deal damage / capture / grant Ambush) = the `$gPlayGrantTurnEffect` findable-marker pattern (SEC_018).** Set `global $gPlayGrantTurnEffect; $gPlayGrantTurnEffect = 'MARKER';` then `ActivateCard($player, $handMz, false, $discount)` (save/restore `$gTurnPlayer` + the `PASS` SWUVar around it), null the marker, then scan all arenas for the unit whose `TurnEffects` contains `'MARKER'` — that's the just-played unit. Reuse `'SEC_007'` as the marker to grant the played unit **Ambush this phase**.
- **⚠ Reactive "When you play a [Smuggle/Underworld/keyword/upgrade] card" LEADER fronts belong to the reactive-trigger subsystem, not Phase 12.** `SWUCollectOwnPlayReactions` scans **deployed UNIT observers only** (`GetUnitsInPlay`), NOT undeployed leaders — so an undeployed-leader "when you play X" reaction has no hook. Defer these leaders (SHD_005/008/010/014/018) to the reactive-trigger phase rather than half-wiring them.
- **Test-assertion gotchas:** (1) **`UPGRADECOUNT` counts shield tokens** (SOR_T02 are Subcards) — after granting a shield, `UPGRADECOUNT` = upgrades + shields; assert `SHIELDCOUNT` for the shield and don't expect `UPGRADECOUNT:1`. (2) **A top-deck search "choose none" is an EMPTY `AnswerDecision:` (blank), NOT `PASS`** — answering `TOPDECKSEARCH` with `PASS` drains the peeked cards OUT of the deck (they're lost, deck shrinks); the no-pick convention is a blank answer (cf. `PrepareForTakeoff_SearchTop8_ChooseNoneof1`). (3) `myBaseDamage:N` / `theirBaseDamage:N` are the CommonSetup opts to pre-damage a base (for "15+ damage on base" gates etc.). (4) **"Put into play as a resource" enters EXHAUSTED** (`SWURampResourceExhausted` / `AddResources(...,Status:0)`); only explicit "…and ready it" wording uses `SWURampResourceReady`.

**SHD Phase 13-14 lessons (the reactive-trigger subsystem + control/modal/alt-win; folded at the end-of-run retro):**
- **Reactive "when X happens" observers already have hooks — extend the collector, don't build new plumbing.** `SWUCollectOwnPlayReactions` ("when YOU play a card/unit/event/upgrade" — add a `$cid === 'X'` case in the deployed-unit-observer loop), `SWUCollectOpponentPlayReactions` ("when an OPPONENT plays a card" — but its early `return` was TWI_210-specific; move any cost-condition INSIDE its block so an unconditional observer isn't skipped), `CollectWhenPlayedAsUpgradeTriggers` (field observer "when you play an upgrade on a unit" — check `_SWUCountActiveUnitsWithCardID` and carry the host UID), `SWUCollectLeavePlayReactions` ("when an [enemy/friendly] unit is defeated/leaves play" — add a `$d`-loop case; needs `$d['upgraded']` etc. captured at the defeat-entry sites BEFORE `SWUDiscardHostSubcards`), `SWUCollectCombatHitTriggers` ("this unit deals combat damage to a base/unit" — switch on the attacker CardID gated on `$combatCtx['dealtToBase']`/`['dealtToUnit']`), `_SWUOnUnitDamaged($obj,$amount,$isCombat)` ("a unit is dealt [combat] damage and SURVIVES" — self-observer in its switch, or a `_SWUShdXXXCheckObserve` field observer). The base-attack observer is inline at the `strpos($targetMzID,'Base')` point in ExecuteSWUAttack (next to ASH_160). Control exchange = LAW_170's `SWUTakeControlOfUnit` (twice); modal "an opponent chooses one" = LAW_080 (OPTIONCHOOSE queued for `OtherPlayer`, branch in the `#0` CUSTOM).
- **⚠ TWO subsystem gaps in the reactive flush — interactive decisions don't always drain.** (1) An interactive **YESNO/choose queued in the LEAVE-PLAY flush** (defeat observer) does NOT drain on the defender-defeat/opponent-observer path — mandatory auto-resolving choices work, but a "may" that needs an answer is orphaned. For a benefit-only "may" (e.g. SHD_137 "you may ready this unit") use the **SOR_015 auto-resolve precedent**: resolve it inline as always-yes, but only when there IS a benefit (e.g. only ready an EXHAUSTED unit) so the once/round isn't wasted. (2) ~~A cross-player interactive target-choose over the ENEMY's board does NOT resolve~~ — **THIS WAS A MISDIAGNOSIS (corrected session 70).** It works exactly like TWI_210 (`CunningOpponentPlayedReaction`), which queues an interactive OPTIONCHOOSE→unit-choose over ALL units (both boards) and drains fine via `FlushEntryTriggerBag`. The recipe (SHD_172 Krayt): `SWUCollectOpponentPlayReactions` `AddTrigger($opp, …)` → `DispatchTrigger` → a reaction fn that queues an **intermediate CUSTOM** (`CardID#0`) → that continuation (which `ExecuteStaticMethods` does NOT $playerID-restore) builds the target list under `$playerID=reactor` + queues the MZMAYCHOOSE. ⚠ Test-drive note: a cross-player single reaction needs an extra `AnswerDecision:EffectStack-0` step (the `RESOLVE_NEXT_TRIGGER` orchestration) BEFORE the target answer — cf. TWI_210's arbitrary-answer first step. ⚠⚠ **Real pre-existing engine bug this exposed:** two IDENTICAL reactive triggers (a NON-UNIQUE reactive card, 2 copies) **hang the EffectStack flush** — never hit before because all prior reactive cards were unique. Workaround: add **ONE** trigger carrying a `cost~count` payload and LOOP the effect once per copy (CR-equivalent for identical triggers), instead of N identical `AddTrigger`s (SHD_172). A proper EffectStack duplicate-handling fix is still owed. So: an opponent-turn enemy-target interactive reaction is BUILDABLE — do NOT defer it.
- **Two central-function extensions were needed and are safe (guard tightly, full-regression after):** `_SWUOnUnitDamaged` gained a `bool $isCombat` param (combat call sites pass `true`, the effect-damage site stays `false`) so "when dealt COMBAT damage" observers (SHD_084/250) don't fire on effect damage; defeat-entry arrays gained `'upgraded' => _SWUIsUpgraded($obj)` at all three sites (two combat + `SWUDefeatUnit`) for "when an UPGRADED enemy is defeated" (SHD_137). A "when a player discards from hand" observer must hook **BOTH** `DoDiscardCard` (self-chosen discards, via `MZMove`) **AND** `SWUAddToDiscard` when `$from==='HAND'` (forced discards — `SWUDiscardCards`/`DISCARD_FROM_OWN_HAND` bypass `DoDiscardCard`); they're disjoint so no double-fire, and a once/round guard in the dispatch case dedupes multi-card discards.
- **Once/round "may" reactions: consume the marker on USE (in the reaction handler when the effect resolves), NOT on trigger** — so declining doesn't waste the round's use, and a later qualifying event can re-trigger. The observer's `GlobalEffectCount(...'_USED') <= 0` gate then reflects actual use. (Clear every `SWU_SHDxxx_USED` at RegroupPhaseStart next to the ASH_128/ASH_032 clears.)
- **`DoCaptureUnit($player, $captorMz, $targetMz)` takes the captor as a mzID STRING, not the object** (cf. SHD_124/232 — `SWUFindMzByUID` returns a mzID string; don't pass `GetZoneObject(...)`). **Opponent-play-reaction tests need the TWI_210 turn setup:** `WithActivePlayer:1` + `WithInitiativePlayer:1`, then `P1>Pass` BEFORE `P2>PlayHand:0`, then `P1>AnswerDecision:…`; and remember the played card's aspect penalty for the OPPONENT (an off-aspect card needs printed cost + penalty in `theirResources`, or it silently fails to play and the reaction never fires).

**Use the dictionary's `$nameData` name verbatim** in comments and test filenames — the CardID is the source of truth, the printed name comes from `$nameData`, never from memory or an inherited comment. A wrong name propagates fast and silently: SOR_139 is **Force Choke**, but had been labeled the non-existent "Swift Strike" across its comments, three code files, two test filenames, a doc, and memory. Registry/handler keys are CardID-keyed so a misnomer is only cosmetic — but it misleads every future reader, so get the name right once, here.

**The dictionary is authoritative for stats over any prose doc (e.g. sor-implement.md).** A doc once listed Protector (SOR_057) as +0/+2 while `upgradePowerData`/`upgradeHpData` said +1/+1 — trusting the doc produced two wrong test expectations. SOR_049 Obi-Wan is **4/6** in the dictionary but "3/6" in the prose doc — seeding a POWER expectation from the doc reddened two tests. Always derive expected combat/heal numbers from the array lookups — **for EVERY unit in the test, including the ability's own subject/chosen unit, not just the targets.** It's easy to rigorously look up the targets while eyeballing the "incidental" fixture (the unit being buffed, the leader, the attacker); that incidental unit is exactly where a doc-vs-dictionary stat drift slips a wrong expected number through. (Reminder, see 3c-stats: **combat lethality uses `ObjectCurrentHP`**, so an upgrade's/buff's +HP DOES keep a unit alive in combat; and a **Sentinel unit force-redirects** an attack onto itself rather than rejecting it as a no-op.)

The dictionary uses PHP `$name = array ( ... );` syntax with a **leading-space indent** — so the anchored `/^\$costData /,/^\];/` form matches nothing and returns empty. Match `= array \(` … `^\);` as above. If a section ever comes back empty, first confirm the array headers with `grep -nE "= array \(" …` and slice by line range (`awk "NR>=START && NR<=END"`).

This is critical — power and HP are easy to swap when reading combined grep output. Always use the array-specific lookup. When extracting the bare number, strip the `SET_NNN` digits first (e.g. `grep -oE '=> [0-9]+'`) — a naive `grep -oE '[0-9]+'` also captures the card number (`'SOR_049' => 4` → `049` *and* `4`).

**Leaders are double-sided — read BOTH ability arrays.** A Leader's `textData` holds only the leader-side text (the action ability + the "Epic Action: deploy" line). The deployed **Leader Unit**'s abilities (On Attack, etc.) live in a *separate* array, `deployTextData`. Implementing only the `textData` side silently misses half the card (this bit SOR_017 Han Solo, whose ramp-from-deck On Attack is deploy-side only). The leader's unit-side power/HP share the normal `powerData`/`hpData` arrays.

```bash
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:

```bash
grep -n "case '" SWUSim/Tests/Framework/SchemaTestRunner.php | grep -v "//"
grep -n "P1LEADER\|P1BASE\|P[12]GROUND\|SHIELDCOUNT\|EPICUSED" SWUSim/Tests/Framework/SchemaTestRunner.php
```

Identify all new DSL commands and assertions the batch will need. List them explicitly — these are what make the tests RED by design.

### ⚠ Clause decomposition — ONE test per clause/branch (do this FIRST)

**Before writing any test, decompose the card text into every INDEPENDENT clause and conditional branch, and enumerate them explicitly.** Then write at least one test per enumerated clause. A card is not "done" until every clause it prints has a passing test that OBSERVES it. This is the #1 source of shipped bugs: a multi-clause card gets *some* clauses implemented + tested and one **silently absent** — invisible to a happy-path test that only exercises the present clause (real examples: LOF_073 Mythosaur — protection clause wired, "friendly leaders gain Mandalorian" clause missing; LOF_261 Constructed Lightsaber — Villainy→Raid 2 and Heroism→Restore 2 wired, the third branch "neutral host → Sentinel" missing).

What counts as a separate clause/branch to enumerate:
- **Each sentence / each trigger** — When Played, When Defeated, On Attack, Action, Epic Action, and every standalone passive sentence is its own clause. A card with "When Played: X. When Defeated: Y." needs a test for X **and** Y (they're often the same handler wired to two triggers — verify BOTH fire).
- **Each `If … / If …` branch** — a card with "If attached unit is Heroism, gains Restore 2. If Villainy, gains Raid 2. If neither, gains Sentinel." is THREE branches → three tests (one host per branch).
- **Each keyword in a granted list** — "gains Ambush, Grit, Sentinel, …" — treat as one test that exercises the *distinct* ones (especially numeric grants like Raid N / Restore N vs boolean keywords).
- **Each half of a multi-part passive** — "Friendly upgraded units can't be exhausted OR returned to hand … AND friendly leaders gain Mandalorian" = protection (2 verbs) + trait grant = separate tests.

**The cross-function grant trap (why branches go missing):** conditional keyword/trait grants live in DIFFERENT engine functions per keyword — Raid in `HasKeyword_Raid`, Restore in the Restore fn, Sentinel in `HasConditionalKeyword_Sentinel`, a granted trait in `TraitContains` (the object-aware trait check; `_SWUUnitHasTrait` was deleted). A multi-branch card is therefore N edits in N different files; it's easy to land N-1 and miss the last. **After implementing (Step 3e verify), grep the CardID and confirm the number of grant hits equals the number of grant branches** — `grep -rn '<CardID>' SWUSim/Custom/` (recursive, so it descends `cards/<set>/`); 2 hits for a 3-branch card is the tell.

**Observability — a GRANT clause is only testable through a CONSUMER that reads it via the object-based path** (`TraitContains($obj,$trait)` for traits, `HasKeyword_X` / the `HASKEYWORD` assertion for keywords — NOT bare-CardID `HasTrait`). For a trait grant, find a card whose behavior depends on the granted trait (e.g. SHD_073 Mandalorian Armor gives a Shield "if attached unit is a Mandalorian" — attach it to a leader that Mythosaur made Mandalorian). **Pick a probe host that exercises the SPECIFIC branch, and avoid a host that already has the keyword/trait PRINTED** (SOR_049 Obi-Wan has Sentinel printed → a Sentinel test on it false-passes regardless of the grant).

**Write all test files before implementing anything.**

### Recurring bug shapes beyond missing clauses (JTL validate-port, 2026-07-23) — decompose the TARGET SET and the OPTIONALITY, not just the clauses
A clause can be present and still wrong. Four shapes cost real bugs this port — enumerate each explicitly when decomposing:
- **"another X unit" with NO "friendly" qualifier → ANY unit, friendly OR enemy.** SWU targeting defaults to "any" unless the text says "friendly"/"enemy". Restricting to friendly is a bug (JTL_088 Phasma "+2/+2 to another First Order unit" targets an ENEMY too; JTL_120 Dorsal Turret "Attach to a Vehicle unit" attaches to an enemy Vehicle; JTL_129 Focus Fire; JTL_078 Direct Hit "non-leader Vehicle"). **Always add a test that the enemy side IS (or a leader/other-arena is NOT) selectable** — build the target list from all four arenas, then filter ONLY by the printed restriction.
- **"Heal/deal UP TO N" and "You may" → support doing LESS and DECLINING.** A mandatory single-target choose (`SWUQueueChooseTarget`, which AUTO-RESOLVES a lone target) is wrong for "you may" / "up to" effects — use `SWUQueueMayChooseTarget` so even one legal target still offers a decline (canChooseNoTargets), and for "up to N" add an amount pick (OPTIONCHOOSE 1..min(N,cap)) so the player can heal/deal fewer than max. (JTL_071 CR90 "heal up to 3" — heal-less + decline; JTL_003 Lando "Play a unit from hand" — soft-pass decline / hidden info.) Tests: a heal-max case, a heal-LESS case, and a DECLINE case.
- **A zero-effect selection must be filtered out.** If choosing a target would do nothing (no friendly Vehicle in that arena for Focus Fire; an undamaged/immune target for some heals), it must be UNSELECTABLE. Add the legality condition to the target filter, and a `SELECTABLENOT:` guard.
- **Restrictions/immunities must apply at SELECTION time, not just resolution.** "This unit can't attack" (JTL_059) has to exclude the unit from event-granted "attack with a unit" pickers (Outflank), not merely no-op at resolution — use `_SWUUnitHardCantAttack` in the picker. Same for any "can't be targeted / can't ready" restriction: enforce it where the candidate list is built.

**Trigger ATTRIBUTION on non-standard damage paths.** A "when this unit deals damage to a base / a unit" trigger must fire on EVERY path that unit deals damage, not just the direct-attack path: Overwhelm spillover to a base is damage-to-a-base (set `combatCtx['dealtToBase']`); divided/split damage must run through the Shield + non-combat-reaction pipeline, not write `Damage` directly (`_SWUApplySplitHits`). And a non-standard attach/play path (`SWUMoveUnitToUpgrade` for a pilot) bypasses the shared `_SWUFinalizeUpgradeAttach` trigger dispatch → host "when a Pilot attaches" reactions silently don't fire (JTL_213 Sidon). When you add a bespoke attach/damage path, route it through the shared collector or explicitly re-fire the observers.

**Test BOTH the On-Attack AND the When-Played/When-Defeated half.** Many multi-trigger cards had only one half covered (Rafa JTL_219, Phasma JTL_088 On-Attack; FO Stormtrooper JTL_132 When-Defeated). A card with two trigger windows needs a section per window even when they share a handler.

### More recurring bug shapes (SEC validate-port, 2026-07-24) — the "do nothing" and "stacking/stale" families
Seven more shapes cost real bugs this port. Enumerate a test for each whenever the card matches:
- **"Name a card, then an opponent reveals their hand …" with an EMPTY opponent hand → skip the whole ability (NO prompt).** A naming/reveal ability that can produce zero effect when the opponent's hand is empty must short-circuit before the NAMECARD prompt (`if (count(GetHand(OtherPlayer($p)))===0) return;`). Bug family: SEC_186 Garindan, SEC_210 Stolen Starpath, SEC_260 Inspector's Shuttle — all raised a pointless prompt. Always add an `OpponentHandEmpty_NoPrompt` section (`P1NODECISION`) for any name-a-card / look-at-hand / reveal-hand ability.
- **"An opponent MAY pay N. If they don't, X" with the opponent UNABLE to afford → skip the prompt, auto-resolve the "don't" branch.** Don't offer a choice the player can't act on. `if (SWUResourceCount($opp,true) < N) { /* do X */ return; }` before queueing the YESNO (SEC_218 Cikatro). Add a `CannotPay_AutoResolve` section (opponent 0 resources).
- **A self-targeting "discard/return from YOUR hand" effect must EXCLUDE the in-flight event.** A played event is `Remove()`d to discard BEFORE its effect runs, but `ZoneSearch("myHand")` STILL returns the removed entry — filter `empty($o->removed)` or the event is selectable / discards itself (SEC_178 Pursue the Lead: self-discard offered the in-flight card + wrongly made a Spy). Add a `SelfDiscard_InFlightNotSelectable` section.
- **A deferred When-Defeated target-choose that reads `$self` via the positional mzID is STALE.** By dispatch time the defeated unit is cleaned up and a survivor shifts into its slot, so `GetZoneObject($mzID)` returns the WRONG unit — which a "give ANOTHER friendly" clause then self-excludes → fizzle (SEC_202 Rebel Propagandist). Fix: guard `($self->CardID ?? '')===<CardID> && empty($self->removed)` before trusting it as self; on defeat, self has left play so every survivor is "another". Add a `WhenDefeatedByCombat_<effect>` section (attacker dies to a bigger blocker) for ANY When-Defeated that references "this unit" / "another".
- **A "+X/+Y for this phase" buff that can trigger MULTIPLE times must STACK.** `SWUApplyPhaseBuff` emits an identical `SWUBUFF-X-Y` string that `AddTurnEffect` DE-DUPES, so repeated applications collapse to one. For a stacking buff (SEC_081 Major Partagaz "when another Official attacks: +2/+2"), emit a unique-per-trigger token — `AddTurnEffect($mz, SWUMakeTurnEffect('SWUBUFF',[X,Y],SWU_DUR_PHASE,'<TAG>_'.$stackIdx))` where `$stackIdx` counts existing `^<TAG>_` tokens on the unit. Add a `BuffStacks` section (two triggers → +2X/+2Y).
- **An upgrade printed "attach to a unit" (no friendly/enemy qualifier) must be attachable to ANY unit.** Default upgrade targeting is friendly-only in SWUSim; a printed-unrestricted upgrade needs its CardID added to the attach-to-any-unit `case` list in `SWUGetUpgradeValidTargets` (GameLogic.php). Bug: SEC_175 Ambition's Reward (+1/+1, no restriction) couldn't attach to an enemy. Confirm the card's printed text carries no friendly/enemy attach restriction, then add an enemy-attach section.
- **"Exhaust N enemy resources" (an EFFECT) is up-to-N, not all-or-nothing.** `SWUExhaustResources` is all-or-nothing (correct only for COST payment). An effect that reads "exhaust 2 enemy resources" must exhaust as many as available up to N — pass the `$partial=true` flag (SEC_235 The Wrong Ride). Add a `FewerThanNReady_ExhaustsAvailable` section.

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

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

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

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

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

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

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

```markdown
# 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:

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

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

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

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

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

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

**EXPECT assertions cheat sheet** (the runner is the source of truth — when in doubt, re-derive with `grep -oE "preg_match\('[^']+'" SWUSim/Tests/Framework/SchemaTestRunner.php`):
- `P1BASEDMG:N` / `P2BASEDMG:N`
- `P1GROUNDARENACOUNT:N` / `P1SPACEARENACOUNT:N`
- `P1GROUNDARENAUNIT:idx:CARDID:X` / `:SHIELDCOUNT:N` / `:DAMAGE:N` / `:POWER:N` / `:HP:N` / `:READY` / `:EXHAUSTED` / `:UPGRADECOUNT:N` / `:UPGRADE:n:CARDID:X` / `:HASKEYWORD:Kw` / `:NOTKEYWORD:Kw`
  - **`UPGRADECOUNT` counts ALL subcards — Shield (`SOR_T02`) and Experience (`SOR_T01`) tokens too**, not just "real" upgrades. A unit with a Lightsaber + a Shield is `UPGRADECOUNT:2`. To assert upgrades on a unit that also has tokens, use `SHIELDCOUNT` (filters `SOR_T02`) and/or a specific `UPGRADE:idx:CARDID`. `HASKEYWORD`/`NOTKEYWORD` dispatch to the real `HasKeyword_<Kw>($obj)` (incl. suppression/grant/conditional layers).
- `P1SPACEARENAUNIT:idx:...` — same fields
- `P1LEADER:READY` / `:EXHAUSTED` / `:DEPLOYED` / `:NOTDEPLOYED` / `:EPICUSED` / `:EPICAVAILABLE`
- `P1BASE:EPICUSED` / `P1BASE:EPICAVAILABLE`
- `P1RESCOUNT:N` / `P1RESAVAILABLE:N` / `P1HANDCOUNT:N` / `P1DISCARDCOUNT:N`
- `P1DISCARDUNIT:idx:CARDID:X` / `:MODIFIER:X` / `:FROM:X`
- `P1DECKCOUNT:N` / `P1DECKTOPCARD:CARD_ID`
- `P1NODECISION` / `P1HASDECISION`
- `PHASE:X` / `PHASEISNOT:X` / `INITIATIVECOUNTER:X`
- `EFFECTSTACKCOUNT:N` / `EFFECTSTACKHAS:TriggerType`
- `LOGCONTAINS:text` / `LASTLOGCONTAINS:text` — substring match against game log entry text
- `P1WIN` / `P2WIN`

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

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

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

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

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

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

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

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

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

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

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

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

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

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

8. **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.

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

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

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

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

---

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

Do NOT blanket-stop here. Run the **self peer-review** against the CR + game logic + card data (top-of-file policy), confirming for each card:
- The full list of new DSL commands and assertions needed is correct and wired.
- Each test drives the **real execution path** (trace it in the engine code), with stats/costs/aspects derived from the dictionary.
- The implementation plan is clear (shared infra first, then which card depends on which), and any "new" seam is a verified mechanical mirror of an existing one — not a real design choice.

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

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

---

## Step 3 — Implement

### 3a. Shared infrastructure first

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

Only add what the tests actually require.

### 3b. New DSL commands / assertions

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

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

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

| Ability type | Where to add it |
|---|---|
| Base Epic Action | `$baseAbilities["CARD_ID"]` closure (in the card's `cards/<set>/<Title>.php`; shared `BaseAbilities.php` holds only families/glue) |
| Leader ability | `SWUSim/Custom/LeaderAbilities.php` — `$leaderAbilities["CARD_ID"]` closure. **For the leader (undeployed, front) side ability, prefer the bare base CardID as the key** — `$leaderAbilities["JTL_011"]`, no `:0` suffix. The *deployed* (unit) side's abilities register in the normal `whenPlayed`/`onAttack`/`whenDefeated` registries under `CardID:0` — so the same base CardID cleanly distinguishes the two sides across namespaces (front side = `$leaderAbilities["X"]`, unit side = `$onAttackAbilities["X:0"]`). |
| Timed-ability window — When Played / When Deployed, On Attack, On Defense, When Defeated, On Attack-End, etc. | `SWUSim/Custom/CardDQHandlers.php` — the matching registry (`$whenPlayedAbilities` / `$onAttackAbilities` / `$onDefenseAbilities` / `$whenDefeatedAbilities` / …) keyed **`CardID:0`**. **Every one of these windows uses the `:N` window-index suffix** (`:0` for the card's first/only such window, `:1` for a second window of the same kind). This is the *one* exception to "bare CardID" — the trigger windows are the `:` namespace; leader-front-side abilities and continuation/reactive handlers are bare-CardID/`#N`. |
| Custom DQ handler (post-choice continuation step) | `SWUSim/Custom/CardDQHandlers.php` — `$customDQHandlers["HANDLER_NAME"]`. **Naming (convention v2, `#0`-based, session-51):** a card-specific continuation handler's key is **`CardID#0` for the first step, then `CardID#1`, `#2`, …** — continuations are **ALWAYS `#N`-suffixed, never bare** (`$customDQHandlers["SOR_006#0"]`/`["SOR_006#1"]`/…). The `#N` suffix is the type-marker: **`#N` = "I'm a continuation step inside an ability's resolution"**, which is exactly what distinguishes it from a bare-CardID *reactive trigger type* (AddTrigger row below) and a `CardID:N` *ability-window entry*. **Number continuations `0,1,2…` in order of first encounter, walking the card's abilities `leader front-side → when-deployed → on-attack`** (a continuation shared by several abilities takes the number of the *earliest* ability that reaches it). **Multiple entry points route to the right handler and SHARE a number when the continuation logic is identical** even if cost/parameter differ (Dooku `TWI_005`'s leader + deployed sides both queue `TWI_005#0`; SHD_013). Keep each ability's entry next to its own continuations and order the registrations #0→#1→#2 top-to-bottom (see SOR_006). Cross-card *universal* handlers keep ALL-CAPS descriptive names (`DEAL_UNIT_DAMAGE`, `HEAL_TARGET`, …) — never a CardID. **Three separators, one meaning each:** `:` = ability/trigger **window index** (entry registries, `whenPlayedAbilities["X:0"]`, `["X:1"]` for a 2nd window); `#` = **continuation step** (here, `#0`-based) — *and* an extra reactive trigger type on one card (AddTrigger row); `-` = dynamic TurnEffect value (`"SOR_051-3-3"`). All three namespaces are CardID-keyed and don't collide at runtime. **Because continuations are always `#N` and never bare, a `#N` handler key is now textually distinct from the bare-CardID trigger token / `case` / TurnEffect token, so handler-key references are safe to blind-replace in a rename.** ⚠ When wiring a continuation, **every channel that queues it must carry the `#N`**: the `'CUSTOM'` AddDecision param, the trailing handler arg of `SWUQueueChooseTarget`/`SWUQueueMayChooseTarget`/`SWUOpponentChoosesOwnUnit`/`SWUQueueDefeatUpgrade(thenHandler:)`/`_topDeckSearchBegin(finalizeHandler)`, the indirect `"KEY#0~args"` string, and any `$cardID`-variable handler (`SWUQueueChooseTarget(…, $cardID . '#0')`). A missed channel silently no-ops the step — caught by regression only if the card has a test, so cross-check defined-vs-referenced keys after bulk work. |
| Reactive trigger (`AddTrigger` / `DispatchTrigger`) — a card that arms a delayed/conditional response (on-defeat, on-play-of-X, on-heal, excess-damage, etc.) | `SWUSim/Custom/{GameLogic,CombatLogic}.php`. **The trigger-TYPE string is a third CardID-keyed namespace:** bare **CardID for a card's first trigger type, then `CardID#1`, `#2`** if one card arms multiple distinct trigger types (a reactive trigger is an *entry* — armed and later fired by an event — so it uses the **bare CardID**, unlike a continuation step which is always `#N`-suffixed) — **never** a PascalCase/card-name token (`BlizzardExcess`, `CassianDraw`) or a malformed CardID (`JTL156Attack`). `AddTrigger($player, $type, $cardID, …)` — **`$type` must equal `$cardID`** (or `$cardID#N`); the matching `case '<CardID>':` in the `DispatchTrigger` switch routes it. ⚠ The dispatcher **functions** keep descriptive names (`CassianDrawTrigger`, `ShadowCasterReuseTrigger`) — only the *key strings* are CardID-keyed: `case 'SOR_013': CassianDrawTrigger(...)`. When the same string doubles as a `customDQHandlers` key (Cassian/Kallus), rename both in lockstep. **`SWU_*` GlobalEffect FLAG names keep card-NAMES** (`SWU_KRENNIC_USED`) — separate, intentional, leave them. **Bulk-rename safety net (a mis-map here is invisible to any card lacking a test):** after a sweep, run the two structural checks that don't depend on coverage — (1) **no duplicate `case` labels** in the `DispatchTrigger` switch (`grep -oE "case '[^']+':" \| sort \| uniq -d`); (2) **every `AddTrigger` type == its cardID arg** (extract the 2nd+3rd args, assert equal). These caught Blizzard's `SOR_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 `AddTrigger`s 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` `OnReadyCard`s 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 `AddTrigger`s 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 `AddTrigger`s 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 `AddTurnEffect`s 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 `SWUBounceUnit`s 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 → auto `PASSPARAMETER`; 2+ → `MZCHOOSE`; then `CUSTOM $handler`. No-ops on `[]` (so drop the `if (!empty(...))` guard). `$handler` may carry args (`"DEAL_UNIT_DAMAGE|4"`). Pass `$block` to match surrounding decisions (older handlers sometimes use block `0`).
- `SWUQueueMayChooseTarget(int $player, array $targets, string $yesTooltip, string $chooseTooltip, string $handler, int $block = 1)` — **"You may"** choose. Emits ONE `MZMAYCHOOSE` (pick a target **or** decline via the Pass button) + `CUSTOM $handler` — collect the targets **once** in the trigger and pass them (no separate re-collecting handler, no double-predicate). The whole "you may give a Shield to another Spectre unit" card becomes one closure that calls this. `$yesTooltip` is retained for call-site readability; `$chooseTooltip` is the popup prompt. (Earlier versions queued a `YESNO` + a `MAY_CHOOSE` follow-up — that indirection is gone; it's a single `MZMAYCHOOSE` now.)
  - **The `$handler` MUST no-op on a `'-'` decline** (`if (!$lastDecision || $lastDecision === '-' || $lastDecision === 'PASS') return;`) — `MZMAYCHOOSE` runs the follow-up even when the player passes. Every universal effect handler (`DEAL_UNIT_DAMAGE`, `GIVE_SHIELD`, `GIVE_EXPERIENCE`, `HEAL_TARGET`, `EXHAUST_UNIT`, `READY_UNIT`, `DEFEAT_UNIT`, `BOUNCE_UNIT`) already does; a bespoke one must.
  - **Keep a hand-written handler instead** only when the target list or an effect amount is computed at *resolution* time from changeable state ("deal damage = resources you control"; "defeat a unit with ≤4 remaining HP"; reveal-then-target), or the decline needs special cleanup (attack-during-WhenPlayed `CleanupRemovedCards`). In those cases compute the list/amount up front in the **ability** and still call `SWUQueueMayChooseTarget` when you can (the reveal/commit moves into the follow-up handler — SOR_176 ISB Agent); fall back to a bespoke handler only when a mid-sequence sub-choice follows (a host→subcard pick like the upgrade-defeat family — now generalized via `SWUQueueDefeatUpgrade` + TempZone staging; see the "Pick among a unit's upgrades / any subcard" row).

**Collect "another X" targets with `_SWUCollectUnits`, and know whether X is a TRAIT or an ASPECT.** `_SWUCollectUnits(int $excludeUID, callable $pred): array` (CardDQHandlers.php) walks all four arenas, excludes one `UniqueID` ("another …"), and returns mzIDs where `$pred($obj)` holds. The predicate distinguishes:
- **Trait** (Spectre, Imperial, Rebel, Mandalorian, Force…) → `fn($o) => HasTrait($o->CardID, 'Spectre')`
- **Aspect** (the `[Vigilance]`/`[Command]`/`[Aggression]`/… icons) → `fn($o) => strpos(CardAspect($o->CardID) ?? '', 'Vigilance') !== false`

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

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

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

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

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

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

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

### 3d. Gotchas

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

### 3e. Verify

Run the regression after each card is implemented:

```bash
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*()` → missing `Do*` implementation
- `expected X, got 0` on SHIELDCOUNT / DAMAGE → `$playerID` not set before `GetZoneObject`
- `Unknown schema command` → WHEN command not wired in SchemaTestRunner
- `Unknown EXPECT assertion` → assertion regex not added to SchemaTestRunner

Debugging rules learned the hard way:
- **Reproduce a "known / deferred engine bug" via `TestSchemaStep` BEFORE trying to fix it — a later infra rework may have already resolved it.** A plan note flagged JTL_227's "onAttack-mid-combat indirect-split mis-resolves" as a genuine bug to fix; stepping the exact scenario showed it now resolves perfectly (the session-50 indirect rework fixed it as a side effect). The right action was to write the deferred guard test and flip the note, not to "fix" working code. Bug notes age; the engine moves under them.
- **A comment is not evidence the code exists.** A card can *look* implemented because a comment says where its logic "is handled" — `KeywordEffects.php` claimed the combat resolver re-checked SOR_130's "while attacking a damaged unit" Overwhelm, but the resolver never did; the card was fully unimplemented. Before trusting that a passive/keyword is wired, grep the **named consumer** (e.g. `CombatLogic.php` for a combat-time effect) and confirm the card ID actually appears there. Same archaeology habit as the dead-duplicate-function trap — a producer's claim with no consumer is a half-built feature. Also relevant when a `case` exists only in the dead GA-fallback `ObjectCurrentPower`/`HP` (~line 10555): present in the file, absent from the live function.
- **The game log accumulates across the entire suite run.** Entries you grep may belong to a *different* test — misreading the preceding test's log entries as the failing test's once cost hours. Anchor to the current test's boundary before interpreting log lines, or assert via `LASTLOGCONTAINS` instead of eyeballing.
- **When a guarded action doesn't happen, instrument the COLLECTION/loop, not the predicate.** A `&&` short-circuit hides the cause: `if (HP - dmg <= 0 && !Immune($o))` — if `$o` is never even iterated (e.g. `ZoneSearch` didn't return it), an `error_log` inside `Immune()` logs *nothing* and looks like "the guard is firing." Log what the loop is iterating (mzID + CardID + the first operand's value) one level up. A deployed-leader sweep miss looked exactly like a phase/immunity bug until the loop dump showed the leader was never in the search results. (PHP `error_log` may go nowhere in the container. ⚠ **`@file_put_contents('/var/www/html/TCGEngine/.claude/tmp/…')` is NOT reliable in every env — that container path does not always map to the local workspace, so the file never appears locally (cost a debug cycle in the SEC Phase-13 run).** The robust, env-independent probe is **`AddGameLogEntry('ABILITY', 'TAG '.$var…)`** at the point of interest, then read it back via `curl …/GetNextTurn.php?gameName=N&playerID=1 | grep -o 'TAG[^"<~]*'` (drive the scenario with `TestSchemaSetup`+`TestSchemaStep` first). Remove the temp log line when done.)
- **`TestSchemaStep.php`'s `pending` array is the fastest way to diagnose a target-collection bug.** It shows the on-the-wire decision (type + param + tooltip) for each step — e.g. it immediately revealed that a deal-damage MZCHOOSE only listed the enemy arena (deployed leader missing) and that Open Fire (SOR_172) emits an `MZCHOOSE` even for a single target (no `1→PASSPARAMETER` optimization, so a test must answer it). When reusing an existing card in a multi-step scenario, drive it through `TestSchemaStep` first to learn its actual decision shape before writing the WHEN block.
- **`GetNextTurn`'s per-card `CurrentPower`/`CurrentHP` render fields are unreliable for deployed leaders** (often show `-1`/`0` for healthy units) — a snapshot-context artifact, NOT the real engine value. Don't read them when smoke-testing; assert via the regression's engine-side `:POWER`/`:HP`/`:DAMAGE` checks (read `Damage`, `Status`, `TurnEffects` from the raw object instead).
- **If an assertion result contradicts everything upstream, instrument the assertion, not the action.** When the action flow traces correct end-to-end but the EXPECT still fails, dump the raw zone state (including `removed` flags and element count) at assertion time. A phantom `null` zone entry was invisible to all upstream tracing and instantly visible in a raw dump.
- **Fixes to generated files must land in the generator too, then be verified by regenerating.** Patch both, re-run `zzGameCodeGenerator.php?rootName=SWUSim`, and confirm the regenerated file still contains the change (it also won't show in `git diff` — generated files are untracked, so grep for the change directly).
- **Regenerating silently destroys hand-edits in untracked generated files — with zero git evidence.** And the regression suite stays green if the loss is in the transport layer (a hand-wired GetNextTurn piece was once erased this way; tests passed while the UI broke). After any regen, run the UI smoke test below. Treat a *consumer with no producer* — e.g. a `window.*Data` read that nothing assigns — as the signature of an erased hand-patch or a half-built feature.
- **A regen also REVEALS latent schema gaps (the inverse of the erase trap) — a previously-working generated file can mask a schema missing a declaration the generator references unconditionally.** `zzGameCodeGenerator.php` rewrites every generated file from the CURRENT schema, so a stale generated file built under an older (more complete) schema keeps working until you regen for some *unrelated* reason — then a swath of unrelated tests fatal with `Call to undefined function Get<X>()`/`Set<X>()`. The fix is NOT to revert the regen: the schema is genuinely missing that scalar zone's declaration. Diff against the GA schema (the template — `Schemas/GrandArchiveSim/GameSchema.txt`), add the missing zone declaration **and** its `Module: Versions=` entry, and regen again. (Caught 2026-06-17: adding the `TempZone` zone forced a regen that surfaced a missing `MacroGameIndex` scalar — the generator always emits `GetMacroGameIndexArray()` → `GetMacroGameIndex()`, but SWU's schema had only `MacroTurnIndex`; 16 capture/shield tests fatal'd. GA declared `MacroGameIndex - Value:string`; SWU didn't.) Companion habit: **after adding any new zone, run the full regression — a green-before/red-after on unrelated tests means the regen exposed a pre-existing gap, not your feature.**
- **⚠ Running `zzCardCodeGenerator.php` re-fetches the live card API and rewrites the WHOLE `GeneratedCardDictionaries.php` from current upstream data — which can have DRIFTED from the (untracked, no-backup) dictionary the project was built on.** A single-card stub regen once pulled: Unicode typography (curly quotes that defeat the generator's straight-`"` grant-style detection, en-dashes in `–3/–3`, non-breaking hyphens in `non‑leader`), reworded triggers, and a corrected stat — silently breaking 5 unrelated tests. **Prefer hand-editing `GeneratedAbilityStubs.php` directly to add ONE card's 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 touching `zzCardCodeGenerator.php`, or the card's trigger text isn't auto-detected), put the membership in a generator **manual list** so it survives regen — the generator now has these hooks: `$onAttachedManual` (Phase 5 SWUSim block) and `$manualStubAdditions` (Phase 7, `['whenPlayed'=>[...], 'whenPlayedAsUpgrade'=>[...]]`). (Caught 2026-06-18: a card hand-added to the stub for a "When played as a unit:" trigger the auto-match doesn't catch would have been erased by the same feature's Phase-1 regen; moving it to `$manualStubAdditions` made it durable — verified by a fresh regen reproducing it.) If you must regenerate: the generator now normalizes punctuation (`NormalizeCardPunctuation`), but **run the full regression immediately after** and treat any newly-red unrelated test as data drift (cross-check stats against `cards.json`, which is authoritative). Other *untested* cards may have silently changed — the regression only guards tested ones.
- **Stub detection is coupled to exact text phrasing; the upstream dataset rewords triggers.** `GeneratedAbilityStubs.php` is generated by matching substrings in card text. The current dataset uses phrasings the base rules miss: `"When this unit completes an attack:"` (→ onAttackEnd, not `"On Attack End:"`), `"When Deployed:"` (→ whenPlayed for leaders, not `"When Played:"`), `"When this upgrade becomes attached…"` (→ whenPlayedAsUpgrade, not `"When played as an upgrade:"`), `"When this unit is attacked:"` (→ **onDefense**, not `"On Defense:"` — see the CR-window note below). If a trigger won't fire and `Has<Trigger>Ability(cardID)` returns false despite matching card text, **add the phrasing to the generator's Phase-7 detection** (`zzCardCodeGenerator.php`) — note `Has<Trigger>Ability` stubs come from **`zzCardCodeGenerator.php`** (the API-refetch one), so to avoid a dictionary regen, **hand-edit `GeneratedAbilityStubs.php` to add the one `case`** AND patch the generator's detection (so a future regen keeps it), but do NOT run the card generator just for this. Register the handler in the array matching the trigger window the engine actually fires (`whenPlayedAsUpgrade` dispatches to `OnWhenPlayedAsUpgrade`; the whenPlayed fallback dispatches to `OnWhenPlayed` — they are different arrays).
- **CR window-equivalence — don't invent a new trigger type for an alternate phrasing; map it to the existing window.** Per **CR 15.c**, *"'On Attack,' 'On Defense,' 'When a unit attacks,' and 'When a unit is attacked' abilities all resolve in the same timing window."* So **"When this unit is attacked:" = the On Defense window** (`$onDefenseAbilities["X:0"]`, fired for the defender in `CollectCombatStep1Triggers`); a plan that proposes a brand-new `WhenAttacked` trigger is over-engineering — register it as On Defense. Check the CR (`.claude/SWUSim/refs/comprehensive-rules.md`) before building a new trigger type for any "When a unit [verb]s" phrasing.
- **Being the FIRST consumer of a wired-but-unused window = expect latent bugs; test the cross-player path.** A trigger window can be fully wired in the engine yet have **zero registered handlers** (so it has never actually fired). `On Defense` was exactly this until SOR_196 — `HasOnDefenseAbility` had detected `LAW_121`/`TS26_24` but neither had a handler, so the path was effectively untested. Implementing the first real one surfaced **two latent bugs** worth knowing for any defender/opponent-context trigger: (1) **relative-mzID frame** — the trigger's mzID was captured in the *active player's* frame (`theirGroundArena-N`) but dispatched under the *defender's* controller, so it resolved to the attacker; fix is a frame-flip at the `AddTrigger` site (`preg_replace('/^their/','my',$mzID)` — the defender always sits in the attacker's `their` zone). (2) **single-trigger orchestration owner** — `FlushCombatTriggerBag`'s 1-trigger branch queued `RESOLVE_NEXT_TRIGGER`/`SWU_TRIGGER_RESUME` on the *trigger's owner*, which is the active player for On Attack (works) but the **opponent** for On Defense → the orchestration never drained in the attacker's action and combat hung; fix is to queue orchestration on the **active player** (the effect still dispatches under the EffectStack entry's `Controller`, so it applies to the right player). General rule: when a single combat/entry trigger is owned by the non-acting player, its orchestration belongs on the acting player's timeline. Reference: SOR_196 Chewbacca (first On Defense).

- **Cards readied at "the start of the next round" are NOT readied during regroup itself — the ready loop runs at the next ReadyPhase, AFTER the regroup-resource step.** To observe a post-regroup ready state (e.g. SOR_186 "can't ready this round" leaving a unit exhausted while others ready), drive the action phase to a close with consecutive passes (`P2>Pass` then `P1>Pass` reaches regroup), then resolve the regroup-resource `MZMAYCHOOSE` for **both** players with `P1>ResourcePass` + `P2>ResourcePass` — only then does the new round's ready loop run and the state become assertable. A WHEN block that stops at the first regroup prompt asserts mid-regroup (units not yet readied) and fails confusingly.
- **The regression runner's `answerDecision` diverges from the live `ProcessInput` path for TWO consecutive *opponent* (P2) decisions after a turn-swap in the same WHEN block.** `P1OnlyActions` makes P2 auto-pass (it eats P2's decisions); but even *without* it, the in-process runner only applies the FIRST of two consecutive `P2>AnswerDecision`s when a turn-swap (`P1>Attack` → `P2>Pass` → `P1>Play…`) preceded them — the live `TestSchemaStep` path applies both correctly (verify there: both `MZCHOOSE`s 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), **AND `P1>Answer(target/multichoose)` → `P2>Answer(mzchoose/multichoose)`** (caster-decision-then-opponent-decision within the SAME action, no turn swap — SOR_187 caster picks 2 → opponent picks 1; SOR_174 both players keep-2). So the broken case is *narrowly* TWO consecutive `P2` decisions after a turn-swap — NOT "any opponent decision." Write the cross-player test normally (drive `P1>…` then `P2>…`); only fall back to a smoke test for the genuine two-P2-after-swap shape. So: when your card's opponent-choice path can't be driven cleanly in a multi-turn regression test, don't ship a red/brittle test — verify it with a live smoke test and lean on the **shared helper's own single-turn regression** (e.g. `SWUDiscardCards`'s choose-N path is already covered by Pillage; a card that just *calls* it only needs to test its own gating/invocation, not re-prove the helper). Reference: SOR_175 (opponent discards 2-of-3), SOR_187/SOR_233/SOR_174 (Phase F cross-player paths).

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

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

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

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

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

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

**TWI Phase 7-22 lessons (direct-damage → leaders, 16 phases; folded at the autonomous→pair-programmed retro):**
- **⚠⚠ PRE-EXISTING ENGINE BUG — own-play reactions do NOT fire when the played unit enters the SPACE arena.** `SWUCollectOwnPlayReactions` ("when you play a/another unit") is never reached for a space-unit play (reproduced with the untouched LOF_087 Eighth Brother, so it's engine-wide — affects SOR_182/LOF_087/TWI_080/101/018/etc.). `SWUCollectOpponentPlayReactions` likely shares the gap. **When testing a "when you play a unit" reaction, use a GROUND-unit fixture** (a space fixture silently no-ops → the reaction looks broken). Root cause is in the space-play path of `ActivateCard`/`CollectEntryTriggers` (the `$isPlay` own/opponent reaction block at ~GameLogic 6735 isn't reached for space entries) — an **owed engine fix**, not a card bug.
- **Test-writing: the attacker-choose in an "attack with a unit" leader/event action AUTO-RESOLVES with exactly 1 ready unit** (`SWUQueueChooseTarget` → PASSPARAMETER). Do NOT answer it — only answer the attack TARGET's `MZCHOOSE`. Over-answering feeds the (superfluous) attacker answer to the target choose → the attack mis-targets and the assertion is confusing (attacker takes the wrong counter). Seed ≥2 ready units only if you actually want to exercise the attacker pick.
- **⚠ `SWU_PLAYED_VILLAINY` and the other play-flags at `ActivateCard` ~L10030 are in the UNIT branch — EVENTS never reach them.** A per-phase "you played an event this phase" flag (TWI_014 Asajj) must be set in **`OnPlayEvent`** (fires for every event), not the unit-play block. (Clear it at RegroupPhaseStart.) Same trap for any "played a [type] card this phase" flag whose type includes events.
- **Chained-attack `SWU_CHAINED_ATTACK` already supports a bare-TRAIT constraint.** `"0,0,0,{uid},Droid"` (5th CSV field) restricts the chained attacker to Droid units (the resolver's final `elseif` does `!HasTrait($o->CardID, $constraint)`). So "attack with a Droid, then another Droid" (TWI_082) is ~15 lines — no new constraint code. (space/ground/costlt:/costle: are the other constraint forms.)
- **Nested play-from-HAND during an event resolution (TWI_225): TWO gotchas.** (1) The chosen `myHand-N` mzID can go STALE by the time the `#0` continuation runs (the event's `FINISH_PLAY_CARD` block-10 discard shifts the hand) — resolve the chosen object's CardID first, then re-find its CURRENT hand index. (2) The `ActivateCard($p,$mz,false,$discount)` discount floors at 0 but the **off-aspect penalty still applies first** (`cost = SWUComputePlayCost − discount`), so a played unit's cost can be higher than its printed cost minus the discount — budget `printed + aspect_penalty − discount` in the test (a Vigilance/Heroism unit under a Cunning leader is +4). Wrap the nested `ActivateCard` in the `$gTurnPlayer`/`PASS` save-restore.
- **`GetBase($player)` returns an ARRAY — use `$base[0]->Damage`, never `$base->Damage`** (a `$base->Damage` read is null → a "15+ base damage" gate silently never fires).
- **A self-re-queuing budget/capture loop (Cad Bane TWI_187) re-offers with the ORIGINAL zone indices — the board does NOT compact mid-loop** (`DoCaptureUnit`/`SWUDefeatUnit` mark `removed` but the next MZMAYCHOOSE's offered set already excludes them). So the re-offer lists `theirGroundArena-1&2` (not `-0&1`) after capturing index 0 — a test answers the CURRENT offered index, not a recompacted one. (Same family as the resource multi-defeat re-scan.)
- **"Random unit or base" can't be deterministically collapsed** (both bases are always in the pool, ≥2 targets) — implement with `array_rand`, `AddGameLogEntry` a spaceless tag (`TWI202_HIT`), assert `LOGCONTAINS:TWI202_HIT`, and smoke-verify a real target took the damage via `TestSchemaStep`+`GetNextTurn`.
- **Leaders — both sides, fast recipe.** Front `Action:` = `$leaderAbilities["CID"]` (bare CardID; `SWULeaderAction` exhausts the leader BEFORE the closure — the closure only PAYS resources + does the effect + closes). Resource cost → `$leaderActionResourceCosts["CID"]=N`; per-card conditions ("if a friendly unit was defeated this phase", Coordinate, "needs a damaged enemy") → a `case 'CID':` in **`SWULeaderActionAffordable`** (a leader with NO front Action — pure passive + Epic, e.g. Nala Se TWI_001 — needs NO `$leaderAbilities` entry; the affordability guard's `if (!isset($leaderAbilities[$cardID])) return false;` suppresses the phantom glow). Epic deploy is 100% generic (threshold = leader's printed cost — no code). Deployed side = the normal registries under `CID:0` (`$onAttackAbilities`/`$whenPlayedAbilities` for On Attack / When Deployed) + deployed passives as field-presence checks in `ObjectCurrentPower/HP` (`_SWULeaderDeployed($ctrl,'CID')`) or the `HasConditionalKeyword_*` fns; **deployed-side keywords (Sentinel/Restore/Overwhelm) are auto-derived by the generator from `deployTextData`** — no wiring. A deployed "each OTHER friendly unit gains X" is one line in `HasConditionalKeyword_X` gated on `_SWULeaderDeployed`. Test a front side with `myLeader:CID:1` + `UseLeaderAbility`; test a deployed side with `myLeader:CID` + `DeployLeader` (Epic needs ≥ printed-cost resources) then attack, OR `myLeader:CID:1:1` to seed it pre-deployed.
- **`SWUDefeatUnit($player, $mzID)` is 2 args (player FIRST)** — a 1-arg `SWUDefeatUnit($mz)` fatals the whole action; grep an existing call before writing one.
- **Grant tokens `'SENTINEL'`/`'SABOTEUR'`/`'RAID'`(+value)/`'OVERWHELM'` are ready-made registry 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 computes `GetMzID()` while `$playerID = $newController`, so the returned string (e.g. `"myGroundArena-N"`) is relative to `$newController`, NOT your handler's `$playerID`. The single-controller "take control" idiom (LOF_189: `$new = SWUTakeControlOfUnit($player,…); AddTurnEffect($new,'TEMPORARY_STEAL')`) works because `$newController === $player`. But when you take control FOR someone else (a 2P control SWAP — TWI_204 has the OPPONENT take a unit), the returned mzID is in the opponent's frame: set `$playerID = $opp` before `AddTurnEffect($new,…)`/any use, else the marker lands on a slot in the caster's arena (wrong/absent unit). Reuse `TEMPORARY_STEAL` for "control until regroup" — `RegroupPhaseStart` auto-returns each such unit to its Owner (no revert code). Symptom that caught it: only one of the two swapped units reverted at regroup.
- **"Becomes a copy of X" (TWI_116 Clone) = CardID swap + an `IsClone`-style flag + leave-play revert.** CR §2/§9.2: a card's **printed attributes INCLUDE its text box / abilities** — a "copy" shares name/cost/aspects/traits/arena/power/HP **and abilities/keywords**. So set the entering object's `CardID` to the copied card and ALL attribute/ability/keyword/arena/entry-trigger reads resolve for free (place via the copied `CardID`, run `CollectEntryTriggers`/`SWUApplyPassiveEntryGrants` with it so the copied When Played + entry keywords fire). Two must-dos: (1) mark it with a durable flag for the bits the dictionary can't give — the granted trait (`TraitContains` special-case), non-unique (`SWUEnforceUniqueness` skip); (2) **revert `CardID` to the real card on EVERY leave-play** or you corrupt deck/discard ownership — the choke is `SWUAddToDiscard(..., $sourceObject)` (reverts when `$sourceObject->IsClone`), but confirm the defeat path PASSES the object: `SWUDefeatUnit` did **not** originally (fixed to pass `$obj`); also patch capture (`DoCaptureUnit`) + bounce (`SWUBounceUnit`). The intercept for the play-time "may become a copy" choice goes in `SWUBeginPlayCard` (mirror the Piloting Unit/Pilot choice), stashing the chosen CardID in a global that `ActivateCard` consumes.
- **A durable per-object bit needs a REAL schema field — dynamic props aren't serialized.** `$obj->foo = true` (an undeclared property) survives in-process but is DROPPED by the per-action gamestate round-trip in real games (`Serialize()` only writes declared fields). To add one: append `Foo:boolean=false` to the zone's line in `Schemas/SWUSim/GameSchema.txt` (append at END → backward-compatible via the `count($arr) > N` parse guard) AND hand-patch `SWUSim/ZoneClasses.php` for BOTH `GroundArena` and `SpaceArena` (property decl + parse at the new index + serialize) to match what the schema-driven generator would emit. **Guard it with an `UndoCycle` test** — that `SaveVersion→LoadVersion` round-trip is the only in-process test that exercises serialization (a non-persistent field passes every other test but breaks live).
- **`LostAbilities($obj)` is the central "this object has no abilities" cascade (~26 gates):** combat On Attack/On Defense/combat-hit/attack-end triggers, **keyword suppression** (`SWUKeywordSuppressed` calls it), unit-action affordability, reactive-observer collectors, defeat-replacement. To make a whole CLASS lose all abilities (TWI_255 Brain Invaders: "each leader loses all abilities except epic actions"), extend `LostAbilities` with the discriminator (`IsLeaderUnit($obj) && _SWUFieldPresence()`) — put the CHEAP discriminator FIRST so the hot non-leader path short-circuits before any board scan. That covers the deployed side; the **front (undeployed) leader Action is separate** — gate `SWULeaderActionAffordable` (returning false there also stops the direct `SWULeaderAction` dispatch, which re-checks affordability). **Epic deploy is gated by NEITHER** (separate path) — so "except epic actions" is free. Gaps to note: "can't gain abilities" and initiative-/defeat-triggered FRONT reactive abilities aren't centrally gated; deployed-leader field-presence passives that buff OTHER units (keyed on `_SWULeaderDeployed`, not the leader's own abilities) aren't suppressed.
- **Field-wide GRANTED activated Action (TWI_047 Satine: "each unit incl. enemy gains Action […]")** rides `SWUGetUnitActionProvider`'s fallback: after the own-CardID/upgrade checks, `if (_SWUGranterInPlay()) return 'GRANTER_CID'`, then register `$unitAbilities['GRANTER_CID']` + `$unitActionCostKind['GRANTER_CID']='exhaust'`. The provider is object-based, so **each unit's own controller activates it on their turn — enemy units work for their controller with NO `$anyPlayerUnitActions`** (that registry is only for "ANY player uses THIS one unit's action", LAW_156). Affordability is auto-true (no `SWUUnitActionAffordable` case needed) unless the effect has a precondition. A unit with its OWN action keeps it (single-provider model surfaces one — the grant is shadowed for that unit; document it).
- **A novel COMBAT variant (Maul 2-defender) — write a dedicated pragmatic routine, don't refactor the 580-line `SWUCombatDamage`.** Reuse the primitives: `_SWUShieldOrReduceCombat` (shield/reduction), `_SWUConsumeAmidalaPrevent`/`_SWUConsumeAsh062Prevent`/`_SWUDamageUnpreventable` (the prevention chain), the defeat block (discard/leader-return/`SWU_*_DEFEATED` flags/`_SWUUnitDefeatReplacement`), then `SWUExpireTurnEffects(SWU_DUR_ATTACK)` + `CollectCombatStep3Triggers` (batched When Defeated) + `CleanupRemovedCards` + `SWUAfterAction` (guarded by `!_SWUInTriggerResumeMode()`). **Snapshot all powers up front** so simultaneity holds (a defeated defender still deals its counter). Skip exotic per-card modifiers the card can't have and document the gap.
- **Official rulings can POST-DATE the set's CR** (Maul/Clone are TWI; the bundled CR is v7.0). When the CR can't settle a timing/interaction, surface the specific ruling question — the user supplied the official rulings mid-implementation (Maul: base+unit forbidden, Sentinel forces ALL defenders, Overwhelm combined-excess; Clone: capture→rescue re-choose). Don't guess a novel interaction as if the CR covers it.

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

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

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

```bash
# 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:

```bash
# 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`):

```bash
# 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.

```bash
# 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.
