+ Provisional inspection priority: keep the original nonleader Atreides
+ answer if it can still be honored on its own. Both answers must permit
+ one legal Battle Plan, so an extra Cunning answer may need to be
+ answered again for the same field after a leader dies. If the original
+ answer becomes impossible, answer its original field again first.
+ This spends no additional Nexus card and does not redraw the victim.
+
{reason && (
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 47b505e1..52bcf194 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -16,6 +16,41 @@ open mode gates.
## Current checkpoint and work
+**Residual Poison after Cunning — 26 October. Prototyped / Partial.**
+The printed pre-leader window now stays available after two completed nonleader
+Cunning inspections. Resolve one actual random leader death/discard, retain an
+independently possible native answer, then require the extra answer to fit the
+same legal plan. An impossible native answer reopens both original fields in
+order, retaining private observations and the spent Nexus. This ordered-pair
+policy is provisional and specific to Residual; existing Sardaukar cancellation
+retains its other feasible answer. Persistent card guidance explains the priority.
+
+Three disjoint native-source/controlled-reconciliation/control lanes and root
+integration pass **17 files,131/131**, types/lint/build and **five engine/GameTable
+SSR journeys** on unchanged source. Seven native cases, four revised controlled
+engine cases and six controlled rendering cases cover the slice. Genuine public
+Basic108/Advanced120-action sources include an actual cache Residual1 purchase,
+original Nexus draw, Richese1 shipment into Arrakeen and both actual answers;
+one death preserves both commitments and the battle completes legally. Three
+explicitly controlled Advanced resource/leader seams separately prove native
+dial/weapon priority, actual cash-in or Ghola and ordered native/extra re-answers.
+All sources retain45 physical Treachery and12 Nexus cards. The four existing
+profiles use the legal new window; strategy/calibration remain deferred.
+
+Frozen v3 report/logs:
+`/home/quintine/.local/share/dune/checkpoints/20261026-residual-cunning-proof-v3/`;
+journeys/HTML: adjacent `20261026-residual-cunning-runtime/`.
+No saved-room/D1/server, reset/restart, deployment or recovery operation occurs.
+No DOM/hydration, browser/mobile or real clicked-control acceptance is claimed.
+Complete rules/combinations, final assurance and mode gates remain open.
+[Source and selected priority](NEXUS_ATREIDES_RULES.md#residual-poison-and-two-inspected-commitments).
+
+Next concrete function gap: the Caladan High victory reinforcement is still
+decline-only when native Face Dance is pending. Scope its original victory
+army and subsequent physical replacement through
+[the reinforcement contract](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md),
+`openHomeworldVictoryReturn` and `game/homeworld-victory-reinforcement.ts`.
+
**Box shipping-cost recovery — 26 October. Prototyped / Partial.**
Paid Box search now permits an active purchased-shipment cost in discard.
Exact cost recovery preserves its original price/creator/beneficiary and ends
@@ -44,13 +79,6 @@ No saved-room/D1/server, reset/restart, deployment or recovery operation occurs.
DOM/hydration, browser/mobile, complete rules/combinations and final assurance
remain due. [Source and provisional policy](NULLENTROPY_BOX_ENGINE_AUDIT.md#shipping-price-cost-recovery--26-october-2026).
-Next concrete card-function gap: Residual Poison still rejects its pre-leader
-window after two answered Cunning inspections. Scope the existing native-first
-reconciliation rather than postponing the printed card window; see
-[Nexus inspection boundary](NEXUS_ATREIDES_RULES.md#residual-poison-and-two-inspected-commitments),
-`validateResidualPoison`, `reconcileChangedBattleInspections` and
-`tests/nexus-residual-ambiguity.test.ts`.
-
**Reactive Fremen Nexus worm prevention — 26 October. Prototyped / Partial.**
Fremen Nexus Betrayal now responds at actual unmade ordinary/Great Maker
rides, after the original completed effects. Uniform public Nexus-back holders
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index c4fa2950..466767cf 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,36 @@
# Dune implementation status
+## 26 October 2026 — Residual Poison after completed Cunning
+
+**Prototyped / Partial.** Remove the blanket pre-leader refusal after two
+answered nonleader Cunning inspections. The actual one-death/one-discard effect
+uses selected provisional native-first reconciliation. Preserve an independently
+possible native answer; test the extra jointly. If native becomes impossible,
+reopen both original fields in order, preserving observations and the spent
+Nexus. Both normal and original discard continuations opt into this ordered
+Residual policy; Sardaukar cancellation retains its other feasible answer.
+Persistent card guidance explains the composition. No leader-commitment or
+other active-interaction restriction is removed.
+
+Frozen **17-file131/131**, types/lint/build and **five engine/GameTable SSR
+journeys** pass. Two genuine public Basic108/Advanced120-action cache/Nexus/
+shipment/battle sources preserve both answers after one real random death and
+finish the actual battle. Three clearly controlled Advanced resource/leader
+sources separately prove native dial3 cash-in, retained weapon with actual Ghola,
+and impossible-native ordered re-answers. All45 physical Treachery and12 Nexus
+cards remain. Seven native cases, four revised controlled engine cases and six
+rendering supplements cover rejection, commitments and four minimal profiles.
+Feedback repaired a base-only test initializer, omitted native outcome readiness,
+CSS-class/attribute assertions and the unrelated cancellation regression; final
+lint also removed an unnecessary test-array copy.
+
+Proof: `/home/quintine/.local/share/dune/checkpoints/20261026-residual-cunning-proof-v3/`;
+journeys/HTML: adjacent `20261026-residual-cunning-runtime/`.
+No saved-room/D1/server, reset/restart, deployment or recovery operation. No
+DOM/hydration, browser/mobile or actual clicked-control acceptance. Complete
+rules/combinations, final assurance and all mode gates remain open.
+[Source and priority](NEXUS_ATREIDES_RULES.md#residual-poison-and-two-inspected-commitments).
+
## 26 October 2026 — Box recovery of a purchased shipment cost
**Prototyped / Partial.** Remove the blanket pending-refund search ban. Exact
diff --git a/docs/NEXUS_ATREIDES_RULES.md b/docs/NEXUS_ATREIDES_RULES.md
index 384a453c..d042ad2a 100644
--- a/docs/NEXUS_ATREIDES_RULES.md
+++ b/docs/NEXUS_ATREIDES_RULES.md
@@ -70,42 +70,68 @@ The unresolved cases were reported to the coordinator before implementation deci
## Residual Poison and two inspected commitments
-The following is a reasoned counterexample from the existing preparation rules,
-not a reported executed gameplay regression. In Advanced play, CHOAM has four
-ordinary forces in the battle, no spice, one living usable leader, a projectile,
-Ghola and a real Karama, and its cash-in power is unused. Suppose native
-Prescience fixed the dial at three and Cunning fixed the projectile. Before a
-leader death, both answers are reachable: cashing Ghola supplies three spice,
-enough to support the dial, while the living leader permits the projectile.
+**Selected provisional composition — 26 October 2026.** The 14 October
+choose-recommended authority supersedes the earlier blanket product refusal.
+The printed Residual Poison window remains available after two completed
+nonleader Cunning inspections, before either combatant commits a leader.
+Active interactions and exact card commitments still resolve as required.
+This does not make leader-valued inspection commitments poisonable.
-If Residual Poison kills that last leader, each answer can remain feasible by
-itself. Keeping the projectile permits Ghola revival with a lower dial. Keeping
-the dial permits cash-in while omitting the weapon. Keeping both cannot spend
-the same Ghola both on revival and on funding, nor cash the committed projectile.
-There is therefore no generally valid assumption that an earlier answer, a later
-answer, or all individually feasible answers identify the uniquely correct
-surviving set. The retrieved publisher rules do not select between these sets.
+Resolve the one actual random eligible leader death and discard first. Use the
+same existing reconciliation already used after canceled Sardaukar enhancement:
-The coordinator has asked whether the affected player should choose the retained
-commitment, the earlier commitment should take priority, or this combination
-should await an official ruling. The answer is pending. No such priority is
-implemented by this document. Until resolved, the runtime uses an explicit
-development boundary: Residual Poison is unavailable after an answered Cunning
-inspection. This guard depends on the publicly played mode and completion stage,
-not the opponent's hidden cards, spice, or whether a private feasibility search
-happens to find a conflict. It is not a printed prohibition. The independent
-leader-commitment restriction still applies, and this boundary does not prohibit
-the single-element Secret Ally continuation.
+1. Test the original native nonleader answer without obsolete extra bindings
+ or Truthtrance. Keep it if a legal prepared plan can still honor it.
+2. Test the extra Cunning answer jointly with any retained native answer. If
+ no legal plan honors both, reopen the extra answer's original field.
+3. If the native answer itself is impossible, reopen both current bindings.
+ Answer its original field first, then the extra field through the original
+ saved protocol, so the old extra binding cannot constrain the renewed native
+ choice.
+4. Reconcile Truthtrance only after retiring the impossible inspection bindings.
+ Keep earlier private observations and the spent Nexus receipt; re-answering
+ changes neither field selection nor card cost. Never redraw the victim,
+ restore a dead leader or repeat the discard to save a commitment.
-Any later implementation should test complete legal continuations with the
-proposed retired bindings removed on private copies. Testing one binding while
-another impossible binding remains can falsely release a valid dial. Likewise,
-reconciling Truthtrance before retiring an impossible inspection can wrongly
-release an otherwise feasible promise. Preserve historical disclosures and the
-consumed Nexus receipt separately from active bindings; a renewed answer is to
-the original field and does not consume another card. Multiple incomparable
-maximal surviving sets must not acquire an unexplained software tie-break.
+The ordered pair reopening when native becomes impossible applies to Residual
+Poison, including its original discard continuation. Sardaukar cancellation
+retains its existing still-feasible other answer. The native-first priority is
+an explicit online interpretation, not a retrieved publisher choice among
+incomparable surviving answer sets. The earlier source
+question is not a separately answered publisher ruling. Ordinary single-element
+Secret Ally and native inspection paths continue to use their existing rules.
-The focused [public-guard regression](../tests/nexus-residual-ambiguity.test.ts)
-covers the declared implementation boundary and hidden-state independence. It
-does not claim to execute or resolve the counterexample above.
+A resource-exclusive example motivates the joint check: Advanced CHOAM can have
+four ordinary battle forces, no spice, one usable living leader, a projectile,
+Ghola and the one Karama needed to activate its unused cash-in power. Native
+dial3 and Cunning projectile may both be reachable before death by selling
+Ghola for support. After that last usable leader dies, Ghola cannot both be
+sold for support and revive a leader for the projectile. Keeping native dial3
+permits an unarmed plan after cash-in; keeping the projectile alone can permit
+revival with a lower dial. The special-activation Karama is not a second freely
+saleable funding card. The controlled regression must execute the real actions
+and check reachable continuations rather than treating this prose as proof.
+
+[Controlled reconciliation evidence](../tests/nexus-residual-ambiguity.test.ts)
+is separate from [the genuine acquisition/Nexus/battle source](../tests/fixture-residual-cunning.ts)
+and [its runtime cases](../tests/residual-cunning-engine.test.ts).
+Complete combinations and final assurance remain open.
+
+## Focused prototype evidence — 26 October 2026
+
+Frozen17-file131/131, types/lint/build and five engine/GameTable SSR journeys
+pass. Genuine public Basic108/Advanced120-action sources really buy Residual1,
+draw the original Atreides Nexus, ship Richese1 into Arrakeen and answer both
+fields. The actual random death preserves both feasible answers; each battle
+finishes legally. Separate controlled Advanced resource/leader seams execute
+both resource-exclusive priorities and impossible-native ordered re-answers,
+including actual cash-in, Ghola and battle. All45 physical Treachery and12 Nexus
+cards remain. Seven native cases, four controlled engine cases and six rendering
+supplements include four-profile legal participation and existing cancellation
+compatibility. Controlled setup is not original acquisition proof.
+
+Report/logs: `/home/quintine/.local/share/dune/checkpoints/20261026-residual-cunning-proof-v3/`;
+journeys/HTML: adjacent `20261026-residual-cunning-runtime/`.
+No saved-room/D1/server, deployment/restart or recovery operation. No DOM/
+hydration, browser/mobile or clicked-control acceptance; complete combinations,
+full rules and final assurance remain due.
diff --git a/docs/RICHESE_RESIDUAL_POISON_ENGINE_AUDIT.md b/docs/RICHESE_RESIDUAL_POISON_ENGINE_AUDIT.md
index 1b6abd1a..d2a0b795 100644
--- a/docs/RICHESE_RESIDUAL_POISON_ENGINE_AUDIT.md
+++ b/docs/RICHESE_RESIDUAL_POISON_ENGINE_AUDIT.md
@@ -6,6 +6,19 @@ 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.
+## Answered Cunning follow-up — 26 October 2026
+
+The pre-leader window now remains available after two answered nonleader
+Cunning inspections. Under the 14 October choose-recommended authority, use
+the existing native-first reconciliation after the single actual death:
+preserve an independently possible native answer, then require the extra
+answer to permit the same legal plan. Reopen only required original fields;
+retain private observations and the original spent Nexus. No victim redraw,
+second death/discard or new card payment occurs. Leader commitments and other
+active interactions remain guarded. The priority is a provisional online
+composition, not a publisher tie-break among incompatible surviving sets.
+[Canonical inspection composition](NEXUS_ATREIDES_RULES.md#residual-poison-and-two-inspected-commitments).
+
## 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`.
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 5ee5e3d3..12bf3c20 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -1,5 +1,18 @@
# Rule decision index
+## Residual Poison after answered Cunning — 26 October 2026
+
+Under the 14 October choose-recommended authority, remove the product refusal
+for two completed nonleader inspections. Resolve the single actual random death
+and discard, then use the existing native-first reconciliation: preserve an
+independently feasible original native answer, test the extra answer jointly,
+and reopen only required original fields. An impossible native answer reopens
+first. Keep private observation history and the original spent Nexus; do not
+redraw the victim or replay its death. This software priority is provisional,
+not a publisher choice among competing surviving commitments. The printed
+pre-leader restriction and other active-interaction/card commitments still apply.
+[Current composition](NEXUS_ATREIDES_RULES.md#residual-poison-and-two-inspected-commitments).
+
## Box recovery of a purchased shipment cost — 26 October 2026
Under the 14 October choose-recommended authority, extend the selected Semuta
diff --git a/game/engine.ts b/game/engine.ts
index 3646d8af..5cae4903 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -9020,7 +9020,7 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
g.decision = next.resume.decision;
g.pendingKarama = next.resume.pendingKarama;
g.phaseOpening = next.resume.phaseOpening;
- reconcileChangedBattleInspections(g, 'the leader death');
+ reconcileChangedBattleInspections(g, 'the leader death', true);
const battle = g.battle!;
if (battle.preLeader && !battle.preLeader.closed)
battle.preLeader.ready = battle.preLeader.ready.filter(id => id !== next.player);
@@ -24804,8 +24804,6 @@ function validateResidualPoison(
![b.attacker, b.defender].some(owner => committedPlanElements(b, owner).some(element => element.field === 'leader')),
'Residual Poison must be played before either combatant commits a leader.',
);
- requireRule(!(b.nexusInspection?.mode === 'cunning' && b.nexusInspection.stage === 'answered'),
- 'Residual Poison after two inspected elements awaits the ruling for incompatible surviving commitments.');
requireRule(
!g.truthtrance && !g.response && !g.decision && !g.phaseOpening,
'Resolve the current interaction before playing Residual Poison.',
@@ -24887,12 +24885,15 @@ function playResidualPoison(
return;
}
}
- reconcileChangedBattleInspections(g, 'the leader death');
+ reconcileChangedBattleInspections(g, 'the leader death', true);
if (b.preLeader && !b.preLeader.closed)
b.preLeader.ready = b.preLeader.ready.filter((id) => id !== p.id);
}
-function reconcileChangedBattleInspections(g: Game, cause: string) {
+function reconcileChangedBattleInspections(g: Game, cause: string, renewExtraWithNative = false) {
const b = g.battle!;
+ // Selected provisional priority: preserve an independently feasible native
+ // answer first, then require the extra answer to be jointly feasible. This
+ // is also used after Residual Poison; never redraw its real leader death.
// Retire an inspection made impossible by the opposing effect. Testing on
// a private copy without Truthtrance avoids releasing a still-feasible truth
// answer solely because an obsolete inspection was left in the trial.
@@ -24913,6 +24914,11 @@ function reconcileChangedBattleInspections(g: Game, cause: string) {
if (b.nexusInspection?.mode === 'cunning')
b.nexusInspection = nexusRule(() => reopenNexusNative(battleInspectionContext(g), b.nexusInspection!));
delete insight.value;
+ // An old extra answer must not constrain the renewed native choice.
+ // Keep its observation history, then answer it after the native field.
+ if (renewExtraWithNative && b.nexusInspection?.mode === 'cunning' &&
+ b.nexusInspection.stage === 'answered' && b.nexusInspection.field !== 'leader')
+ b.nexusInspection = nexusRule(() => reopenNexusInspection(battleInspectionContext(g), b.nexusInspection!));
b.preparation = {
kind: 'prescienceAnswer',
owner: answering.id,
diff --git a/game/reference.ts b/game/reference.ts
index b9744f33..b103f14e 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -866,6 +866,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.',
+ 'Two completed nonleader Cunning inspections do not block this pre-leader card window. The provisional priority keeps a still-possible original native answer first, then requires the extra answer to fit the same legal plan. An impossible answer reopens its original field without another Nexus card or another leader death.',
'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.',
],
@@ -880,7 +881,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 now uses provisional ordinary public captive death; wider combinations remain incomplete.',
+ 'Pre-leader timing, uniform eligible-disc selection, automatic death, single discard, custody, promise reconciliation and exact event validation are integrated. Answered nonleader Cunning uses provisional native-first reconciliation; Advanced Harkonnen uses provisional ordinary public captive death. Wider combinations remain incomplete.',
},
{
area: 'Player controls',
@@ -904,7 +905,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Verification',
status: 'Partial',
detail:
- '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.',
+ 'Focused pure/engine/control/bot cases cover native/captive death and the pre-leader timing. Genuine Basic/Advanced cache acquisition, Nexus Cunning and battle sources cover two surviving disclosures. Controlled resource-exclusive and impossible-answer cases separately exercise native-first re-answer priority. Historical persisted evidence does not certify final assurance or complete expansion games.',
evidence: [
'tests/residual-poison.test.ts',
'tests/residual-poison-engine.test.ts',
@@ -912,6 +913,11 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/residual-poison-bots.test.ts',
'tests/residual-poison-custody.test.ts',
'tests/residual-poison-capture-engine.test.ts',
+ 'tests/residual-cunning-engine.test.ts',
+ 'tests/residual-cunning-controls.test.ts',
+ 'tests/nexus-residual-ambiguity.test.ts',
+ 'tests/fixture-residual-cunning.ts',
+ 'tests/fixture-residual-cunning-priority.ts',
'tests/fixture-residual-poison-capture.ts',
'docs/RICHESE_RESIDUAL_POISON_ENGINE_AUDIT.md',
'tests/residual-poison-recovery.test.ts',
@@ -5390,7 +5396,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Atreides Cunning adds a second, different opposing plan element after the first native answer. Both remain binding. Use it in your own battle before sealing your plan; Karama may cancel the extra attempt while preserving the first disclosure.',
'With Atreides absent, Secret Ally inspects one opposing element in your battle. A sealed plan supplies only that selected answer automatically. Otherwise the opponent chooses an answer that permits a legal plan. Other players receive no private answer.',
'The No-Field restriction names Atreides, so Cunning cannot inspect that dial. A non-Atreides Secret Ally holder may request it without learning the token denomination or other plan elements.',
- 'Residual Poison cannot follow a committed leader. If it makes a single nonleader inspection impossible, the same field is answered again and the earlier observation remains private history. Residual Poison after two completed Cunning inspections awaits a ruling on incompatible surviving commitments.',
+ 'Residual Poison cannot follow a committed leader. After its actual random death, keep the original native nonleader answer if it can still be honored. The extra Cunning answer must permit the same legal plan and may need an answer to that original field again. If the native answer is impossible, reopen its original field first. Earlier private observations and the spent Nexus stay recorded. This native-first priority is provisional; the victim is never redrawn to preserve an answer.',
'Harkonnen Cunning draws one Traitor Card before you choose one held card to return. It can pause another action, including a response or decision. Finish the private return before that action continues; active Truthtrance takes priority. Returning an identity already declared as a traitor does not cancel that declaration.',
'With Harkonnen absent, Secret Ally draws two Traitor Cards during Mentat Pause, then you choose exactly two distinct held identities to return. Newly drawn cards may be returned. Tleilaxu receives Face Dancers and can return newly drawn or existing unrevealed dancers; previously revealed dancers stay protected.',
'The Harkonnen exchange spends the Nexus Card when you draw and shuffles the returned cards into the Traitor Deck after your selection. Your original hand size is restored. Other players see that the exchange is pending, without learning your drawn or returned identities.',
diff --git a/tests/fixture-residual-cunning-priority.ts b/tests/fixture-residual-cunning-priority.ts
new file mode 100644
index 00000000..0b0cf3cb
--- /dev/null
+++ b/tests/fixture-residual-cunning-priority.ts
@@ -0,0 +1,211 @@
+import assert from 'node:assert/strict';
+import {
+ applyAction, createGame, joinGame, newPlayer, viewGame,
+ type Action, type Game,
+} from '../game/engine';
+import { botActions } from '../game/bots';
+import type { Card } from '../game/cards';
+import { drawNexusCard, validateNexusCards } from '../game/nexus-cards';
+import { orderNexusSpice } from './fixture-nexus-cards';
+
+export type ResidualCunningPriorityKind = 'nativeDial' | 'nativeWeapon' | 'nativeImpossible';
+export type ResidualCunningPriorityStep = { actor: string; action: Action };
+export const residualCunningPriorityPlayer = (game: Game, actor = 'c') => {
+ const p = game.players.find(p => p.id === actor); assert.ok(p); return p;
+};
+export const residualCunningPriorityInventory = (game: Game) => {
+ const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
+ ...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? []),
+ ...game.players.flatMap(p => p.hand)];
+ const ids = cards.map(card => card.id).sort(); assert.equal(new Set(ids).size, ids.length); return ids;
+};
+export function actResidualCunningPriority(game: Game, actor: string, action: Action,
+ actions: ResidualCunningPriorityStep[] = []): Game {
+ const next = applyAction(game, actor, action); actions.push({ actor, action: structuredClone(action) }); return next;
+}
+export const residualCunningPriorityClean = (game: Game) => !game.response && !game.decision &&
+ !game.phaseOpening && !game.pendingTreacheryDiscard && !game.truthtrance;
+export function nextResidualCunningPriority(game: Game): ResidualCunningPriorityStep | null {
+ if (game.pendingTreacheryDiscard) {
+ const pending: NonNullable = game.pendingTreacheryDiscard;
+ assert.equal(pending.reaction?.stage, 'offer');
+ const reaction = pending.reaction; assert.ok(reaction);
+ const actor = game.players.find(p => !reaction.passed.includes(p.id)); assert.ok(actor);
+ return { actor: actor.id, action: { type: 'semutaPass', event: pending.batch.event } };
+ }
+ if (game.response) {
+ const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
+ return { actor: actor.id, action: { type: 'passResponse' } };
+ }
+ if (game.phaseOpening) {
+ const actor = game.players.find(p => !game.phaseOpening!.passed.includes(p.id)); assert.ok(actor);
+ return { actor: actor.id, action: { type: 'ready' } };
+ }
+ if (game.decision?.kind === 'fullPlanOffer')
+ return { actor: game.decision.player, action: { type: 'decision', decline: true } };
+ if (game.decision?.kind === 'battleLosses')
+ return { actor: game.decision.player, action: { type: 'decision', choice: 0 } };
+ if (game.decision?.kind === 'battleCards')
+ return { actor: game.decision.player, action: { type: 'decision', discard: [] } };
+ if (game.decision?.kind === 'choamMarket')
+ return { actor: game.decision.player, action: { type: 'decision', done: true } };
+ if (game.battle?.outcomeWindow && !game.battle.outcomeWindow.closed) {
+ const actor = [game.battle.attacker, game.battle.defender]
+ .find(id => !game.battle!.outcomeWindow!.ready.includes(id));
+ assert.ok(actor);
+ return { actor, action: { type: 'battleOutcomeReady', event: game.battle.outcomeWindow.event } };
+ }
+ if (game.battle?.preLeader && !game.battle.preLeader.closed) {
+ const actor = [game.battle.attacker, game.battle.defender].find(id => !game.battle!.preLeader!.ready.includes(id));
+ assert.ok(actor); return { actor, action: { type: 'battlePreparationReady', event: game.battle.event } };
+ }
+ if (game.battle?.preparation?.kind === 'nexusVoice')
+ return { actor: game.battle.preparation.owner, action: { type: 'declineBattlePower' } };
+ if (game.battle?.revealed) {
+ const actor = viewGame(game, game.host).battle!.traitorVoters.find(id => game.battle!.traitorCalls[id] === undefined);
+ if (actor) return { actor, action: { type: 'traitorCall', call: false } };
+ }
+ if (game.phase === 0 && game.stormPending === null) {
+ const actor = game.stormDialers.find(id => game.stormDials[id] === undefined);
+ if (actor) return { actor, action: { type: 'stormDial', amount: 0 } };
+ }
+ if ([0, 1].includes(game.phase)) {
+ const actor = game.players.find(p => !game.ready.includes(p.id));
+ if (actor) return { actor: actor.id, action: { type: 'ready' } };
+ }
+ return null;
+}
+export function advanceResidualCunningPriority(game: Game, until: (state: Game) => boolean,
+ actions: ResidualCunningPriorityStep[] = []): Game {
+ for (let count = 0; count < 160; count++) {
+ if (until(game)) return game;
+ const step = nextResidualCunningPriority(game);
+ assert.ok(step, `Choose the original inspection or plan at ${game.phase}/${game.battle?.preparation?.kind}/${game.decision?.kind}`);
+ game = actResidualCunningPriority(game, step.actor, step.action, actions);
+ }
+ throw Error('The controlled Residual/Cunning continuation did not reach its bounded native control');
+}
+
+/** Original public Advanced A/R/C preview setup, followed by EXPLICIT CONTROLLED
+ * physically conserved hands, casualties, cash and battle position. This is
+ * the resource-conflict supplement, not a native acquisition/loss programme. */
+export function createResidualCunningPriorityFixture(options: { kind?: ResidualCunningPriorityKind } = {}) {
+ const kind = options.kind ?? 'nativeDial', asker = 'a', target = 'c', observer = 'r';
+ let game = createGame('RESIDUALCUNNINGPRIORITY', newPlayer(asker, 'Atreides', 'atreides'), true, ['choam']);
+ joinGame(game, newPlayer(target, 'CHOAM', 'choam')); joinGame(game, newPlayer(observer, 'Richese', 'richese'));
+ const actions: ResidualCunningPriorityStep[] = [];
+ const act = (actor: string, action: Action) => { game = actResidualCunningPriority(game, actor, action, actions); };
+ const reach = (until: (state: Game) => boolean) => { game = advanceResidualCunningPriority(game, until, actions); };
+ act(game.host, { type: 'moduleOption', module: 'nexusCards', enabled: true });
+ for (const p of game.players) act(p.id, { type: 'ready' });
+ const lobby = structuredClone(game); act(game.host, { type: 'start', advancedPreview: true });
+ assert.equal(game.advanced, true); const setup = structuredClone(game);
+ for (let count = 0; game.status === 'setup' && count < 100; count++) {
+ let selected: ResidualCunningPriorityStep | null = null;
+ for (const p of game.players) {
+ const view = viewGame(game, p.id); view.players.find(seat => seat.id === p.id)!.bot = 'Easy';
+ const action = botActions(view)[0]; if (action) { selected = { actor: p.id, action }; break; }
+ }
+ assert.ok(selected, 'The original Advanced Nexus setup needs a legal choice'); act(selected.actor, selected.action);
+ }
+ assert.equal(game.status, 'playing'); const afterSetup = structuredClone(game);
+ const originalInventory = residualCunningPriorityInventory(game); assert.equal(originalInventory.length, 45);
+ const originalLeaders = game.players.map(p => ({ player: p.id, ids: p.leaders.map(l => l.id).sort() }));
+ orderNexusSpice(game, ['land', 'land']);
+ reach(state => state.phase === 2 && residualCunningPriorityClean(state));
+ const beforeControlled = structuredClone(game);
+ for (const p of game.players) {
+ game.deck.push(...p.hand.splice(0)); p.forces = {}; p.reserves = 20; p.tanks = 0;
+ }
+ const hold = (actor: string, predicate: (card: Card) => boolean) => {
+ for (const stock of [game.deck, game.richeseCache!]) {
+ const index = stock.findIndex(predicate);
+ if (index >= 0) { const card = stock.splice(index, 1)[0]; residualCunningPriorityPlayer(game, actor).hand.push(card); return card.id; }
+ }
+ throw Error('The controlled source needs one original physical card');
+ };
+ const residual = hold(asker, card => card.id === 'richese-residual-poison');
+ const projectile = hold(target, card => card.kind === 'projectile');
+ const ghola = kind === 'nativeImpossible' ? null : hold(target, card => card.effect === 'ghola');
+ const karama = kind === 'nativeImpossible' ? null : hold(target, card => card.effect === 'karama');
+ const choam = residualCunningPriorityPlayer(game);
+ const victim = [...choam.leaders].filter(leader => leader.strength > 0).sort((a, b) => b.strength - a.strength)[0];
+ assert.ok(victim);
+ for (const leader of choam.leaders) if (leader.id !== victim.id) { leader.dead = true; leader.deaths = 1; }
+ choam.spice = 0; assert.ok(!choam.specialKaramaUsed);
+ choam.forces = { 'pasty_mesa:5': 4 }; choam.reserves = 16;
+ residualCunningPriorityPlayer(game, asker).forces = { 'pasty_mesa:5': 5 };
+ residualCunningPriorityPlayer(game, asker).reserves = 15;
+ Object.assign(game, { phase: 6, active: asker, order: [asker, target, observer], storm: 18,
+ ready: [], response: null, decision: null, phaseOpening: null });
+ const cards = game.nexusCards!.cards!, index = cards.deck.indexOf('atreides'); assert.ok(index >= 0);
+ cards.deck.splice(index, 1); cards.deck.unshift('atreides');
+ game.nexusCards!.cards = drawNexusCard(cards, asker, game.players, () => 0);
+ const controlled = structuredClone(game);
+ assert.deepEqual(residualCunningPriorityInventory(game), originalInventory); validateNexusCards(game.nexusCards!.cards!, game.players);
+ act(asker, { type: 'chooseBattle', territory: 'pasty_mesa', target });
+ reach(state => state.battle?.preparation?.kind === 'prescience' && !state.response && !state.decision);
+ const beforeAnswers = structuredClone(game);
+ const nativeField = kind === 'nativeDial' ? 'dial' as const : 'weapon' as const;
+ const extraField = kind === 'nativeDial' ? 'weapon' as const : 'dial' as const;
+ const nativeValue = kind === 'nativeDial' ? 3 : projectile;
+ const extraValue = kind === 'nativeDial' ? projectile : kind === 'nativeWeapon' ? 3 : 0;
+ act(asker, { type: 'prescience', field: nativeField });
+ reach(state => !state.response && !state.phaseOpening);
+ act(target, { type: 'prescienceAnswer', value: nativeValue }); reach(residualCunningPriorityClean);
+ const beforeCunning = structuredClone(game);
+ act(asker, { type: 'nexusAtreides', event: game.battle!.event, mode: 'cunning', field: extraField });
+ reach(state => !state.response && !state.phaseOpening);
+ act(target, { type: 'nexusPrescienceAnswer', event: game.battle!.event, value: extraValue });
+ reach(residualCunningPriorityClean);
+ const beforePoison = structuredClone(game), event = game.battle!.event!;
+ assert.equal(game.battle!.nexusInspection?.stage, 'answered'); assert.equal(game.battle!.nexusInspection?.mode, 'cunning');
+ assert.equal(choam.hand.length, kind === 'nativeImpossible' ? 1 : 3);
+ const action: Action = { type: 'card', card: residual, target, event };
+ const nativeReplacement = null, extraReplacement = kind === 'nativeDial' ? null : 0;
+ const cashAction: Action | null = karama && ghola ? { type: 'card', card: karama, mode: 'special', cards: [ghola] } : null;
+ const gholaAction: Action | null = ghola ? { type: 'card', card: ghola, leader: victim.id } : null;
+ const targetPlan: Action = { type: 'battlePlan', dial: kind === 'nativeDial' ? 3 : 0,
+ support: kind === 'nativeDial' ? 3 : 0, leader: kind === 'nativeWeapon' ? victim.id : null,
+ weapon: kind === 'nativeWeapon' ? projectile : null, defense: null };
+ const opposing = [...residualCunningPriorityPlayer(game, asker).leaders].filter(l => !l.dead && !l.usedAt)
+ .sort((a, b) => a.strength - b.strength)[0]; assert.ok(opposing);
+ const opponentPlan: Action = { type: 'battlePlan', dial: 0, support: 0, leader: opposing.id, weapon: null, defense: null };
+ return { game, beforePoison, source: beforePoison, kind, advanced: true as const, asker, owner: asker, target, observer,
+ action, event, residual, projectile, ghola, karama, victim: victim.id, victimStrength: victim.strength,
+ nativeField, nativeValue, extraField, extraValue, nativeReplacement, extraReplacement, cashAction, gholaAction,
+ targetPlan, opponentPlan, lobby, setup, afterSetup, beforeControlled, controlled, beforeAnswers, beforeCunning,
+ originalInventory, originalLeaders, actions, sourceActionCount: actions.length,
+ staging: [
+ 'Original public Advanced-preview A/CHOAM/Richese exact CHOAM+Nexus start and actual setup choices are followed by explicitly controlled, physically conserved battle/cash/card/leader availability. This is not a genuine acquisition, casualty or earned Nexus-draw programme. Starting hands are returned to original stock; original Residual moves cache→A, projectile and (except nativeImpossible) one Ghola plus exactly one activation Karama move stock→CHOAM. All original 45 Treachery/12 Nexus cards and native discs remain unique.',
+ 'Controlled CHOAM has four ordinary Pasty5 counters, zero spice, no ally, one living usable native leader, all other original discs dead once, and unused cash-in. There is no spare Karama, Cheap Hero, Worthless or extra funding. Original first Storm/two land blows complete before the labelled battle boundary. Actual chooseBattle/readiness/inspection/response/Cunning/answer actions create both disclosures and spend the one real Nexus. Actual Residual then selects its only available native leader; no victim or reconciliation result is installed.',
+ ],
+ };
+}
+export type ResidualCunningPriorityFixture = ReturnType;
+
+export function answerResidualCunningPriority(game: Game, fixture: ResidualCunningPriorityFixture,
+ actions: ResidualCunningPriorityStep[] = []): Game {
+ if (fixture.kind === 'nativeImpossible')
+ game = actResidualCunningPriority(game, fixture.target, { type: 'prescienceAnswer', value: fixture.nativeReplacement }, actions);
+ game = actResidualCunningPriority(game, fixture.target,
+ { type: 'nexusPrescienceAnswer', event: fixture.event, value: fixture.extraReplacement }, actions);
+ return advanceResidualCunningPriority(game, residualCunningPriorityClean, actions);
+}
+export function prepareResidualCunningPriority(game: Game, fixture: ResidualCunningPriorityFixture,
+ actions: ResidualCunningPriorityStep[] = []): Game {
+ const p = residualCunningPriorityPlayer(game);
+ const action = fixture.kind === 'nativeDial' && !p.specialKaramaUsed ? fixture.cashAction
+ : fixture.kind === 'nativeWeapon' && p.leaders.find(l => l.id === fixture.victim)?.dead ? fixture.gholaAction : null;
+ if (action) game = actResidualCunningPriority(game, fixture.target, action, actions);
+ return advanceResidualCunningPriority(game, residualCunningPriorityClean, actions);
+}
+export function finishResidualCunningPriority(game: Game, fixture: ResidualCunningPriorityFixture,
+ actions: ResidualCunningPriorityStep[] = []): Game {
+ if (game.battle?.preparation?.kind === 'prescienceAnswer' || game.battle?.preparation?.kind === 'nexusPrescienceAnswer')
+ game = answerResidualCunningPriority(game, fixture, actions);
+ game = prepareResidualCunningPriority(game, fixture, actions);
+ if (!game.battle!.plans[fixture.target]) game = actResidualCunningPriority(game, fixture.target, fixture.targetPlan, actions);
+ if (!game.battle!.plans[fixture.asker]) game = actResidualCunningPriority(game, fixture.asker, fixture.opponentPlan, actions);
+ return advanceResidualCunningPriority(game, state => state.phase === 7 && !state.battle, actions);
+}
diff --git a/tests/fixture-residual-cunning.ts b/tests/fixture-residual-cunning.ts
new file mode 100644
index 00000000..eaf39bf8
--- /dev/null
+++ b/tests/fixture-residual-cunning.ts
@@ -0,0 +1,181 @@
+import assert from 'node:assert/strict';
+import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
+import { validateNexusCards, NEXUS_FACTIONS } from '../game/nexus-cards';
+import { residualPoisonCandidates } from '../game/residual-poison';
+import { CAPTURE_RESIDUAL_POISON, actResidualPoisonCapture,
+ type ResidualPoisonCaptureStep } from './fixture-residual-poison-capture';
+import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
+import { semutaGuildIncomeInventory } from './fixture-semuta-guild-income';
+
+export type ResidualCunningStep = ResidualPoisonCaptureStep;
+export const actResidualCunning = actResidualPoisonCapture;
+export const residualCunningClean = (game: Game) => !game.response && !game.phaseOpening &&
+ !game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
+export function residualCunningPlayer(game: Game, id: string) {
+ const player = game.players.find(player => player.id === id);
+ assert.ok(player, `The original ${id} seat must remain present`);
+ return player;
+}
+export function residualCunningInventory(game: Game) {
+ const treachery = semutaGuildIncomeInventory(game);
+ assert.ok(game.nexusCards?.cards);
+ const cards = game.nexusCards.cards;
+ validateNexusCards(cards, game.players);
+ const nexus = [...cards.deck, ...cards.discard, ...Object.values(cards.hands).filter(card => card !== null)].sort();
+ assert.deepEqual(nexus, [...NEXUS_FACTIONS].sort());
+ return { treachery, nexus };
+}
+export function residualCunningPhysical(game: Game) {
+ return game.players.map(player => ({ id: player.id, spice: player.spice, reserves: player.reserves,
+ tanks: player.tanks, forces: structuredClone(player.forces), elites: structuredClone(player.elites),
+ moved: player.moved, shipped: player.shipped }));
+}
+/** Native setup, no-spend ordinary controls and a genuine one-spice cache bid.
+ * The inspected answer itself and sealed plans are explicit owned choices. */
+export function nextResidualCunningStep(game: Game): ResidualCunningStep | null {
+ if (residualCunningClean(game) && game.nexusCards?.phase?.stage === 'drawing') {
+ const actor = game.nexusCards.phase.eligible.find(id => !game.nexusCards!.phase!.done.includes(id));
+ assert.ok(actor);
+ return { actor, action: { type: 'nexusCardChoice', turn: game.turn,
+ card: game.nexusCards.cards!.hands[actor], choice: 'keep', ownRedraws: 0 } };
+ }
+ if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
+ if (game.decision?.kind === 'richeseCache' && game.richeseCache?.some(card => card.id === CAPTURE_RESIDUAL_POISON))
+ return { actor: game.decision.player, action: { type: 'decision', event: game.richeseBidding!.event,
+ card: CAPTURE_RESIDUAL_POISON, method: 'onceAround', direction: 'counterclockwise' } };
+ if (residualCunningClean(game) && game.richeseAuction?.cardId === CAPTURE_RESIDUAL_POISON &&
+ !game.richeseAuction.outcome && game.richeseAuction.active === 'a' && game.richeseAuction.bid === 0)
+ return { actor: 'a', action: { type: 'richeseBid', event: game.richeseAuction.event, amount: 1, allyPayment: 0 } };
+ }
+ return nextRicheseStrongholdsNativeStep(game);
+}
+export function advanceResidualCunning(game: Game, until: (state: Game) => boolean,
+ actions: ResidualCunningStep[] = []): Game {
+ for (let count = 0; count < 1800; count++) {
+ if (until(game)) return game;
+ assert.notEqual(game.status, 'finished', 'The original Residual/Cunning source ended before its owned boundary');
+ const step = nextResidualCunningStep(game);
+ assert.ok(step, `The native ${game.turn}/${game.phase}/${game.decision?.kind} requires an explicit owned action`);
+ game = actResidualCunning(game, step.actor, step.action, actions);
+ }
+ throw Error('The native Residual/Cunning source did not reach its finite boundary');
+}
+function orderSpice(game: Game, names: readonly string[]) {
+ const cards = names.map(name => {
+ const at = game.spiceDeck.findIndex(card => name === 'worm'
+ ? 'worm' in card && !card.greatMaker && !card.thumper && !card.suppressed
+ : 'territory' in card && !card.discovery && card.territory === name);
+ assert.ok(at >= 0, `The original ${name} Spice Card must remain unopened`);
+ return game.spiceDeck.splice(at, 1)[0];
+ });
+ game.spiceDeck.unshift(...cards);
+}
+
+/** Fresh public native start, paid cache purchase, actual natural Nexus and
+ * own-faction draw, real opposing shipment, and two actual inspected answers.
+ * Only unopened Spice/Nexus positions and scalar lotteries are selected. */
+export function createResidualCunningFixture(options: { advanced?: boolean } = {}) {
+ const advanced = options.advanced ?? false;
+ let game = createGame('RESIDUALCUNNING', newPlayer('r', 'Richese', 'richese'), advanced, ['choam']);
+ for (const [id, name, faction] of [['a', 'Atreides', 'atreides'], ['e', 'Emperor', 'emperor'],
+ ['g', 'Guild', 'guild']] as const) joinGame(game, newPlayer(id, name, faction));
+ const actions: ResidualCunningStep[] = [];
+ const act = (actor: string, action: Action) => { game = actResidualCunning(game, actor, action, actions); };
+ const reach = (until: (state: Game) => boolean) => { game = advanceResidualCunning(game, until, actions); };
+ act(game.host, { type: 'moduleOption', module: 'nexusCards', enabled: true });
+ for (const player of game.players) act(player.id, { type: 'ready' });
+ const initial = structuredClone(game);
+ act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
+ const setup = structuredClone(game);
+ reach(state => state.status === 'playing');
+ const afterSetup = structuredClone(game), originalInventory = residualCunningInventory(game);
+ orderSpice(game, ['broken_land', ...(advanced ? ['south_mesa'] : [])]);
+ reach(state => state.decision?.kind === 'richeseCache');
+ const beforePurchase = structuredClone(game), purchaseStart = actions.length;
+ reach(state => residualCunningPlayer(state, 'a').hand.some(card => card.id === CAPTURE_RESIDUAL_POISON) && residualCunningClean(state));
+ const acquisition = { before: beforePurchase, actions: structuredClone(actions.slice(purchaseStart)), after: structuredClone(game) };
+ reach(state => state.turn === 2 && state.phase === 1 && residualCunningClean(state));
+ const afterFirstTurn = structuredClone(game);
+ orderSpice(game, ['worm', 'hagga_basin', ...(advanced ? ['red_chasm'] : [])]);
+ reach(state => state.nexus && !state.spiceWindow && !state.spiceResolution && residualCunningClean(state));
+ const beforeAlliance = structuredClone(game);
+ act('e', { type: 'alliance', target: 'g' });
+ act('g', { type: 'alliance', target: 'e' });
+ reach(state => state.nexusCards?.phase?.stage === 'drawing' && residualCunningClean(state));
+ const cards = game.nexusCards!.cards!, at = cards.deck.indexOf('atreides');
+ assert.ok(at >= 0, 'The native Atreides Nexus singleton must remain undrawn');
+ cards.deck.unshift(cards.deck.splice(at, 1)[0]);
+ const beforeDraw = structuredClone(game), drawStart = actions.length;
+ for (const actor of game.nexusCards!.phase!.eligible) {
+ act(actor, { type: 'nexusCardChoice', turn: game.turn, card: game.nexusCards!.cards!.hands[actor],
+ choice: actor === 'a' ? 'draw' : 'keep', ownRedraws: 0 });
+ }
+ const afterDraw = structuredClone(game), drawActions = structuredClone(actions.slice(drawStart));
+ assert.equal(game.nexusCards!.cards!.hands.a, 'atreides');
+ reach(state => state.turn === 2 && state.phase === 5 && state.active === 'r' && residualCunningClean(state));
+ const beforeShipment = structuredClone(game), shipment: ResidualCunningStep = { actor: 'r',
+ action: { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1, elite: 0, allyPayment: 0 } };
+ act(shipment.actor, shipment.action); reach(residualCunningClean);
+ const afterShipment = structuredClone(game);
+ reach(state => state.phase === 6 && residualCunningClean(state));
+ assert.ok(game.active === 'r' || game.active === 'a');
+ const beforeBattle = structuredClone(game), chooser = game.active, target = chooser === 'r' ? 'a' : 'r';
+ const choice: ResidualCunningStep = { actor: chooser,
+ action: { type: 'chooseBattle', territory: 'arrakeen', target } };
+ act(choice.actor, choice.action);
+ reach(state => state.battle?.preparation?.kind === 'prescience' && !state.response && !state.phaseOpening && !state.decision);
+ const beforeNative = structuredClone(game), nativeStart = actions.length;
+ act('a', { type: 'prescience', field: 'dial' });
+ reach(state => !state.response && state.battle?.preparation?.kind === 'prescienceAnswer');
+ act('r', { type: 'prescienceAnswer', value: 0 });
+ reach(state => !!state.battle?.preLeader?.closed && !state.battle.preparation && residualCunningClean(state));
+ const afterNative = structuredClone(game), nativeActions = structuredClone(actions.slice(nativeStart));
+ const event = game.battle!.event!, cunningStart = actions.length;
+ const cunning: Action = { type: 'nexusAtreides', event, mode: 'cunning', field: 'weapon' };
+ act('a', cunning);
+ reach(state => !state.response && state.battle?.preparation?.kind === 'nexusPrescienceAnswer');
+ act('r', { type: 'nexusPrescienceAnswer', event, value: null });
+ const cunningActions = structuredClone(actions.slice(cunningStart));
+ assert.equal(game.battle!.nexusInspection!.stage, 'answered');
+ assert.deepEqual(game.battle!.nexusInspection!.answers, [null]);
+ assert.deepEqual(game.battle!.plans, {});
+ assert.equal(game.battle!.preparation ?? null, null);
+ assert.ok(residualPoisonCandidates(residualCunningPlayer(game, 'r').leaders, 'arrakeen').length > 1);
+ const option = viewGame(game, 'a').residualPoison;
+ assert.ok(option && !option.blocked, option?.blocked ?? 'The earned answered-Cunning window must permit the original card');
+ const action: Action = { type: 'card', card: CAPTURE_RESIDUAL_POISON, target: 'r', event };
+ return { game, advanced, holder: 'a', target: 'r', card: CAPTURE_RESIDUAL_POISON, event, action,
+ initial, setup, afterSetup, acquisition, afterFirstTurn, beforeAlliance, beforeDraw, afterDraw, drawActions,
+ beforeShipment, shipment, afterShipment, beforeBattle, choice, beforeNative, afterNative, nativeActions,
+ cunning, cunningActions, originalInventory, originalPhysical: residualCunningPhysical(game),
+ originalPrescience: structuredClone(game.battle!.prescience),
+ originalInspection: structuredClone(game.battle!.nexusInspection),
+ originalLeaders: structuredClone(residualCunningPlayer(game, 'r').leaders),
+ actions, sourceActionCount: actions.length,
+ staging: ['Only unopened original Spice/Nexus positions and scalar lotteries are selected. The public start, paid Residual1 lot, E/G alliance, native Atreides own-card draw, Richese1 Arrakeen shipment, battle, native dial0, shared readiness and Cunning weapon-null answer are actual actions.'] };
+}
+export type ResidualCunningFixture = ReturnType;
+export function playResidualCunning(fixture: ResidualCunningFixture, game = structuredClone(fixture.game),
+ actions: ResidualCunningStep[] = []) {
+ return actResidualCunning(game, fixture.holder, fixture.action, actions);
+}
+/** Seal real legal surviving native leaders and resolve only this battle's
+ * existing votes/disposal/aftermath. No plan, result or later phase is staged. */
+export function finishResidualCunning(fixture: ResidualCunningFixture, game: Game,
+ actions: ResidualCunningStep[] = []) {
+ game = advanceResidualCunning(game, state => !!state.battle?.preLeader?.closed &&
+ !state.battle.preparation && residualCunningClean(state), actions);
+ const target = residualPoisonCandidates(residualCunningPlayer(game, fixture.target).leaders, 'arrakeen')
+ .sort((a, b) => a.strength - b.strength)[0];
+ const owner = residualPoisonCandidates(residualCunningPlayer(game, fixture.holder).leaders, 'arrakeen')
+ .sort((a, b) => b.strength - a.strength)[0];
+ assert.ok(target && owner && owner.strength > target.strength);
+ const plans: ResidualCunningStep[] = [
+ { actor: fixture.target, action: { type: 'battlePlan', leader: target.id, dial: 0, support: 0 } },
+ { actor: fixture.holder, action: { type: 'battlePlan', leader: owner.id, dial: 0, support: 0 } },
+ ];
+ for (const step of plans) game = actResidualCunning(game, step.actor, step.action, actions);
+ const sealed = structuredClone(game);
+ game = advanceResidualCunning(game, state => !state.battle && residualCunningClean(state), actions);
+ return { game, sealed, plans, actions };
+}
diff --git a/tests/nexus-residual-ambiguity.test.ts b/tests/nexus-residual-ambiguity.test.ts
index 93f2542b..acdd9f14 100644
--- a/tests/nexus-residual-ambiguity.test.ts
+++ b/tests/nexus-residual-ambiguity.test.ts
@@ -1,114 +1,136 @@
import test from 'node:test';
import assert from 'node:assert/strict';
+import { applyAction, viewGame, type Action, type Game } from '../game/engine';
+import { nexusInspectionFixture, inspectionNative, inspectionInventory, inspectionResidual } from './fixture-nexus-inspection';
+import { nexusAllow, nexusPlayer } from './fixture-nexus-cards';
import {
- applyAction,
- normalizeAutomaticGame,
- viewGame,
- type Game,
-} from '../game/engine';
-import { baseDeck } from '../game/cards';
-import { richeseCards } from '../game/richese-cards';
-import {
- nexusInspectionFixture,
- inspectionNative,
- inspectionInventory,
-} from './fixture-nexus-inspection';
-import { nexusAllow, nexusPlayer, nexusReload } from './fixture-nexus-cards';
+ createResidualCunningPriorityFixture as fixture, actResidualCunningPriority as act,
+ answerResidualCunningPriority as answer, prepareResidualCunningPriority as prepare,
+ finishResidualCunningPriority as finish, residualCunningPriorityPlayer as player,
+ residualCunningPriorityInventory as inventory,
+ type ResidualCunningPriorityFixture as Fixture, type ResidualCunningPriorityStep as Step,
+} from './fixture-residual-cunning-priority';
-const residual = 'richese-residual-poison';
-function inventory(g: Game): string[] {
- return [
- ...g.deck,
- ...g.discard,
- ...(g.richeseCache ?? []),
- ...g.players.flatMap((p) => p.hand),
- ]
- .map((c) => c.id)
- .sort();
+function reject(game: Game, actor: string, action: Action) {
+ const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
-function answeredCunning(): Game {
- let g = inspectionNative(
- nexusInspectionFixture(false, 'a', { richese: true }),
- 'dial',
- 0,
- );
- // Conserved acquisition seam: move the canonical Residual from the cache
- // initialized by the genuine Richese setup. No purchase is claimed here.
- assert.ok(g.richeseCache);
- const index = g.richeseCache.findIndex((c) => c.id === residual);
- nexusPlayer(g, 'a').hand.push(g.richeseCache.splice(index, 1)[0]);
- assert.equal(viewGame(g, 'a').residualPoison!.blocked, null);
- g = nexusAllow(
- applyAction(g, 'a', {
- type: 'nexusAtreides',
- event: g.battle!.event,
- mode: 'cunning',
- field: 'weapon',
- }),
- );
- g = applyAction(g, 'h', {
- type: 'nexusPrescienceAnswer',
- event: g.battle!.event,
- value: null,
- });
- assert.equal(g.battle!.nexusInspection!.stage, 'answered');
- assert.equal(g.battle!.nexusInspection!.mode, 'cunning');
- inspectionInventory(g);
- return g;
+function source(f: Fixture) {
+ assert.equal(f.lobby.advanced, true); assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true);
+ assert.deepEqual(f.setup.expansions, ['choam']); assert.ok(f.setup.nexusCards?.cards);
+ assert.equal(f.beforeControlled.phase, 2); assert.equal(f.beforeControlled.advanced, true);
+ assert.deepEqual(f.game.players.map(p => ({ player: p.id, ids: p.leaders.map(l => l.id).sort() })), f.originalLeaders);
+ assert.equal(player(f.game).spice, 0); assert.equal(player(f.game).forces['pasty_mesa:5'], 4);
+ assert.equal(player(f.game).reserves, 16); assert.equal(player(f.game).tanks, 0);
+ assert.ok(!player(f.game).specialKaramaUsed); assert.equal(player(f.game).ally, null);
+ assert.deepEqual(player(f.game).leaders.filter(l => !l.dead).map(l => l.id), [f.victim]);
+ assert.equal(player(f.game).hand.length, f.kind === 'nativeImpossible' ? 1 : 3);
+ assert.equal(player(f.game).hand.filter(card => card.effect === 'karama').length, f.karama ? 1 : 0);
+ assert.equal(player(f.game).hand.some(card => card.kind === 'hero' || card.kind === 'worthless'), false);
+ assert.equal(f.originalInventory.length, 45); assert.deepEqual(inventory(f.game), f.originalInventory);
+ assert.deepEqual(f.game.battle!.plans, {}); assert.equal(f.game.battle!.prescience!.value, f.nativeValue);
+ assert.equal(f.game.battle!.nexusInspection!.field, f.extraField);
+ assert.deepEqual(f.game.battle!.nexusInspection!.answers, [f.extraValue]);
+ assert.equal(f.game.nexusCards!.cards!.discard.filter(card => card === 'atreides').length, 1);
+ assert.match(f.staging[0], /CONTROLLED|controlled/); assert.equal(f.sourceActionCount, f.actions.length);
+}
+function poison(f: Fixture, actions: Step[] = []) {
+ source(f); assert.equal(viewGame(f.game, f.asker).residualPoison!.blocked, null);
+ const before = structuredClone(f.game), game = act(f.game, f.asker, f.action, actions);
+ const old = player(before).leaders.find(l => l.id === f.victim)!, dead = player(game).leaders.find(l => l.id === f.victim)!;
+ assert.equal(old.dead, false); assert.equal(dead.dead, true); assert.equal(dead.deaths, old.deaths + 1);
+ assert.deepEqual(player(game).leaders.filter(l => l.id !== f.victim), player(before).leaders.filter(l => l.id !== f.victim));
+ const physical = (g: Game) => g.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks, spice: p.spice }));
+ assert.deepEqual(physical(game), physical(before));
+ assert.equal(game.discard.filter(card => card.id === f.residual).length, 1);
+ assert.equal(game.battle!.event, f.event); assert.equal(game.battle!.revealed, false); assert.deepEqual(game.battle!.plans, {});
+ assert.deepEqual(game.nexusCards, before.nexusCards); assert.deepEqual(inventory(game), f.originalInventory);
+ reject(game, f.asker, f.action); return game;
}
-void test('answered Cunning publicly blocks Residual independently of hidden funding or Ghola, before RNG or mutation', (t) => {
- const original = answeredCunning();
- assert.deepEqual(
- inventory(original),
- [...baseDeck(), ...richeseCards()].map((c) => c.id).sort(),
- );
- const reasons: string[] = [];
- for (const hasGhola of [false, true]) {
- const g = nexusReload(original),
- target = nexusPlayer(g, 'h');
- const index = g.deck.findIndex((c) =>
- hasGhola ? c.effect === 'ghola' : c.kind === 'projectile',
- );
- assert.ok(index >= 0);
- target.hand.push(g.deck.splice(index, 1)[0]);
- target.spice = hasGhola ? 0 : 20;
- const before = nexusReload(g),
- beforeCards = inventory(g);
- const reason = viewGame(g, 'a').residualPoison!.blocked;
- assert.ok(reason);
- assert.match(reason, /two inspected elements.*ruling/i);
- reasons.push(reason);
- for (const p of g.players) assert.doesNotThrow(() => viewGame(g, p.id));
- assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), g);
- let randomCalls = 0;
- const random = t.mock.method(crypto, 'getRandomValues', () => {
- randomCalls++;
- throw Error('Rejected action must not draw a victim');
- });
- try {
- assert.throws(
- () =>
- applyAction(g, 'a', {
- type: 'card',
- card: residual,
- target: 'h',
- event: g.battle!.event,
- }),
- /two inspected elements.*ruling/i,
- );
- } finally {
- random.mock.restore();
- }
- assert.equal(randomCalls, 0);
- assert.deepEqual(g, before);
- assert.deepEqual(inventory(g), beforeCards);
- assert.equal(
- nexusPlayer(g, 'a').hand.filter((c) => c.id === residual).length,
- 1,
- );
- assert.equal(g.battle!.prescience!.value, 0);
- assert.deepEqual(g.battle!.nexusInspection!.answers, [null]);
- }
- assert.equal(reasons[0], reasons[1]);
+void test('controlled Advanced Residual preserves native dial and reopens the resource-exclusive Cunning weapon before actual cash-in and battle', () => {
+ const f = fixture({ kind: 'nativeDial' }), actions: Step[] = [];
+ let game = poison(f, actions); const record = game.battle!.nexusInspection!;
+ assert.equal(game.battle!.prescience!.field, 'dial'); assert.equal(game.battle!.prescience!.value, 3);
+ assert.deepEqual(record.first, { player: f.asker, field: 'dial', value: 3 }); assert.deepEqual(record.firstCurrent, { value: 3 });
+ assert.equal(record.stage, 'answer'); assert.equal(record.field, 'weapon'); assert.deepEqual(record.answers, [f.projectile]);
+ assert.equal(game.battle!.preparation?.kind, 'nexusPrescienceAnswer');
+ // One activation Karama cannot also be sold. Keeping the projectile leaves
+ // only Ghola to fund support3; spending that card removes the sole revival.
+ reject(game, f.target, { type: 'nexusPrescienceAnswer', event: f.event, value: f.projectile });
+ game = answer(game, f, actions);
+ assert.equal(game.battle!.prescience!.value, 3); assert.deepEqual(game.battle!.nexusInspection!.answers, [f.projectile, null]);
+ const nexus = structuredClone(game.nexusCards), deaths = player(game).leaders.map(l => [l.id, l.deaths]);
+ game = prepare(game, f, actions);
+ assert.equal(player(game).spice, 3); assert.equal(player(game).specialKaramaUsed, true);
+ assert.equal(game.discard.filter(card => card.id === f.ghola).length, 1);
+ assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
+ assert.deepEqual(player(game).leaders.map(l => [l.id, l.deaths]), deaths);
+ assert.equal(player(game).leaders.every(l => l.dead), true); assert.deepEqual(game.nexusCards, nexus);
+ assert.ok(player(game).hand.some(card => card.id === f.projectile));
+ reject(game, f.target, { ...f.targetPlan, weapon: f.projectile });
+ game = finish(game, f, actions);
+ assert.equal(game.phase, 7); assert.equal(game.lastBattleContext?.winner, f.target);
+ assert.equal(player(game).forces['pasty_mesa:5'], 1); assert.equal(player(game).tanks, 3); assert.equal(player(game).spice, 0);
+ assert.equal(actions.filter(step => step.action.mode === 'special').length, 1);
+ assert.deepEqual(game.nexusCards, nexus); assert.deepEqual(inventory(game), f.originalInventory);
+});
+
+void test('controlled Advanced Residual preserves native weapon and reanswers the later dial before actual Ghola revival and battle', () => {
+ const f = fixture({ kind: 'nativeWeapon' }), actions: Step[] = [];
+ let game = poison(f, actions);
+ assert.equal(game.battle!.prescience!.value, f.projectile);
+ assert.deepEqual(game.battle!.nexusInspection!.firstCurrent, { value: f.projectile });
+ assert.equal(game.battle!.nexusInspection!.stage, 'answer'); assert.deepEqual(game.battle!.nexusInspection!.answers, [3]);
+ reject(game, f.target, { type: 'nexusPrescienceAnswer', event: f.event, value: 3 });
+ game = answer(game, f, actions);
+ assert.deepEqual(game.battle!.nexusInspection!.answers, [3, 0]); assert.equal(game.battle!.prescience!.value, f.projectile);
+ game = finish(game, f, actions);
+ assert.equal(game.phase, 7); assert.equal(game.lastBattleContext?.winner, f.target);
+ const returned = player(game).leaders.find(l => l.id === f.victim)!;
+ assert.equal(returned.dead, false); assert.equal(returned.deaths, 1);
+ assert.equal(game.discard.filter(card => card.id === f.ghola).length, 1);
+ assert.equal(game.discard.some(card => card.id === f.karama), false); assert.ok(!player(game).specialKaramaUsed);
+ assert.equal(player(game).forces['pasty_mesa:5'], 4);
+ assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'atreides').length, 1);
+ assert.deepEqual(inventory(game), f.originalInventory);
+});
+
+void test('controlled impossible native weapon reopens native then the original Cunning field without another Nexus use', () => {
+ const f = fixture({ kind: 'nativeImpossible' }), actions: Step[] = [];
+ let game = poison(f, actions); const old = structuredClone(game.battle!.nexusInspection!);
+ assert.equal(Object.hasOwn(game.battle!.prescience!, 'value'), false);
+ assert.equal(old.firstCurrent, undefined); assert.equal(old.stage, 'answer'); assert.deepEqual(old.answers, [0]);
+ assert.equal(old.first!.value, f.projectile); assert.equal(game.battle!.preparation?.kind, 'prescienceAnswer');
+ reject(game, f.target, { type: 'nexusPrescienceAnswer', event: f.event, value: 0 });
+ reject(game, f.target, { type: 'prescienceAnswer', value: f.projectile });
+ game = act(game, f.target, { type: 'prescienceAnswer', value: null }, actions);
+ assert.equal(game.battle!.preparation?.kind, 'nexusPrescienceAnswer');
+ assert.equal(game.battle!.nexusInspection!.first!.value, f.projectile);
+ assert.deepEqual(game.battle!.nexusInspection!.firstCurrent, { value: null });
+ assert.deepEqual(game.battle!.nexusInspection!.answers, [0]);
+ game = act(game, f.target, { type: 'nexusPrescienceAnswer', event: f.event, value: 0 }, actions);
+ assert.deepEqual(game.battle!.nexusInspection!.answers, [0, 0]); assert.equal(game.battle!.nexusInspection!.field, 'dial');
+ game = finish(game, f, actions); assert.equal(game.phase, 7); assert.equal(game.lastBattleContext?.winner, f.asker);
+ assert.equal(player(game).leaders.every(l => l.dead), true); assert.equal(player(game).spice, 0);
+ assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'atreides').length, 1);
+ assert.equal(actions.filter(step => step.action.type === 'nexusAtreides').length, 0);
+ assert.deepEqual(inventory(game), f.originalInventory);
+});
+
+void test('answered Basic Cunning remains binding when an actual Residual death leaves both original disclosures feasible', () => {
+ let game = inspectionNative(nexusInspectionFixture(false, 'a', { richese: true }), 'dial', 0);
+ const residual = inspectionResidual(game), event = game.battle!.event!;
+ game = nexusAllow(applyAction(game, 'a', { type: 'nexusAtreides', event, mode: 'cunning', field: 'weapon' }));
+ game = applyAction(game, 'h', { type: 'nexusPrescienceAnswer', event, value: null });
+ const before = structuredClone(game), old = structuredClone(game.battle!.nexusInspection);
+ assert.equal(viewGame(game, 'a').residualPoison!.blocked, null);
+ game = applyAction(game, 'a', { type: 'card', card: residual, target: 'h', event });
+ assert.equal(nexusPlayer(game, 'h').leaders.filter(l => l.dead).length, 1);
+ assert.equal(nexusPlayer(before, 'h').leaders.filter(l => l.dead).length, 0);
+ assert.equal(game.battle!.prescience!.value, 0); assert.deepEqual(game.battle!.nexusInspection, old);
+ assert.equal(game.battle!.nexusInspection!.stage, 'answered'); assert.deepEqual(game.battle!.plans, {});
+ const leader = nexusPlayer(game, 'h').leaders.find(l => !l.dead && !l.usedAt)!;
+ game = applyAction(game, 'h', { type: 'battlePlan', dial: 0, leader: leader.id, weapon: null, defense: null });
+ assert.equal(game.battle!.plans.h.leader, leader.id); assert.equal(game.battle!.plans.h.weapon, null);
+ assert.equal(game.discard.filter(card => card.id === residual).length, 1); inspectionInventory(game);
});
diff --git a/tests/residual-cunning-controls.test.ts b/tests/residual-cunning-controls.test.ts
new file mode 100644
index 00000000..c8f684b6
--- /dev/null
+++ b/tests/residual-cunning-controls.test.ts
@@ -0,0 +1,95 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { ResidualPoison } from '../components/residual-poison';
+import { createGame, joinGame, newPlayer, viewGame, type GameView } from '../game/engine';
+import { richeseCards } from '../game/richese-cards';
+
+// Controlled availability projections supplement the genuine Residual/Cunning
+// source and reconciliation suite. No played battle, answer, victim, card hand
+// or pending Game state is installed. The component owns useId and is always
+// entered through React's server renderer, never invoked as a plain function.
+function projected(): GameView {
+ const game = createGame('RESIDUALCUNNINGCONTROLS', newPlayer('a', 'Atreides', 'atreides'), true, ['choam']);
+ joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
+ joinGame(game, newPlayer('r', 'Richese', 'richese'));
+ const view = viewGame(game, 'a'), card = richeseCards().find(card => card.effect === 'residualPoison');
+ assert.ok(card);
+ view.status = 'playing'; view.phase = 6;
+ view.residualPoison = { card, blocked: null, event: 'controlled-residual-pre-leader-event', target: 'e' };
+ return view;
+}
+function markup(game: GameView, busy = false) {
+ return renderToStaticMarkup(createElement(ResidualPoison, { game, busy, act() {} }));
+}
+function playButton(html: string) {
+ const button = html.match(/