From 377e9c516035191a6d73f8eca05e253d31f39739 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:18:28 +1100 Subject: [PATCH] Enable Residual Poison against Advanced Harkonnen captives Include available ordinary captives, captured foreign gholas and shared Duke in the original random pool. Apply one public ordinary death without execution income, preserve foreign revival custody and other concealed captives, and end Duke tenure. Find the signed Semuta victim by canonical physical identity after captivity ends. Explain the provisional normal-Tanks source composition in controls and developer reference. Validation:9 files74/74, types/lint/build and two actual-rule/rendered-table programmes plus four finite policy continuations pass on one frozen source. Details and exact unchanged-source proof are recorded in the canonical Residual Poison contract. Genuine public Advanced cache purchase, funded deployments, native victory/capture/keep and second battle cover native and captive deaths plus finite completion. Four minimal profiles use legal offered card/readiness actions. Separate controlled cases cover ghola/Duke, last-native return and Semuta claim; no strategic AI refinement. This interpretation remains provisional. Wider interactions, complete modes, final assurance and browser/deployed acceptance remain open. No saved-room/database/reset/restart/deployment operation. --- components/residual-poison.tsx | 5 + docs/BASE_ADVANCED_READINESS_20260907.md | 4 +- docs/CURRENT_STATUS.md | 26 +++ docs/IMPLEMENTATION_STATUS.md | 28 +++ docs/RICHESE_RESIDUAL_POISON_ENGINE_AUDIT.md | 92 +++++++++ docs/RULE_DECISIONS.md | 1 + game/engine.ts | 24 +-- game/reference.ts | 12 +- tests/fixture-residual-poison-capture.ts | 205 +++++++++++++++++++ tests/residual-poison-capture-engine.test.ts | 131 ++++++++++++ tests/residual-poison-custody.test.ts | 107 ++++++++++ tests/residual-poison-engine-review.test.ts | 9 +- tests/residual-poison-engine.test.ts | 3 +- 13 files changed, 619 insertions(+), 28 deletions(-) create mode 100644 tests/fixture-residual-poison-capture.ts create mode 100644 tests/residual-poison-capture-engine.test.ts create mode 100644 tests/residual-poison-custody.test.ts diff --git a/components/residual-poison.tsx b/components/residual-poison.tsx index 989662b8..18fd96ed 100644 --- a/components/residual-poison.tsx +++ b/components/residual-poison.tsx @@ -32,6 +32,11 @@ export function ResidualPoison({ game, act, busy }: PreparationProps) { leader is randomly sent to the Tanks, with no spice bounty, then Residual Poison is discarded. You do not choose the leader.

+ {game.advanced && game.players.some(player => player.faction === 'harkonnen') &&

+ An available captive is included. If selected, that leader’s death + becomes public and captivity ends; other captives stay concealed. + This uses a provisional interpretation of ordinary Tanks handling. +

} {reason && ( diff --git a/docs/BASE_ADVANCED_READINESS_20260907.md b/docs/BASE_ADVANCED_READINESS_20260907.md index 9ef3b73d..49b9cc98 100644 --- a/docs/BASE_ADVANCED_READINESS_20260907.md +++ b/docs/BASE_ADVANCED_READINESS_20260907.md @@ -123,7 +123,7 @@ undone is superseded by the linked record. | Storm, forecast, two spice piles, worm choices | `tests/advanced-phases.test.ts` covers forecasts, first-turn worm handling, two separate piles, per-pile Nexus/rides, additional worms, canceled protection, typed half losses and Weather Control. `game/reference.ts:1810` describes these as focused support. `tests/first-storm.test.ts` plus `docs/IMPLEMENTATION_STATUS.md:901` record the corrected printed storm meridian/player circles. | Do not redo the already corrected first-storm orientation based on the old coverage page. Verify interaction across actual multi-turn Advanced games, and visually inspect two-pile/forecast/worm controls and reloads. | | Spice support, elites, casualty options | `game/combat.ts`, `combatForces` at `game/engine.ts:1437`, `takeBattleLosses` at `:1475`, `tests/combat-karama.test.ts` and `tests/kwisatz.test.ts:237`. | Focused arithmetic and distinct cancellation tests exist, including Fremen support versus doubled elite strength and Sardaukar versus Fremen. Full games must preserve physical and typed force totals, one-elite-per-turn revival limits, actual support cost and survivor allocation. | | Atreides KH and revival | `tests/kwisatz.test.ts` covers seven battle losses, leader/Hero requirement, one territory, death/bonus distinction, explosion and traitor precedence, cancellation privacy, leader-cycle revival and Ghola. `tests/capture.test.ts:341` adds the all-native-captured revival edge. | KH is implemented; separate finished original KH component artwork and full browser control/recovery review remain open. The tests are selected scenarios, not all Advanced interactions. | -| Harkonnen capture | `tests/capture.test.ts` covers optional/canceled capture, random eligible pool, private identity/execution state, one battle, traitor, release, death/revival cycles and JSON privacy. Engine post-battle capture branch is at `:5646`. | Do not conflate base captive handling with unresolved Richese Residual Poison/Assassination capture disclosure. Base capture's integrated post-battle continuation and browser inspection still need acceptance. | +| Harkonnen capture | `tests/capture.test.ts` covers optional/canceled capture, random eligible pool, private identity/execution state, one battle, traitor, release, death/revival cycles and JSON privacy. Historical engine line locations are not current code anchors. | Base capture's integrated post-battle continuation and browser inspection still need acceptance. Residual Poison now has a separately labelled [provisional normal-Tanks composition](RICHESE_RESIDUAL_POISON_ENGINE_AUDIT.md#native-captive-death--18-october-2026); it does not certify base-mode or general Assassination acceptance. | | BG advisors and Worthless conversion | `tests/advisors.test.ts` covers entry/intrusion, lock propagation, pre-shipment flips, advisor occupancy, movement, collection, city benefit, Atomics prohibition, storm/worm/explosion, setup and canceled flips. `tests/bg-karama.test.ts` covers charity, conversion, nested cancellation, commitments and payment. | The public coverage page still calling advisors simply unfinished is stale. Nested conversion and unfunded recovery remain a specific base issue. Test real setup, repeated turns and UI ownership rather than recreating the existing advisor system. | | Guild turn timing and special powers | `tests/advanced-guild.test.ts` covers early, delayed, last and in-between turns and their cancellation. `tests/special-karama.test.ts` covers Harkonnen exchange and Emperor revival. Fremen summoned-worm and Atreides full-plan tests exist elsewhere. | Their presence supports continued integration, not a blanket certification. Ordinary Guild cancellations and the recorded provisional special semantics remain distinct blockers. | | Increased stronghold income/victory | `tests/kwisatz.test.ts:237` checks stacked Advanced income; advisor tests exclude peaceful forces; `game/reference.ts:1835` explains contested storm strongholds, prediction and final-turn fallbacks. | Run targeted official scenario review plus full Advanced games across player counts and alliances. This bounded audit found no new specific victory defect; absence of a found defect is not completion proof. | @@ -142,7 +142,7 @@ Browser evidence in `docs/VISUAL_PLAYTEST.md` explicitly distinguishes isolated For a no-expansion/no-module game, none of the following exists: Richese cache cards/No-Fields, CHOAM Worthless powers/Auditor, Ix forces/mobile stronghold/Poison Tooth/Sandtrout, Tleilaxu Face Dancers/gholas, Ecaz ambassadors/Duke, Moritani terror, or leader skills/stronghold cards/homeworlds/Nexus cards/discoveries/tech tokens. Their missing behavior remains required by the overall goal, but does not itself prevent a genuinely completed six-base-faction Advanced mode from being enabled later. -In particular, Stone/Poison Tooth choice ordering, mixed No-Field battles, Residual Poison secret-captive disclosure, Ornithopter-card/Hajr/advisor composition, and foreign-ghola acquisition are **not** base Advanced blockers. Base city ornithopters, base Ghola card and ordinary Harkonnen capture are separate, relevant mechanisms. Do not hold an otherwise completed base mode for unrelated faction art or copy the expansion guards into it. +In particular, wider Stone/Poison Tooth and No-Field interactions, Residual Poison's provisional captive-death composition, Ornithopter-card/Hajr/advisor combinations, and foreign-ghola acquisition are **not** base Advanced blockers. Base city ornithopters, base Ghola card and ordinary Harkonnen capture are separate, relevant mechanisms. Do not hold an otherwise completed base mode for unrelated faction art or copy expansion guards into it. Follow each canonical feature contract for current prototype evidence rather than treating this dated inventory as absence proof. ## Source basis and limits diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 58064291..923bbff2 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,32 @@ open mode gates. ## Current checkpoint and work +**Residual Poison / Advanced Harkonnen — 18 October. Prototyped / Partial.** +The card now includes available ordinary captives, captured foreign Gholas and +the shared Duke. One selected public death pays no bounty; other living +captives stay concealed. Ghola revival custody, Duke disc/history and last-native +Harkonnen returns remain. Semuta locates the signed physical victim after +temporary control ends. Controls explain provisional ordinary Tanks handling; +this is source composition, not a Residual-specific publisher FAQ. + +A disjoint genuine-fixture lane, bounded source review and owner integration +pass **9 files74/74**, types/lint/build and two original-action/rendered-table +programmes plus four finite policy continuations. Public Advanced start, real +opening bid1/seller purchase2, paid Hark3/Emperor1 desert/Richese1 Carthag +deployments, actual victory/capture/keep and second battle use70 source actions. +Native or captive selection from five eligible discs and battle completion use9 +continuations; one death/discard, zero bounty and unselected concealment remain. +Four profiles make eight legal card/readiness proposals and finish. Separate +controlled probes cover Ghola/Duke, last-native returns and Semuta claim. + +First feedback repaired only two fixture assumptions: Once Around's all-zero +shortcut needs a real opening bid before the seller's final raise, and resolved +discard uses null. No rule change followed frozen feedback. Actual GameTable +and enabled/busy card controls render the provisional consequence. No browser +clicks, visual/deployed or broad combination/assurance acceptance; complete modes +remain open. No saved-room/database/reset/restart/deployment operation. +[Canonical source, interpretation and proof](RICHESE_RESIDUAL_POISON_ENGINE_AUDIT.md#native-captive-death--18-october-2026). + **Sapho Extortion payer order — 18 October. Prototyped / Partial.** The general ordered-action alternative now provisionally reorders only native Extortion's unanswered payer suffix. Public replay preserves original membership and prior diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 16086220..cb7727c1 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,33 @@ # Dune implementation status +## 18 October 2026 — Residual Poison with native Harkonnen captives + +Remove the Advanced Harkonnen and exceptional-custody card exclusions. All +available controlled physical discs participate; one ordinary public death +ends selected captivity with zero bounty. Other living captures stay concealed, +foreign Ghola revival custody remains, and the shared Duke's tenure ends with +his original disc/history intact. Last-native Harkonnen death still returns +survivors. The Semuta suffix locates its signed victim across physical homes +after control ends; it does not replay death. UI explains provisional normal +Tanks handling, separate from execution-for-two and known trained-card capture. + +A disjoint genuine-fixture lane, bounded source review and owner integration +pass9-file74/74, types/lint/build and two original-action/rendered-table programmes +plus four finite policy continuations. Public Advanced start, opening bid1/native +seller purchase2, paid Hark3/Emperor1 desert and Richese1 Carthag shipments, +actual win/capture/keep and second battle use70 source actions. Native/captive +selection from five eligible discs plus final battle uses9 continuations; four +profiles make eight legal card/readiness proposals and finish. Controlled +Ghola/Duke/last-native/Semuta probes are separately labelled. Actual GameTable +and enabled/busy card controls render the consequence. + +First feedback corrected only a seller-auction fixture shortcut and the expected +null resolved-discard sentinel. The whole frozen batch then passes unchanged. +[Canonical source, exact proof and remaining limits](RICHESE_RESIDUAL_POISON_ENGINE_AUDIT.md#native-captive-death--18-october-2026). +No browser/visual/deployed, full combination or complete-mode acceptance. Final +assurance remains due; no saved-room/database/reset/restart/deployment operation. + + ## 18 October 2026 — Sapho Extortion payer ordering The printed ordered-action alternative now has a provisional native Extortion diff --git a/docs/RICHESE_RESIDUAL_POISON_ENGINE_AUDIT.md b/docs/RICHESE_RESIDUAL_POISON_ENGINE_AUDIT.md index b5e0bb74..1b6abd1a 100644 --- a/docs/RICHESE_RESIDUAL_POISON_ENGINE_AUDIT.md +++ b/docs/RICHESE_RESIDUAL_POISON_ENGINE_AUDIT.md @@ -2,6 +2,10 @@ Audit: 2026-09-06. The source findings below informed the bounded runtime integration recorded at the end. Historical hook and proposed-contract sections describe the pre-integration audit; they do not supersede the final checkpoint. Complete expansion compliance remains unverified. +The Advanced Harkonnen exclusion below is superseded by the +[18 October captive-death composition](#native-captive-death--18-october-2026). +Earlier timings, test totals and unimplemented-card statements remain historical. + ## Evidence and confirmed effect The publisher card face says **“Play on your opponent before choosing leaders in a battle.”** It then randomly sends one available leader of that faction to the Tanks, specifies **“No spice is collected for it”**, and requires disposal after use. The enlarged physical face was visually read at `/tmp/dune-rules/choam-residual-reading.png`, from the [photograph of the actual components](https://cdn.anyfinder.eu/assets/QsUJtVktEC87xwK08oLlwnKyaozUWfuaLzG0LFEn67ENEEVgNrDGoITy3608Dqe7), linked by the [product gallery](https://www.tabletopfinder.eu/en/boardgame/32692/dune-choam-richese). The photographer's printing/date is unverified; the evidence is the visible publisher text, not retailer commentary. Photograph SHA256: `9c1d3e65fa33489e7675055ed1fc932fefcb79f6053ee365ddee6737ae10b81f`. @@ -86,3 +90,91 @@ No extra user question was sent. Guaranteed opportunity scheduling and exception - Five original CHOAM portraits bring ordinary coverage to **45/60**, completing the six base factions plus Ixians, Tleilaxu and CHOAM. All forty earlier files and manifest entries remain unchanged. Full-resolution inspection, four existing asset tests, and actual 192px inspector / 80px circular browser review pass. The temporary contact-sheet server was stopped. See [CHOAM_PORTRAITS.md](CHOAM_PORTRAITS.md). Fifteen ordinary portraits and separate special-disc artwork remain unfinished. - Five Richese effects remain inactive: Semuta Drug, Stone Burner, Mirror Weapon, Portable Snooper and Juice of Sapho. Other incomplete faction/module systems and unresolved interactions remain tracked. No full Advanced or expansion game was certified and no start gate was removed. The full goal remains active. +## Native captive death — 18 October 2026 + +**Prototyped / Partial.** Residual Poison now works with Advanced Harkonnen. +The random pool includes every living, currently controlled physical disc +available in the battle territory, including a concealed ordinary captive, +captured foreign Ghola and captured shared Duke. It never chooses a particular +leader at the player's request or redraws to preserve a commitment. + +The selected disc suffers one ordinary death, with zero spice awarded. Its +identity/death becomes public and captivity ends. Unselected living captives +retain their concealment. A foreign Ghola keeps its existing Tleilaxu revival +custody; the shared Duke retains his one physical disc/death history while +temporary tenure and capture underlay end. Ecaz-only Duke revival is unchanged. +The existing common Skill hook returns a dead trained leader's card; no new +replacement assignment is created. Killing the last native Harkonnen leader +still returns surviving captives immediately, without executing them. + +This public death is a **provisional source composition**, not an express +Residual Poison FAQ answer. The authorized supplied Advanced PDF, physical33 +(printed31), permits undisclosed capture but gives the distinct execution-for-two +branch its face-down Tanks instruction. Its physical9 (printed7) supplies +ordinary death-history handling. The [official FAQ, p6](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf) +gives execution its special private inspection and twice-dead treatment. The +physical Residual face specifies no bounty and no face-down exception. Applying +ordinary public Tanks handling avoids inventing a secret death or importing +execution's extra payment/death rule. It does not reveal living captures in +general or replace the [known trained-card inference](LEADER_SKILLS_CAPTURE.md). +The supplied file identity/authority remains in +[the source register](RULE_DECISIONS.md#authorized-source-amendment--1-october-2026). + +The source-specific configuration/custody bans are removed. Existing exact-card, +event, participant, pre-leader, interaction and promise checks remain. The +private card control explains the consequence and provisional interpretation +in Advanced Harkonnen tables. Existing minimal policies consume the same legal +server option, without scoring a hidden victim pool or strategic refinement. + +The Semuta suffix now finds the signed victim by its unique physical identity +across native rosters/shared Duke: a dead captive no longer belongs to its former +controller. The signature still binds the target, victim, committed battle and +complete physical state. Claiming the discarded card neither kills again nor +changes readiness or the battle's original continuation. Existing signature +bytes and neutral response procedure remain unchanged. + +### Targeted proof and limits + +One disjoint genuine-fixture lane, bounded source review and owner integration +pass **9 files74/74**, `npm run check:quick`, production build and actual-rule/ +rendered-control smoke on one frozen source: + +- Base commit `6c83148ead958a7071f755b598c76615b02e458c`; source tree + `a0348b55b6ef2e5da6301982693f85c3bce09f2b883bda1b6fbe5669d0e281a9`, + 2,592 tracked/nonignored source files, including only a hash of the preserved + untracked supplied PDF. Proof is local; no PDF is committed or republished. +- `tests/fixture-residual-poison-capture.ts` uses70 original public Advanced + actions: real host start, opening bid1/seller purchase2, Harkonnen3/Emperor1 + Red Chasm and Richese1 Carthag shipments costing6/2/1, actual native battle win, + optional random capture/keep and the second original battle. No postdeal hand, + disc, force, wallet, clock or capture is installed. Only unopened original + Spice positions and authoritative scalar lotteries are selected. +- `tests/residual-poison-capture-engine.test.ts` and runtime smoke select a native + or captive disc from five actual eligible leaders, excluding the Harkonnen + leader already used in Red Chasm. Each uses9 continuation actions through the + shared opportunity, real plans/votes/final acknowledgement and completed + Carthag battle. One death/discard, zero bounty and nonselected concealment + remain. Four minimal profiles make eight legal card/readiness proposals and + finish that battle; no strength calibration is claimed. +- `tests/residual-poison-custody.test.ts` uses separately labelled controlled + probes for ordinary/captured-Ghola/shared-Duke death, last-native Harkonnen + return and Semuta claim after physical custody ends. These are not additional + original-action combined-module programmes. Existing core capture, trained + capture and Homeworld/Semuta card regressions pass in the same affected union. +- Actual GameTable and card components render offered, busy, death and completed + states. The new explanation is visible and busy disables the real play control. + Framework aliases/CSS stubs mean no browser click or visual acceptance. + +First targeted feedback repaired fixture assumptions only: a Once Around seller +gets its final raise after a real positive opening, and the resolved discard +sentinel is null. All affected checks then pass on unchanged rule source. Private +logs, source snapshots, rendered HTML and runtime results are in +`/home/quintine/.local/share/dune/checkpoints/20261018-residual-capture/`; +`frozen-integrated/report.json` is the complete passing batch. Final edits after +this proof are documentation/reference prose/evidence only, separately checked +against all verified file hashes before commit/push. + +Wider combinations, complete modes, final privacy/custody/recovery assurance and +deployed acceptance remain open. No saved-room/database/reset/restart/deployment +operation was performed. + diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 74dc11a7..89a5fd9b 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -877,6 +877,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP | Basic Tleilaxu / Leader Skills | Compose settled native Rihani Face Dancer eligibility, own revival offers and Face Dance death return through the full deck. Zoal copies opposing printed disc value as the Smuggler base; the controller's own modifiers follow the later provisional policy. Advanced foreign gholas and existing skill questions retain separate contracts. | [Integration and sources](TLEILAXU_LEADER_SKILLS.md), [Smuggler policy](SMUGGLER_BATTLE.md#provisional-modifier-policy--17-october-2026) | | Native Ecaz / Leader Skills | Basic/Advanced Ecaz plus classic opponents retains the exact ecaz deck, five native assignment discs, public skills before Loyalty/Traitors and original placement. Ordinary rescue/death/own revival and Ambassador handlers compose. A separately acquired Duke6 may receive the normal Warmaster1, never trained-disc3; surviving release or printed6 death leaves the native trainer intact. Existing-skilled Ecaz paid5 Duke revival preserves the earlier living set-aside result, without authorizing Duke assignment. Advanced Harkonnen and combined Occupy skills remain bounded. | [Native scope and source contracts](LEADER_SKILLS_RUNTIME.md#native-ecaz-ordinary-disc-composition--4-october-2026), [original Duke lifecycle](DUKE_VIDAL_RULES.md) | | Leader Skills | Fourteen physical cards, variant setup order, public/hidden assignment, death return, own-leader revival and captured lower-band effects are sourced. Five battle disciplines, Planetologist movement/Special substitution, Suk Graduate rescues and common lifecycle are prototyped; Rihani, lower Mentat/Bureaucrat/Sandmaster, normal Smuggler and normal Diplomat now have connected follow-ups. Remaining bands and combinations stay missing. Known skilled capture identifies the leader through its unique public card. | [Source contract](LEADER_SKILLS_RULES.md), [capture inference](LEADER_SKILLS_CAPTURE.md), [runtime](LEADER_SKILLS_RUNTIME.md), [Diplomat](DIPLOMAT_DEFENSE.md), [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md) | +| Residual Poison / native captives | Apply one ordinary public no-bounty death to the selected available physical captive, retaining foreign Ghola revival custody and ending shared-Duke tenure. Other living captures remain concealed; last-native Harkonnen death still returns survivors. The normal Tanks composition is provisional, distinct from face-down execution-for-two and the known trained-card inference. Semuta finds the committed victim at its physical home after control ends. | [Source, interpretation and bounded runtime](RICHESE_RESIDUAL_POISON_ENGINE_AUDIT.md#native-captive-death--18-october-2026) | | Classic Leader Skills / Tech Tokens | Fresh classic3..6 Basic/Advanced, base33/all14, canonical unused three tokens and original setup/first-Storm owners. The ordinary leader-skills entry preserves selected Tech and now includes mandatory normal Banker income; native/other overlays retain their existing admission. Tech collects at phase end, independent Banker waits for actual Mentat; neither currency store is a recursive payment producer. Smuggler changes actual priced count; original Suk/Sandmaster precede card cleanup and mandatory token transfer. One token auto-transfers, several require choice, none skips. This composes existing rules, not a new ruling or public activation. | [Scope and evidence](LEADER_SKILLS_RUNTIME.md#classic-tech-tokens-composition--4-october-2026) | | Native E1/E2 Leader Skills / Tech Tokens | Fresh supported Ixian/Tleilaxu/CHOAM/Richese3..6 Basic/Advanced keeps original native deck/roster limits, all14 skills, cache and printed Tech setup. Original typed payments/rescue/card cleanup precede mandatory winner token reward; Tleilaxu A.1–3 expressly preserves that reward before Face Dance. Actual Charity affects Production, not Axlotl; native CHOAM initial income is not poverty Charity. Separate Banker/Mentat bounds and unrelated guards remain. E3/other overlays, public starts and save conversion are excluded. This composes sourced rules, not a new interpretation. | [Scope, supplied physical21/38 and evidence](LEADER_SKILLS_RUNTIME.md#native-e1e2-tech-tokens-composition--4-october-2026) | | Advanced Leader Skills / Stronghold Cards | Fresh classic or supported E1/E2 native2..6 Advanced preserves all14 skills, six unused cards, original decks/cache/wallets and actual end-Mentat claims; optional Tech requires3..6. Arrakeen bank subsidy is separate from actual payer debit/Banker threshold and original CHOAM income. Physical defense/normal discipline, holder tie and Tabr/Tuek income compose original skill outcomes. Suk/native cleanup and mandatory Tech precede Face Dance, retaining original rewards and one killed-skill return. Copied-Diplomat/active skill-Stone and unrelated guards remain. Basic Strongholds, E3/other overlays, public starts and save conversion are excluded. | [Authorized physical21/38, existing effects and evidence](LEADER_SKILLS_RUNTIME.md#advanced-stronghold-composition--4-october-2026) | diff --git a/game/engine.ts b/game/engine.ts index 371c859d..1815cdef 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -6565,9 +6565,11 @@ function treacheryDiscardIntegrity(g: Game) { saphoAggressorIntegrity(g); } else if (continuation?.kind === 'residualPoisonDiscard') { const c = continuation, entry = batch.entries[0], battle = g.battle; - const victim = g.players.some(p => p.id === c.target) - ? controlledLeaders(g, getPlayer(g, c.target)).find(l => l.id === c.victim) - : null; + // Death has already ended capture/temporary Duke control. Find the physical + // disc at its home, rather than requiring its former controller to own it. + const victims = [...g.players.flatMap(p => p.leaders), + ...(g.dukeVidal ? [g.dukeVidal.leader] : [])].filter(l => l.id === c.victim); + const victim = victims.length === 1 ? victims[0] : null; requireRule( g.phase === 6 && battle && battle.event === c.battleEvent && !battle.revealed && !Object.keys(battle.plans).length && @@ -21479,22 +21481,8 @@ function validateResidualPoison( ); const reserved = transferCardBlock(g, p, card); requireRule(!reserved, reserved ?? 'The card is committed.'); - // Guard on public configuration, never on whether a particular hidden captive exists. - requireRule( - !(g.advanced && byFaction(g, 'harkonnen')), - 'Residual Poison with advanced Harkonnen capture awaits the secret-captive disclosure ruling.', - ); const target = getPlayer(g, targetId), controlled = controlledLeaders(g, target); - requireRule( - !controlled.some( - (l) => - l.concealed || - (l.capturedBy && l.gholaBy) || - (l.id === DUKE_VIDAL_ID && (l.capturedBy || l.gholaBy)), - ), - 'This exceptional leader custody is not yet supported for Residual Poison.', - ); const candidates = noFieldRule(() => residualPoisonCandidates(controlled, b.territory), ); @@ -21533,6 +21521,8 @@ function playResidualPoison( Object.assign(victim, dead); delete victim.usedAt; if (victim.capturedBy) { + // Residual Poison is an ordinary public death, not Harkonnen's separate + // face-down execution for two spice. Only the selected captive is revealed. delete victim.capturedBy; delete victim.concealed; } diff --git a/game/reference.ts b/game/reference.ts index cf498ddc..10269b45 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -860,7 +860,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'An available leader must be alive and eligible for this battle territory. A leader used in this same territory remains eligible; one used elsewhere does not. Cheap Hero cards are not leader discs. The card is discarded once, the victim enters the Tanks, and no spice bounty or battle victory is awarded. Force counts and ordinary battle preparation continue.', 'In games with Richese seated, both combatants finish a shared preparation opportunity before submitting plans or a leader-valued Prescience answer. This application timing procedure appears independently of both hands and does not reveal who holds a preparation card. Active responses, decisions and Truthtrance resolve first. A completed readiness step does not itself choose a leader.', 'A foreign ghola retains its recorded revival custody when killed. Duke Vidal is the same physical disc with its death history retained, and his temporary control ends. An involuntary death releases impossible battle promises and can require a previously answered non-leader Prescience question to be answered again; it never redraws the victim to preserve an answer.', - 'The handling of a still-secret Harkonnen captive after this death is unresolved. Advanced games with Harkonnen seated therefore guard this card based on that public configuration, independently of any hidden captive. This is a development limitation, not a printed ban. Legacy battles without a saved preparation event are not retroactively reopened.', + 'Available Harkonnen captives are included. If selected, the captive suffers one public death and captivity ends; unselected living captives stay concealed. This ordinary Tanks interpretation is provisional, distinct from face-down execution for two spice. Foreign Ghola revival custody remains; the shared Duke returns dead and set aside. Legacy battles without a saved preparation event are not retroactively reopened.', 'Saved battle events bind readiness and card actions. Reconnect preserves the opportunity and a committed death. Competing actions use room versions: a stale request cannot repeat the discard or death and must refresh before retrying. All four AI profiles use only their entitled card availability and opponent information.', ], related: [ @@ -874,7 +874,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation', status: 'Partial', detail: - 'Pre-leader timing, uniform eligible-disc selection, automatic death, single discard, custody, promise reconciliation and exact event validation are integrated. Advanced Harkonnen disclosure and full expansion combinations remain unresolved.', + 'Pre-leader timing, uniform eligible-disc selection, automatic death, single discard, custody, promise reconciliation and exact event validation are integrated. Advanced Harkonnen now uses provisional ordinary public captive death; wider combinations remain incomplete.', }, { area: 'Player controls', @@ -892,18 +892,22 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Documentation', status: 'Partial', detail: - 'Explains printed timing and disposal, the shared application opportunity, casualty custody and explicit unresolved disclosure.', + 'Explains printed timing/disposal, shared application opportunity, casualty custody and the provisional public captive-death composition.', }, { area: 'Verification', status: 'Partial', detail: - 'Pure selection, twelve-holder engine, independent review, four-profile AI and persisted concurrent-action tests cover supported boundaries. Full expansion games remain unverified.', + 'Current 74-case affected batch, types/lint/build and two real Advanced cache2/capture/battle programmes cover native/captive death, with four finite legal-policy continuations. Controlled Ghola/Duke/last-native/Semuta probes are separate. Historical persisted evidence does not certify final assurance or full expansion games.', evidence: [ 'tests/residual-poison.test.ts', 'tests/residual-poison-engine.test.ts', 'tests/residual-poison-engine-review.test.ts', 'tests/residual-poison-bots.test.ts', + 'tests/residual-poison-custody.test.ts', + 'tests/residual-poison-capture-engine.test.ts', + 'tests/fixture-residual-poison-capture.ts', + 'docs/RICHESE_RESIDUAL_POISON_ENGINE_AUDIT.md', 'tests/residual-poison-recovery.test.ts', ], }, diff --git a/tests/fixture-residual-poison-capture.ts b/tests/fixture-residual-poison-capture.ts new file mode 100644 index 00000000..87fe3df6 --- /dev/null +++ b/tests/fixture-residual-poison-capture.ts @@ -0,0 +1,205 @@ +import assert from 'node:assert/strict'; +import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { controlsLeader } from '../game/leader-control'; +import { residualPoisonCandidates } from '../game/residual-poison'; +import { richeseCards } from '../game/richese-cards'; +import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; +import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus'; + +const poison = richeseCards().find(card => card.effect === 'residualPoison'); assert.ok(poison); +export const CAPTURE_RESIDUAL_POISON = poison.id; +export type ResidualPoisonCaptureStep = { actor: string; action: Action }; +export const residualPoisonCapturePlayer = (game: Game, actor = 'richese') => { + const player = game.players.find(player => player.id === actor); assert.ok(player); return player; +}; +export const residualPoisonCaptureClean = (game: Game) => !game.response && !game.phaseOpening && + !game.decision && !game.truthtrance && !game.pendingTreacheryDiscard; +function lottery(rolls: readonly number[], operation: () => T): T { + const native = crypto.getRandomValues.bind(crypto); + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + let cursor = 0; + crypto.getRandomValues = (array: V): V => { + if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++] ?? 0xffffffff; + else Reflect.apply(native, crypto, [array]); + return array; + }; + try { return operation(); } + finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} +export function actResidualPoisonCapture(game: Game, actor: string, action: Action, + actions: ResidualPoisonCaptureStep[] = [], rolls?: readonly number[]): Game { + const operation = () => applyAction(game, actor, action); + const next = rolls ? lottery(rolls, operation) : withClassicDiscoveryNexusLottery(operation); + actions.push({ actor, action: structuredClone(action) }); return next; +} +/** Capture and private plans are owned explicit source choices. Ordinary + * optional powers, native responses and auctions continue without spending. */ +export function nextResidualPoisonCaptureStep(game: Game): ResidualPoisonCaptureStep | null { + if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) { + const decision = game.decision; + if (decision?.kind === 'captureOffer' || decision?.kind === 'capturedLeader') return null; + if (decision?.kind === 'guildTiming') return { actor: decision.player, + action: { type: 'decision', take: false } }; + if (decision?.kind === 'richeseCache') { + const card = game.richeseCache!.find(card => card.id === CAPTURE_RESIDUAL_POISON) ?? game.richeseCache![0]; assert.ok(card); + return { actor: decision.player, action: { type: 'decision', event: game.richeseBidding!.event, + card: card.id, method: 'onceAround', direction: 'clockwise' } }; + } + if (decision?.kind === 'richeseUnbid') return { actor: decision.player, + action: { type: 'decision', event: game.richeseBidding!.event, keep: false } }; + } + const lot = game.richeseAuction; + if (residualPoisonCaptureClean(game) && lot?.cardId === CAPTURE_RESIDUAL_POISON && !lot.outcome && lot.active) { + // Once Around gives the seller a final raise only after a positive bid. + if (lot.active === 'richese' || lot.bid === 0) + return { actor: lot.active, action: { type: 'richeseBid', event: lot.event, + amount: lot.active === 'richese' ? 2 : 1, allyPayment: 0 } }; + } + return nextRicheseStrongholdsNativeStep(game); +} +export function advanceResidualPoisonCapture(game: Game, until: (state: Game) => boolean, + actions: ResidualPoisonCaptureStep[] = []): Game { + for (let count = 0; count < 2200; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'The original capture/Poison programme ended before its next control'); + const step = nextResidualPoisonCaptureStep(game); assert.ok(step, 'Choose the actual capture or sealed Battle Plan before advancing'); + game = actResidualPoisonCapture(game, step.actor, step.action, actions); + } + throw Error('The original capture/Poison programme did not reach its finite boundary'); +} +export function residualPoisonCaptureAction(game: Game): Action { + const option = viewGame(game, 'richese').residualPoison; + assert.ok(option && !option.blocked && option.event && option.target === 'harkonnen', + option?.blocked ?? 'The genuine native capture must leave an owned pre-leader Poison opportunity'); + return { type: 'card', card: option.card.id, target: option.target, event: option.event }; +} + +/** Actual Advanced public start, paid cache2, native funded deployments, + * Harkonnen victory followed by capture/keep, and a second original battle. + * Only unopened first-turn Spice positions and original scalar lotteries are + * selected. No dealt hand, leader, force, wallet, clock or capture is installed. */ +export function createResidualPoisonCaptureFixture() { + let game = createGame('RESIDUALCAPTURE', newPlayer('harkonnen', 'Harkonnen', 'harkonnen'), true, ['choam']); + for (const faction of ['emperor', 'richese', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction)); + const actions: ResidualPoisonCaptureStep[] = []; + const act = (actor: string, action: Action) => { game = actResidualPoisonCapture(game, actor, action, actions); }; + const reach = (until: (state: Game) => boolean) => { game = advanceResidualPoisonCapture(game, until, actions); }; + for (const player of game.players) act(player.id, { type: 'ready' }); + const lobby = structuredClone(game); + act(game.host, { type: 'start', advancedPreview: true }); + const setup = structuredClone(game); + reach(state => state.status === 'playing'); + const afterSetup = structuredClone(game), front: Game['spiceDeck'] = []; + for (let count = 0; count < 2; count++) { + const index = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(index >= 0); + front.push(game.spiceDeck.splice(index, 1)[0]); + } + game.spiceDeck.unshift(...front); + reach(state => residualPoisonCapturePlayer(state).hand.some(card => card.id === CAPTURE_RESIDUAL_POISON)); + const acquired = structuredClone(game), shipments: { + before: Game; step: ResidualPoisonCaptureStep; after: Game; + }[] = []; + for (const [actor, territory, sector, amount] of [ + ['harkonnen', 'red_chasm', 7, 3], ['emperor', 'red_chasm', 7, 1], ['richese', 'carthag', 11, 1], + ] as const) { + reach(state => state.phase === 5 && state.active === actor && residualPoisonCaptureClean(state)); + assert.equal(game.turn, 1); + const before = structuredClone(game), step: ResidualPoisonCaptureStep = { actor, action: { + type: 'ship', territory, sector, amount, elite: 0, allyPayment: 0, + } }; + act(step.actor, step.action); reach(residualPoisonCaptureClean); + shipments.push({ before, step, after: structuredClone(game) }); + act(actor, { type: 'endMovement' }); + } + reach(state => state.phase === 6 && state.active === 'harkonnen' && residualPoisonCaptureClean(state)); + const beforeFirstBattle = structuredClone(game), firstChoice: ResidualPoisonCaptureStep = { actor: 'harkonnen', + action: { type: 'chooseBattle', territory: 'red_chasm', target: 'emperor' } }; + act(firstChoice.actor, firstChoice.action); + reach(state => !!state.battle?.preLeader?.closed && !state.battle.preparation && residualPoisonCaptureClean(state)); + const firstBattle = structuredClone(game); + const winnerLeader = [...residualPoisonCapturePlayer(game, 'harkonnen').leaders] + .filter(leader => !leader.dead && !leader.usedAt).sort((a, b) => b.strength - a.strength)[0]; + const loserLeader = [...residualPoisonCapturePlayer(game, 'emperor').leaders] + .filter(leader => !leader.dead && !leader.usedAt).sort((a, b) => a.strength - b.strength)[0]; + assert.ok(winnerLeader && loserLeader && winnerLeader.strength + 1 > loserLeader.strength); + const firstPlans: ResidualPoisonCaptureStep[] = [ + { actor: 'harkonnen', action: { type: 'battlePlan', leader: winnerLeader.id, dial: 1, support: 1 } }, + { actor: 'emperor', action: { type: 'battlePlan', leader: loserLeader.id, dial: 0, support: 0 } }, + ]; + for (const step of firstPlans) act(step.actor, step.action); + reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'captureOffer'); + const captureOffer = structuredClone(game); + act('harkonnen', { type: 'decision', accept: true }); + const captureDeclared = structuredClone(game); + reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'capturedLeader'); + const captured = structuredClone(game), decision = game.decision; + assert.ok(decision?.kind === 'capturedLeader'); + const captive = decision.leader; + assert.equal(decision.owner, 'emperor'); + act('harkonnen', { type: 'decision', mode: 'keep' }); + reach(state => !state.battle && residualPoisonCaptureClean(state)); + const afterCapture = structuredClone(game); + assert.ok(residualPoisonCapturePlayer(game, 'emperor').leaders.find(leader => leader.id === captive)?.concealed); + assert.equal(game.active, 'harkonnen'); + const beforeBattle = structuredClone(game), choice: ResidualPoisonCaptureStep = { actor: 'harkonnen', + action: { type: 'chooseBattle', territory: 'carthag', target: 'richese' } }; + act(choice.actor, choice.action); + reach(state => !!state.battle?.preLeader && !state.battle.preLeader.closed && !state.battle.preparation && residualPoisonCaptureClean(state)); + assert.deepEqual(game.battle!.plans, {}); + return { game, lobby, setup, afterSetup, acquired, shipments, beforeFirstBattle, firstChoice, firstBattle, + firstPlans, captureOffer, captureDeclared, captured, afterCapture, beforeBattle, choice, captive, + firstHarkonnenLeader: winnerLeader.id, owner: 'richese', target: 'harkonnen', nativeOwner: 'emperor', + observer: 'guild', card: CAPTURE_RESIDUAL_POISON, sourceActionCount: actions.length, actions, + staging: ['Only unopened original land Spice positions and scalar lotteries are selected. Public Advanced host/setup, an actual opening bid1 and native seller raise/purchase2, Harkonnen3/Emperor1 desert and Richese1 Carthag shipments, actual Harkonnen victory, random capture and keep create this source. No postdeal hand, leader, counter, wallet, phase, battle or custody receipt is installed.'] }; +} +export type ResidualPoisonCaptureFixture = ReturnType; + +/** The test lottery observes the actual controlled source pool. The request + * carries no victim, pool or random index, and the engine owns the death. */ +export function useResidualPoisonCapture(fixture: ResidualPoisonCaptureFixture, branch: 'native' | 'captive') { + let game = structuredClone(fixture.game); + const target = residualPoisonCapturePlayer(game, fixture.target); + const pool = residualPoisonCandidates(game.players.flatMap(owner => owner.leaders.filter(leader => controlsLeader(target, leader))), + game.battle!.territory); + const selected = branch === 'captive' ? pool.find(leader => leader.id === fixture.captive) + : pool.find(leader => leader.faction === 'harkonnen'); + assert.ok(selected); + const working = pool.map(leader => leader.id), desired = [selected.id, ...working.filter(id => id !== selected.id)], rolls: number[] = []; + for (let index = working.length - 1; index > 0; index--) { + const from = working.indexOf(desired[index]); assert.ok(from >= 0 && from <= index); + rolls.push(Math.floor((from + 0.5) * 0x100000000 / (index + 1))); + [working[index], working[from]] = [working[from], working[index]]; + } + const actions: ResidualPoisonCaptureStep[] = [], action = residualPoisonCaptureAction(game); + const before = structuredClone(game); + game = actResidualPoisonCapture(game, fixture.owner, action, actions, rolls); + assert.equal(game.players.flatMap(player => player.leaders).find(leader => leader.id === selected.id)!.dead, true); + return { game, before, action, victim: selected.id, branch, rolls, poolIds: pool.map(leader => leader.id), actions }; +} + +/** Both participants finish the real shared opportunity and seal ordinary + * no-weapon plans using available native discs. Richese wins without killing + * another leader; an unused living captive remains controlled and concealed. */ +export function completeResidualPoisonCapture(fixture: ResidualPoisonCaptureFixture, state: Game) { + const actions: ResidualPoisonCaptureStep[] = []; + let game = advanceResidualPoisonCapture(structuredClone(state), + game => !!game.battle?.preLeader?.closed && !game.battle.preparation && residualPoisonCaptureClean(game), actions); + const beforePlans = structuredClone(game); + const own = [...residualPoisonCapturePlayer(game, fixture.owner).leaders] + .filter(leader => !leader.dead && !leader.capturedBy && (!leader.usedAt || leader.usedAt === 'carthag')) + .sort((a, b) => b.strength - a.strength)[0]; + const enemy = [...residualPoisonCapturePlayer(game, fixture.target).leaders] + .filter(leader => !leader.dead && !leader.capturedBy && (!leader.usedAt || leader.usedAt === 'carthag')) + .sort((a, b) => a.strength - b.strength)[0]; + assert.ok(own && enemy && own.strength > enemy.strength); + const plans: ResidualPoisonCaptureStep[] = [ + { actor: fixture.target, action: { type: 'battlePlan', leader: enemy.id, dial: 0, support: 0 } }, + { actor: fixture.owner, action: { type: 'battlePlan', leader: own.id, dial: 0, support: 0 } }, + ]; + for (const step of plans) game = actResidualPoisonCapture(game, step.actor, step.action, actions); + game = advanceResidualPoisonCapture(game, game => !game.battle && residualPoisonCaptureClean(game), actions); + return { game, beforePlans, plans, actions }; +} diff --git a/tests/residual-poison-capture-engine.test.ts b/tests/residual-poison-capture-engine.test.ts new file mode 100644 index 00000000..18815df7 --- /dev/null +++ b/tests/residual-poison-capture-engine.test.ts @@ -0,0 +1,131 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { viewGame, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { + actResidualPoisonCapture, advanceResidualPoisonCapture, completeResidualPoisonCapture, + createResidualPoisonCaptureFixture, residualPoisonCaptureClean, residualPoisonCapturePlayer as player, + useResidualPoisonCapture, type ResidualPoisonCaptureFixture, +} from './fixture-residual-poison-capture'; + +const physicalLeader = (game: Game, id: string) => { + const leader = game.players.flatMap(player => player.leaders).find(leader => leader.id === id); assert.ok(leader); return leader; +}; +const publicEmperorLeader = (game: Game, f: ResidualPoisonCaptureFixture) => + viewGame(game, f.observer).players.find(player => player.id === f.nativeOwner)!.leaders.find(leader => leader.id === f.captive)!; +function source(f: ResidualPoisonCaptureFixture) { + assert.equal(f.game.advanced, true); + assert.ok(f.actions.some(step => step.actor === f.game.host && step.action.type === 'start' && step.action.advancedPreview === true)); + assert.ok(f.actions.some(step => step.actor !== f.owner && step.action.type === 'richeseBid' && step.action.amount === 1)); + assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'richeseBid' && step.action.amount === 2)); + assert.ok(player(f.acquired).hand.some(card => card.id === f.card)); + assert.equal(player(f.afterSetup, f.target).forces['carthag:11'], 10); + assert.deepEqual(f.shipments.map(shipment => [shipment.step.actor, shipment.step.action.amount]), + [[f.target, 3], [f.nativeOwner, 1], [f.owner, 1]]); + for (const shipment of f.shipments) { + const cost = shipment.step.actor === f.target ? 6 : shipment.step.actor === f.nativeOwner ? 2 : 1; + assert.equal(player(shipment.before, shipment.step.actor).spice - player(shipment.after, shipment.step.actor).spice, cost); + assert.equal(player(shipment.before, shipment.step.actor).reserves - player(shipment.after, shipment.step.actor).reserves, + shipment.step.action.amount); + } + assert.equal(f.captureOffer.decision?.kind, 'captureOffer'); + assert.ok(f.actions.some(step => step.actor === f.target && step.action.type === 'decision' && step.action.accept === true)); + assert.ok(f.actions.some(step => step.actor === f.target && step.action.type === 'decision' && step.action.mode === 'keep')); + assert.equal(physicalLeader(f.afterCapture, f.captive).capturedBy, f.target); + assert.ok(physicalLeader(f.afterCapture, f.captive).concealed); + assert.equal(physicalLeader(f.game, f.captive).dead, false); + assert.equal(physicalLeader(f.game, f.firstHarkonnenLeader).usedAt, 'red_chasm'); + assert.equal(publicEmperorLeader(f.game, f).capturedBy, undefined); + assert.equal(publicEmperorLeader(f.game, f).dead, false); + assert.equal(viewGame(f.game, f.observer).players.find(player => player.id === f.target)!.leaders.some(leader => leader.id === f.captive), false); + assert.equal(f.game.battle!.territory, 'carthag'); + assert.equal(f.game.battle!.attacker, f.target); + assert.equal(f.game.battle!.defender, f.owner); + assert.deepEqual(f.game.battle!.plans, {}); + assert.equal(f.game.battle!.preLeader!.closed, false); + assert.equal(viewGame(f.game, f.owner).residualPoison!.blocked, null); + assert.equal(f.sourceActionCount, f.actions.length); +} +function death(f: ResidualPoisonCaptureFixture, result: ReturnType) { + const before = result.before, after = result.game; + assert.equal(result.poolIds.length, 5); + assert.ok(result.poolIds.includes(f.captive)); + assert.equal(result.poolIds.includes(f.firstHarkonnenLeader), false); + const changed = before.players.flatMap(player => player.leaders) + .filter(leader => leader.deaths !== physicalLeader(after, leader.id).deaths); + assert.deepEqual(changed.map(leader => leader.id), [result.victim]); + assert.equal(physicalLeader(after, result.victim).dead, true); + assert.equal(physicalLeader(after, result.victim).deaths, physicalLeader(before, result.victim).deaths + 1); + assert.equal(physicalLeader(after, result.victim).usedAt, undefined); + assert.equal(physicalLeader(after, result.victim).capturedBy, undefined); + assert.equal(physicalLeader(after, result.victim).concealed, undefined); + assert.deepEqual(after.players.map(player => player.spice), before.players.map(player => player.spice), + 'Residual death pays neither a bounty nor the Harkonnen execution2'); + assert.deepEqual(after.players.map(player => ({ forces: player.forces, reserves: player.reserves, tanks: player.tanks })), + before.players.map(player => ({ forces: player.forces, reserves: player.reserves, tanks: player.tanks }))); + assert.equal(after.discard.filter(card => card.id === f.card).length, 1); + assert.equal(player(after).hand.some(card => card.id === f.card), false); + assert.equal(after.richeseCache!.some(card => card.id === f.card), false); + assert.equal(after.battle!.event, before.battle!.event); + assert.deepEqual(after.battle!.plans, {}); + assert.throws(() => actResidualPoisonCapture(after, f.owner, result.action)); +} + +void test('genuine Advanced native Harkonnen Poison death preserves the actually captured nonselected Emperor disc concealed', () => { + const f = createResidualPoisonCaptureFixture(); source(f); + const result = useResidualPoisonCapture(f, 'native'); death(f, result); + assert.equal(physicalLeader(result.game, result.victim).faction, 'harkonnen'); + assert.deepEqual(physicalLeader(result.game, f.captive), physicalLeader(f.game, f.captive)); + assert.deepEqual(publicEmperorLeader(result.game, f), publicEmperorLeader(f.game, f)); + assert.equal(result.game.log.slice(f.game.log.length).some(entry => entry.text.includes(physicalLeader(f.game, f.captive).name)), false); + const finished = completeResidualPoisonCapture(f, result.game); + assert.equal(finished.game.battle, null); + assert.equal(finished.game.discard.filter(card => card.id === f.card).length, 1); + assert.deepEqual(physicalLeader(finished.game, f.captive), physicalLeader(f.game, f.captive)); + assert.equal(player(finished.game).forces['carthag:11'], 1); + assert.ok(finished.actions.some(step => step.action.type === 'battlePreparationReady')); + assert.ok(finished.actions.some(step => step.action.type === 'battleOutcomeReady')); +}); + +void test('genuine Advanced captured Emperor Poison death publishes only its selected normal Tanks death and clears its actual captivity', () => { + const f = createResidualPoisonCaptureFixture(); source(f); + const result = useResidualPoisonCapture(f, 'captive'); death(f, result); + assert.equal(result.victim, f.captive); + assert.equal(physicalLeader(result.game, f.captive).faction, 'emperor'); + assert.equal(publicEmperorLeader(result.game, f).dead, true); + assert.equal(publicEmperorLeader(result.game, f).deaths, 1); + assert.equal(publicEmperorLeader(result.game, f).capturedBy, undefined); + assert.ok(result.game.log.slice(f.game.log.length).some(entry => entry.text.includes(physicalLeader(f.game, f.captive).name))); + assert.equal(player(result.game, f.target).leaders.filter(leader => leader.dead).length, + player(f.game, f.target).leaders.filter(leader => leader.dead).length); + const finished = completeResidualPoisonCapture(f, result.game); + assert.equal(finished.game.battle, null); + assert.equal(physicalLeader(finished.game, f.captive).dead, true); + assert.equal(physicalLeader(finished.game, f.captive).deaths, 1); + assert.equal(physicalLeader(finished.game, f.captive).capturedBy, undefined); + assert.equal(finished.game.discard.filter(card => card.id === f.card).length, 1); +}); + +void test('all four minimal profiles play the original offered Poison against the real captor then continue the actual shared preparation', () => { + const f = createResidualPoisonCaptureFixture(); + for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { + const view = viewGame(f.game, f.owner); + view.players.find(player => player.id === f.owner)!.bot = difficulty; + const action = botActions(view).find(action => action.type === 'card' && action.card === f.card); + assert.ok(action, `${difficulty} uses the real offered owned Poison source`); + assert.equal(action.target, f.target); + assert.equal(action.event, f.game.battle!.event); + let game = actResidualPoisonCapture(structuredClone(f.game), f.owner, action); + assert.equal(game.discard.filter(card => card.id === f.card).length, 1); + const owned = viewGame(game, f.owner); + owned.players.find(player => player.id === f.owner)!.bot = difficulty; + const ready = botActions(owned).find(action => action.type === 'battlePreparationReady'); assert.ok(ready); + game = actResidualPoisonCapture(game, f.owner, ready); + game = advanceResidualPoisonCapture(game, + game => !!game.battle?.preLeader?.closed && residualPoisonCaptureClean(game)); + const planning = viewGame(game, f.owner); + planning.players.find(player => player.id === f.owner)!.bot = difficulty; + const plan = botActions(planning).find(action => action.type === 'battlePlan'); assert.ok(plan); + assert.doesNotThrow(() => actResidualPoisonCapture(game, f.owner, plan)); + } +}); diff --git a/tests/residual-poison-custody.test.ts b/tests/residual-poison-custody.test.ts new file mode 100644 index 00000000..cf2c9a37 --- /dev/null +++ b/tests/residual-poison-custody.test.ts @@ -0,0 +1,107 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, createGame, newPlayer, viewGame, type Game } from '../game/engine'; +import { baseDeck } from '../game/cards'; +import { acquireDuke, captureDuke, createDukeVidal } from '../game/duke-vidal'; +import { richeseCards } from '../game/richese-cards'; + +const card = 'richese-residual-poison'; +const player = (g: Game, id: string) => g.players.find(p => p.id === id)!; +// Controlled custody probes. The separate capture programme earns the real +// cache card, deployments and captive through original actions. +function fixture(kind: 'ordinary' | 'ghola' | 'duke' = 'ordinary', semuta = false) { + let g = createGame('POISONCUSTODY', newPlayer('r', 'Richese', 'richese'), true, ['choam']); + g.players.push(newPlayer('h', 'Harkonnen', 'harkonnen'), + newPlayer('e', 'Original owner', kind === 'duke' ? 'ecaz' : 'emperor')); + if (kind === 'ghola') g.players.push(newPlayer('t', 'Tleilaxu', 'tleilaxu')); + Object.assign(g, { status: 'playing', phase: 6, turn: 2, storm: 18, + active: 'r', order: g.players.map(p => p.id), deck: baseDeck(), richeseCache: richeseCards() }); + for (const p of g.players) { p.hand = []; p.forces = {}; p.spice = 10; } + for (const id of ['r', 'h']) player(g, id).forces = { 'arrakeen:10': 2 }; + const take = (effect: string, owner: string) => { + const index = g.richeseCache!.findIndex(c => c.effect === effect); assert.ok(index >= 0); + player(g, owner).hand.push(g.richeseCache!.splice(index, 1)[0]); + }; + take('residualPoison', 'r'); + if (semuta) { g.semutaPreview = true; take('semutaDrug', 'e'); } + // Native discs remain alive, but their earlier territory use excludes them. + for (const leader of player(g, 'h').leaders) leader.usedAt = 'carthag'; + let victim: string; + if (kind === 'duke') { + g.dukeVidal = captureDuke(acquireDuke(createDukeVidal(), 'e', 2, 'ecaz'), 'h', 'e', 2, 'carthag'); + victim = g.dukeVidal.leader.id; + } else { + const leader = player(g, 'e').leaders[0]; victim = leader.id; + leader.capturedBy = 'h'; + if (kind === 'ghola') leader.gholaBy = 't'; + leader.concealed = { captor: 'h', controller: kind === 'ghola' ? 't' : 'e', dead: false, deaths: 0 }; + } + g = applyAction(g, 'r', { type: 'chooseBattle', territory: 'arrakeen', target: 'h' }); + return { game: g, victim }; +} +function poison(g: Game) { + return applyAction(g, 'r', { type: 'card', card, target: 'h', event: g.battle!.event }); +} +function physical(g: Game, id: string) { + return [...g.players.flatMap(p => p.leaders), ...(g.dukeVidal ? [g.dukeVidal.leader] : [])].find(l => l.id === id)!; +} + +for (const kind of ['ordinary', 'ghola', 'duke'] as const) void test( + `Residual Poison ends ${kind} captivity with one public death and original revival custody`, () => { + const { game, victim } = fixture(kind), before = structuredClone(game); + assert.equal(viewGame(game, 'r').residualPoison?.blocked, null); + const done = poison(game), dead = physical(done, victim); + assert.deepEqual(game, before); + assert.equal(dead.dead, true); assert.equal(dead.deaths, 1); + assert.equal(dead.capturedBy, undefined); assert.equal(dead.concealed, undefined); + assert.equal(dead.gholaBy, kind === 'ghola' ? 't' : undefined); + if (kind === 'duke') { + assert.equal(done.dukeVidal!.controller, null); + assert.equal(done.dukeVidal!.captureVisibility, undefined); + assert.equal(done.dukeVidal!.acquiredTurn, null); + assert.equal(done.dukeVidal!.source, null); + assert.equal(done.players.some(p => p.leaders.some(l => l.id === victim)), false); + } else { + assert.equal(player(done, 'e').leaders.filter(l => l.id === victim).length, 1); + const publicDead = viewGame(done, 'r').players.find(p => p.id === 'e')!.leaders.find(l => l.id === victim)!; + assert.equal(publicDead.dead, true); assert.equal(publicDead.deaths, 1); + assert.equal(publicDead.capturedBy, undefined); + } + assert.deepEqual(done.players.map(p => p.spice), game.players.map(p => p.spice)); + assert.deepEqual(done.players.map(p => p.forces), game.players.map(p => p.forces)); + assert.equal(done.discard.filter(c => c.id === card).length, 1); + assert.equal(done.battle?.event, game.battle?.event); + assert.deepEqual(done.battle?.plans, {}); + assert.match(done.log.at(-1)!.text, /No spice is awarded/); + }); + +void test('killing the last native Harkonnen disc returns living captives without executing them', () => { + const { game, victim } = fixture(); + const natives = player(game, 'h').leaders; + delete natives[0].usedAt; + for (const leader of natives.slice(1)) { leader.dead = true; leader.deaths = 1; } + physical(game, victim).usedAt = 'carthag'; + const done = poison(game), returned = physical(done, victim); + assert.equal(player(done, 'h').leaders[0].dead, true); + assert.equal(returned.dead, false); assert.equal(returned.deaths, 0); + assert.equal(returned.capturedBy, undefined); assert.equal(returned.concealed, undefined); + assert.equal(returned.usedAt, 'carthag'); + assert.deepEqual(done.players.map(p => p.spice), game.players.map(p => p.spice)); + assert.ok(done.log.some(row => /returned 1 captive leaders/.test(row.text))); +}); + +for (const kind of ['ordinary', 'duke'] as const) void test( + `Semuta finds the dead ${kind} physical disc after temporary control ends`, () => { + const { game, victim } = fixture(kind, true); + const pending = poison(game), event = pending.pendingTreacheryDiscard!.batch.event; + assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'residualPoisonDiscard'); + assert.equal(physical(pending, victim).capturedBy, undefined); + assert.equal(viewGame(pending, 'e').semutaReaction?.canCommit, true); + const done = applyAction(pending, 'e', { type: 'semutaCommit', event }); + assert.equal(done.pendingTreacheryDiscard, null); + assert.equal(physical(done, victim).dead, true); assert.equal(physical(done, victim).deaths, 1); + assert.equal(player(done, 'e').hand.filter(c => c.id === card).length, 1); + assert.equal(done.battle?.event, game.battle?.event); + assert.deepEqual(done.players.map(p => p.spice), game.players.map(p => p.spice)); + assert.throws(() => applyAction(done, 'e', { type: 'semutaCommit', event })); + }); diff --git a/tests/residual-poison-engine-review.test.ts b/tests/residual-poison-engine-review.test.ts index a70ceeac..9e324323 100644 --- a/tests/residual-poison-engine-review.test.ts +++ b/tests/residual-poison-engine-review.test.ts @@ -340,16 +340,19 @@ void test('foreign ghola, ordinary captive and separate Duke deaths retain exact } }); -void test('public advanced Harkonnen guard is invariant to captive possession and a legitimately reacquired physical card may be played again', () => { +void test('Advanced Harkonnen no longer disables the card based on captive possession', () => { let g = fixture(); g.advanced = true; g.players[2] = newPlayer('e', 'Harkonnen', 'harkonnen'); g = start(g); const first = viewGame(g, 'r').residualPoison!.blocked; + assert.equal(first, null); g.players[1].leaders[0].capturedBy = 'e'; assert.equal(viewGame(g, 'r').residualPoison!.blocked, first); - assert.match(first!, /advanced Harkonnen/); - g = start(fixture()); +}); + +void test('a legitimately reacquired physical Residual Poison may be played again', () => { + let g = start(fixture()); hold(g, 'r', 'Nullentropy Box'); const all = inventory(g); g = poison(g); diff --git a/tests/residual-poison-engine.test.ts b/tests/residual-poison-engine.test.ts index 54063bf1..0d3b64ba 100644 --- a/tests/residual-poison-engine.test.ts +++ b/tests/residual-poison-engine.test.ts @@ -48,14 +48,13 @@ void test('normal foreign ghola control and Duke tank history are preserved with } }); void test('invalid timing, target, identity and state fail before random selection and preserve the card',t=>{ - for(const problem of ['stale','outsider','target','sealed','prescienceLeader','empty','overflow','advancedCapture','forgedVictim'] as const){ + for(const problem of ['stale','outsider','target','sealed','prescienceLeader','empty','overflow','forgedVictim'] as const){ const g=fixture();const extra:Partial={};let actor='p'; if(problem==='stale')extra.event='stale';if(problem==='outsider')actor='r';if(problem==='target')extra.target='p'; if(problem==='sealed')g.battle!.plans.q={dial:1,leader:g.players[1].leaders[0].id,weapon:null,defense:null,support:0}; if(problem==='prescienceLeader')g.battle!.prescience={player:'p',field:'leader',value:g.players[1].leaders[0].id}; if(problem==='empty')g.players[1].leaders.forEach(l=>{l.dead=true;l.deaths=1;}); if(problem==='overflow')g.players[1].leaders[0].deaths=Number.MAX_SAFE_INTEGER; - if(problem==='advancedCapture'){g.advanced=true;g.players[0].faction='harkonnen';} if(problem==='forgedVictim')extra.leader=g.players[1].leaders[0].id; const before=structuredClone(g);let calls=0;const mock=t.mock.method(crypto,'getRandomValues',()=>{calls++;throw Error('RNG must not run');}); assert.throws(()=>applyAction(g,actor,{type:'card',card,target:'q',event:g.battle!.event,...extra}),problem);assert.deepEqual(g,before);assert.equal(calls,0,problem);mock.mock.restore();