Allow Residual Poison after Cunning inspections

This commit is contained in:
Quintine committed 2026-10-26 07:34:38 +11:00
1 parent 0c848bec74
commit 18bf5d4423
13 files changed
+944 -156

No files matched your search

+8
View File
@@ -37,6 +37,14 @@ export function ResidualPoison({ game, act, busy }: PreparationProps) {
becomes public and captivity ends; other captives stay concealed.
This uses a provisional interpretation of ordinary Tanks handling.
</p>}
<p className="fine" aria-label="Provisional inspection priority">
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.
</p>
<CardRules card={poison.card} />
<CardInspector card={poison.card} />
{reason && (
+35 -7
View File
@@ -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
+31
View File
@@ -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
+61 -35
View File
@@ -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.
@@ -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`.
+13
View File
@@ -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
+11 -5
View File
@@ -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,
+9 -3
View File
@@ -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.',
+211
View File
@@ -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']> = 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<typeof createResidualCunningPriorityFixture>;
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);
}
+181
View File
@@ -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<typeof createResidualCunningFixture>;
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 };
}
+128 -106
View File
@@ -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);
});
+95
View File
@@ -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(/<button\b[^>]*>Play Residual Poison against Emperor<\/button>/)?.[0];
assert.ok(button, 'The original direct Residual action remains readable'); return button;
}
function priority(html: string) {
assert.match(html, /aria-label="Provisional inspection priority"/);
assert.match(html, /keep the original nonleader Atreides answer if it can still be honored on its own/);
assert.match(html, /Both answers must permit one legal Battle Plan/);
assert.match(html, /an extra Cunning answer may need to be answered again for the same field after a leader dies/);
assert.match(html, /If the original answer becomes impossible, answer its original field again first/);
assert.match(html, /This spends no additional Nexus card and does not redraw the victim/);
}
void test('Residual Poison persistently explains provisional native-first Cunning reconciliation without another choice', () => {
const game = projected();
for (let render = 0; render < 3; render++) {
const html = markup(game); priority(html);
assert.match(html, /Residual Poison: before choosing leaders/);
assert.match(html, /leader is randomly sent to the Tanks, with no spice bounty/);
assert.match(html, /You do not choose the leader/);
assert.doesNotMatch(html, /Confirm reconciliation|Acknowledge|Choose a victim|Redraw victim|countdown/);
}
});
void test('a true projected Residual availability remains enabled alongside the Cunning reminder', () => {
const game = projected(), html = markup(game); priority(html);
assert.doesNotMatch(playButton(html), /\sdisabled=/);
assert.doesNotMatch(html, /Cunning.*(?:prevents|forbids) Residual Poison/);
});
void test('busy rendering keeps the inspection priority visible while disabling the original action', () => {
const html = markup(projected(), true); priority(html);
assert.match(playButton(html), /disabled=""/);
});
void test('native unavailable reasons remain authoritative and never imply that committed leaders can be poisoned', () => {
for (const reason of [
'A battle leader has already been committed.',
'Finish the current inspection answer before using Residual Poison.',
'No eligible opposing leader is available.',
]) {
const game = projected(); game.residualPoison!.blocked = reason;
for (let render = 0; render < 2; render++) {
const html = markup(game); priority(html);
assert.ok(html.includes(reason)); assert.match(playButton(html), /disabled=""/);
assert.match(html, /Residual Poison: before choosing leaders/);
}
}
});
void test('missing original event or target still uses the pre-leader fallback rather than enabling the card', () => {
const event = projected(); event.residualPoison!.event = null;
const eventHtml = markup(event); priority(eventHtml);
assert.match(eventHtml, /Use this card against your opponent before battle leaders are chosen/);
assert.match(playButton(eventHtml), /disabled=""/);
const target = projected(); target.residualPoison!.target = null;
const targetHtml = markup(target); priority(targetHtml);
assert.match(targetHtml, /Play Residual Poison against your battle opponent/);
assert.match(targetHtml, /disabled=""[^>]*>Play Residual Poison against your battle opponent/);
const absent = projected(); absent.residualPoison = null;
assert.equal(markup(absent), '');
});
void test('the general priority reminder reads no hidden opposing hand or private inspection answer', () => {
const game = projected();
for (const player of game.players) for (const field of ['hand', 'spice', 'traitors', 'leaders'])
Object.defineProperty(player, field, { get() { throw Error(`Residual guide read private ${field}`); } });
Object.defineProperty(game, 'nexusAtreides', { get() { throw Error('Residual guide inspected private Cunning knowledge'); } });
const html = markup(game); priority(html); assert.doesNotMatch(playButton(html), /\sdisabled=/);
});
+148
View File
@@ -0,0 +1,148 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
actResidualCunning, createResidualCunningFixture, finishResidualCunning,
playResidualCunning, residualCunningInventory, residualCunningPhysical,
residualCunningPlayer as player, type ResidualCunningFixture,
} from './fixture-residual-cunning';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => actResidualCunning(game, actor, action));
assert.deepEqual(game, before);
}
function source(f: ResidualCunningFixture) {
assert.equal(f.game.status, 'playing');
assert.equal(f.game.turn, 2);
assert.equal(f.game.phase, 6);
assert.equal(f.game.response ?? null, null);
assert.equal(f.game.decision ?? null, null);
assert.equal(f.game.pendingTreacheryDiscard ?? null, null);
assert.equal(f.game.battle!.event, f.event);
assert.deepEqual(f.game.battle!.plans, {});
assert.equal(f.game.battle!.preparation ?? null, null);
assert.equal(f.game.battle!.preLeader!.closed, true);
assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.card));
assert.equal(f.acquisition.before.richeseCache!.filter(card => card.id === f.card).length, 1);
assert.ok(f.acquisition.actions.some(step => step.actor === f.holder && step.action.type === 'richeseBid' && step.action.amount === 1));
assert.equal(f.beforeDraw.nexusCards!.cards!.hands[f.holder], null);
assert.equal(f.afterDraw.nexusCards!.cards!.hands[f.holder], 'atreides');
assert.ok(f.drawActions.some(step => step.actor === f.holder && step.action.type === 'nexusCardChoice' &&
step.action.choice === 'draw' && step.action.ownRedraws === 0));
assert.equal(player(f.game, f.holder).ally, null);
assert.equal(player(f.game, 'e').ally, 'g');
assert.equal(player(f.game, 'g').ally, 'e');
assert.equal(f.game.nexusCards!.cards!.hands[f.holder], null);
assert.equal(f.game.nexusCards!.cards!.discard.filter(card => card === 'atreides').length, 1);
assert.equal(f.shipment.actor, f.target);
assert.equal(f.shipment.action.amount, 1);
assert.equal(player(f.afterShipment, f.target).reserves, player(f.beforeShipment, f.target).reserves - 1);
assert.equal(player(f.game, f.target).forces['arrakeen:10'], 1);
assert.equal(player(f.game, f.holder).forces['arrakeen:10'], 10);
assert.equal(f.originalPrescience!.field, 'dial');
assert.equal(f.originalPrescience!.value, 0);
assert.equal(f.originalInspection!.mode, 'cunning');
assert.equal(f.originalInspection!.stage, 'answered');
assert.equal(f.originalInspection!.field, 'weapon');
assert.deepEqual(f.originalInspection!.answers, [null]);
assert.equal(f.originalLeaders.filter(leader => !leader.dead).length, 5);
assert.equal(viewGame(f.game, f.holder).residualPoison!.blocked, null);
assert.equal(f.sourceActionCount, f.actions.length);
assert.deepEqual(residualCunningInventory(f.game), f.originalInventory);
}
function poisonOutcome(f: ResidualCunningFixture, game: Game) {
assert.equal(game.battle!.event, f.event);
assert.deepEqual(game.battle!.plans, {});
assert.deepEqual(game.battle!.prescience, f.originalPrescience);
assert.deepEqual(game.battle!.nexusInspection, f.originalInspection,
'Both feasible answers and the original signed Cunning history survive the actual death');
assert.equal(game.battle!.preparation ?? null, null);
assert.deepEqual(residualCunningPhysical(game), f.originalPhysical, 'No bounty, force loss, movement or payment is introduced');
const changed = player(game, f.target).leaders.filter(leader => {
const before = f.originalLeaders.find(original => original.id === leader.id)!;
return leader.dead !== before.dead || leader.deaths !== before.deaths;
});
assert.equal(changed.length, 1);
const victim = changed[0], before = f.originalLeaders.find(leader => leader.id === victim.id)!;
assert.equal(before.dead, false);
assert.equal(victim.dead, true);
assert.equal(victim.deaths, before.deaths + 1);
for (const leader of player(game, f.target).leaders.filter(leader => leader.id !== victim.id))
assert.deepEqual(leader, f.originalLeaders.find(original => original.id === leader.id));
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
assert.equal(player(game, f.holder).hand.some(card => card.id === f.card), false);
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'atreides').length, 1);
assert.deepEqual(residualCunningInventory(game), f.originalInventory);
return victim;
}
for (const advanced of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} genuine Residual after answered Cunning preserves both answers and completes the actual battle`, () => {
const f = createResidualCunningFixture({ advanced });
source(f);
const poisoned = playResidualCunning(f), victim = poisonOutcome(f, poisoned);
rejected(poisoned, f.holder, f.action);
assert.equal(player(poisoned, f.target).leaders.find(leader => leader.id === victim.id)!.deaths, victim.deaths);
const completed = finishResidualCunning(f, poisoned);
assert.equal(completed.plans[0].actor, f.target);
assert.notEqual(completed.plans[0].action.leader, victim.id);
assert.equal(completed.plans[0].action.dial, 0);
assert.equal(completed.plans[0].action.weapon, undefined);
assert.deepEqual(completed.sealed.battle!.prescience, f.originalPrescience);
assert.deepEqual(completed.sealed.battle!.nexusInspection, f.originalInspection);
assert.equal(completed.game.battle, null);
assert.equal(completed.game.discard.filter(card => card.id === f.card).length, 1);
assert.equal(player(completed.game, f.target).leaders.find(leader => leader.id === victim.id)!.deaths, victim.deaths);
assert.equal(player(completed.game, f.target).leaders.filter(leader => leader.dead).length, 1);
assert.equal(completed.game.nexusCards!.cards!.discard.filter(card => card === 'atreides').length, 1);
assert.deepEqual(residualCunningInventory(completed.game), f.originalInventory);
});
void test(`${advanced ? 'Advanced' : 'Basic'} genuine Residual Cunning source rejects stale and foreign use without a death or discard`, () => {
const f = createResidualCunningFixture({ advanced });
source(f);
rejected(f.game, f.target, f.action);
rejected(f.game, 'g', f.action);
rejected(f.game, f.holder, { ...f.action, event: 'stale' });
rejected(f.game, f.holder, { ...f.action, target: 'e' });
assert.deepEqual(player(f.game, f.target).leaders, f.originalLeaders);
assert.deepEqual(f.game.battle!.nexusInspection, f.originalInspection);
assert.deepEqual(residualCunningPhysical(f.game), f.originalPhysical);
assert.equal(f.game.discard.some(card => card.id === f.card), false);
assert.deepEqual(residualCunningInventory(f.game), f.originalInventory);
});
void test(`${advanced ? 'Advanced' : 'Basic'} actual leader commitment closes the earned Residual Cunning window for either combatant`, () => {
const f = createResidualCunningFixture({ advanced });
source(f);
for (const actor of [f.holder, f.target]) {
const leader = player(f.game, actor).leaders.find(leader => !leader.dead && !leader.usedAt);
assert.ok(leader);
const game = actResidualCunning(structuredClone(f.game), actor,
{ type: 'battlePlan', leader: leader.id, dial: 0, support: 0 });
assert.ok(game.battle!.plans[actor]);
assert.match(viewGame(game, f.holder).residualPoison!.blocked!, /leader/i);
rejected(game, f.holder, f.action);
assert.deepEqual(player(game, f.target).leaders, f.originalLeaders);
assert.equal(game.discard.some(card => card.id === f.card), false);
assert.deepEqual(residualCunningInventory(game), f.originalInventory);
}
});
}
void test('four minimal profiles legally play the actually purchased Residual after native and Cunning answers', () => {
for (const advanced of [false, true]) {
const f = createResidualCunningFixture({ advanced });
for (const difficulty of DIFFICULTIES) {
const view = viewGame(f.game, f.holder);
view.players.find(seat => seat.id === f.holder)!.bot = difficulty;
const action = botActions(view)[0];
assert.deepEqual(action, f.action, `${advanced}/${difficulty}`);
const game = actResidualCunning(structuredClone(f.game), f.holder, action);
poisonOutcome(f, game);
}
}
});