Implement five CHOAM Nexus Cunning effects with private costs and recovery

This commit is contained in:
Quintine committed 2026-09-10 12:16:39 +10:00
1 parent a6d9235099
commit 290259bac2
24 files changed
+1935 -131

No files matched your search

+20 -13
View File
@@ -1,5 +1,7 @@
'use client';
import { useState } from 'react';
import { ChoamPowerCost, useChoamPower } from './choam-power-cost';
import { choamPowerAction } from '@/game/choam-power-options';
import { Button } from '@/components/ui/button';
import { HelpTip } from './help-tip';
import type { Action, GameView } from '@/game/engine';
@@ -14,12 +16,12 @@ export function ChoamBaliset({
act: (a: Action) => void;
busy: boolean;
}) {
const power = useChoamPower(g, 'baliset');
const [target, setTarget] = useState('');
const [territory, setTerritory] = useState('');
if (!g.choamWorthless || g.phase !== 5) return null;
const d = g.decision?.kind === 'choamMovement' ? g.decision : null;
const card = g.choamWorthless.cards.find((c) => c.name === 'Baliset');
if (!card && !d) return null;
if (!power.play && !d) return null;
const players = g.players.filter((p) => p.faction !== 'choam');
const selectedPlayer =
d?.mover ?? players.find((p) => p.id === target)?.id ?? players[0]?.id;
@@ -50,7 +52,7 @@ export function ChoamBaliset({
forces have moved yet.
</p>
) : (
card && (
power.play && (
<>
<label>
Player{' '}
@@ -81,23 +83,28 @@ export function ChoamBaliset({
</>
)
)}
{card && (
<ChoamPowerCost {...power} busy={busy} />
{power.play && (
<Button
disabled={busy || !selectedPlayer || !selectedTerritory}
onClick={() =>
act({
type: 'card',
mode: 'choam',
card: card.id,
disabled={
busy ||
!selectedPlayer ||
!selectedTerritory ||
!power.play ||
!choamPowerAction(g, power.play)
}
onClick={() => {
const action = choamPowerAction(g, power.play!, {
target: selectedPlayer,
territory: selectedTerritory,
})
}
});
if (action) act(action);
}}
>
Prevent movement
</Button>
)}
{!card && d && <p className="fine">No available Baliset play.</p>}
{!power.play && d && <p className="fine">No available Baliset play.</p>}
{d && (
<Button
variant="outline"
+14 -11
View File
@@ -1,5 +1,7 @@
'use client';
import { useState } from 'react';
import { ChoamPowerCost, useChoamPower } from './choam-power-cost';
import { choamPowerAction } from '@/game/choam-power-options';
import { HelpTip } from './help-tip';
import { Button } from '@/components/ui/button';
import type { Action, GameView } from '@/game/engine';
@@ -13,9 +15,9 @@ export function ChoamGamont({
act: (a: Action) => void;
busy: boolean;
}) {
const power = useChoamPower(g, 'gamont');
const [selection, setSelection] = useState('');
if (!g.choamWorthless || g.phase !== 8) return null;
const card = g.choamWorthless.cards.find((c) => c.name === 'Trip to Gamont');
const options = g.choamWorthless.gamont;
const key = (o: (typeof options)[number]) =>
`${o.target}/${o.key}/${o.elite}`;
@@ -31,7 +33,8 @@ export function ChoamGamont({
opportunity. A No-Field in the selected sector must reveal first; the
card is used even if no force is there to return.
</p>
{card && options.length > 0 && (
<ChoamPowerCost {...power} busy={busy} />
{power.play && options.length > 0 && (
<>
<label>
Force to return{' '}
@@ -65,23 +68,23 @@ export function ChoamGamont({
</select>
</label>
<Button
disabled={busy || !chosen}
onClick={() =>
act({
type: 'card',
mode: 'choam',
card: card.id,
disabled={
busy || !chosen || !power.play || !choamPowerAction(g, power.play)
}
onClick={() => {
const action = choamPowerAction(g, power.play!, {
target: chosen.target,
from: chosen.key,
elite: chosen.elite,
})
}
});
if (action) act(action);
}}
>
Return this force
</Button>
</>
)}
{(!card || !options.length) && (
{(!power.play || !options.length) && (
<p className="fine">No available Trip to Gamont play.</p>
)}
{g.decision?.kind === 'choamMentat' && (
+70
View File
@@ -0,0 +1,70 @@
'use client';
import { useState } from 'react';
import type { GameView } from '@/game/engine';
import {
choamPowerKey,
choamPowerPlays,
CHOAM_POWER_NAMES,
type ChoamPowerEffect,
type ChoamPowerPlay,
} from '@/game/choam-power-options';
import { CardInspector } from './card-inspector';
export function useChoamPower(game: GameView, effect: ChoamPowerEffect) {
const [selected, select] = useState('');
const plays = choamPowerPlays(game, effect);
const play =
plays.find((candidate) => choamPowerKey(candidate) === selected) ??
plays.find((candidate) => !candidate.blocked) ??
plays[0];
return { plays, play, select };
}
export function ChoamPowerCost({
plays,
play,
select,
busy,
}: {
plays: ChoamPowerPlay[];
play?: ChoamPowerPlay;
select: (key: string) => void;
busy: boolean;
}) {
if (!play) return null;
return (
<div className="space-y-2">
{plays.length > 1 && (
<label>
Card to use for {CHOAM_POWER_NAMES[play.effect]}
<select
value={choamPowerKey(play)}
disabled={busy}
onChange={(event) => select(event.target.value)}
>
{plays.map((candidate) => (
<option
key={choamPowerKey(candidate)}
value={choamPowerKey(candidate)}
>
{candidate.card.name} ·{' '}
{candidate.source === 'nexus'
? 'CHOAM Nexus Cunning'
: 'printed power'}
{candidate.blocked ? ' · unavailable' : ''}
</option>
))}
</select>
</label>
)}
<p>
Use <strong>{play.card.name}</strong> for{' '}
{CHOAM_POWER_NAMES[play.effect]}.
{play.source === 'nexus' &&
' This spends CHOAM Nexus Cunning once. Karama can prevent the effect and leave this Treachery Card in hand; the Nexus stays spent.'}
</p>
<CardInspector card={play.card} />
{play.blocked && <p className="fine">{play.blocked}</p>}
</div>
);
}
+13 -10
View File
@@ -1,5 +1,7 @@
'use client';
import { useState } from 'react';
import { ChoamPowerCost, useChoamPower } from './choam-power-cost';
import { choamPowerAction } from '@/game/choam-power-options';
import { Button } from '@/components/ui/button';
import { HelpTip } from './help-tip';
import type { Action, GameView } from '@/game/engine';
@@ -14,10 +16,10 @@ export function ChoamStorm({
act: (a: Action) => void;
busy: boolean;
}) {
const power = useChoamPower(g, 'jubba');
const [selected, setSelected] = useState('');
const decision = g.decision?.kind === 'choamStorm' ? g.decision : null;
if (!decision || decision.player !== g.me) return null;
const card = g.choamWorthless?.cards.find((c) => c.name === 'Jubba Cloak');
const choice =
decision.territories.find((t) => t.territory === selected) ??
decision.territories[0];
@@ -51,17 +53,18 @@ export function ChoamStorm({
))}
</select>
</label>
{card ? (
<ChoamPowerCost {...power} busy={busy} />
{power.play ? (
<Button
disabled={busy || !choice}
onClick={() =>
act({
type: 'card',
mode: 'choam',
card: card.id,
territory: choice.territory,
})
disabled={
busy || !choice || !power.play || !choamPowerAction(g, power.play)
}
onClick={() => {
const action = choamPowerAction(g, power.play!, {
territory: choice.territory,
});
if (action) act(action);
}}
>
Protect this territory
</Button>
+31 -27
View File
@@ -1,5 +1,7 @@
'use client';
import { useState } from 'react';
import { ChoamPowerCost, useChoamPower } from './choam-power-cost';
import { choamPowerAction, choamPowerPlays } from '@/game/choam-power-options';
import { HelpTip } from './help-tip';
import { Button } from '@/components/ui/button';
import type { Action, GameView } from '@/game/engine';
@@ -13,7 +15,7 @@ export function ChoamWorthless({
busy: boolean;
}) {
const [target, setTarget] = useState('');
const me = g.players.find((p) => p.id === g.me)!;
const power = useChoamPower(g, g.phase === 4 ? 'laLaLa' : 'kulon');
const reactive = g.decision?.kind === 'choamFreeRevival';
if (!g.choamWorthless || ![4, 5].includes(g.phase)) return null;
const selected = g.choamWorthless.targets.includes(target)
@@ -53,32 +55,34 @@ export function ChoamWorthless({
force revival.
</p>
)}
{g.choamWorthless.cards
.filter((c) =>
g.phase === 4 ? c.name === 'La La La' : c.name === 'Kulon',
)
.map((c) => (
<Button
key={c.id}
disabled={
busy ||
(g.phase === 5 &&
(g.active !== me.id ||
(me.moved ?? 0) >= (me.movesAllowed ?? 1))) ||
(g.phase === 4 && !recipient)
}
onClick={() =>
act({
type: 'card',
mode: 'choam',
card: c.id,
target: recipient,
})
}
>
Use {c.name}
</Button>
))}
<ChoamPowerCost {...power} busy={busy} />
{power.play && (
<Button
disabled={
busy ||
(g.phase === 4 && !recipient) ||
!choamPowerAction(g, power.play)
}
onClick={() => {
const action = choamPowerAction(
g,
power.play!,
g.phase === 4 ? { target: recipient } : {},
);
if (action) act(action);
}}
>
Use {g.phase === 4 ? 'La La La' : 'Kulon'}
</Button>
)}
{choamPowerPlays(g, 'kull').some(
(play) => play.source === 'nexus' && play.effect === 'kull',
) && (
<p className="fine">
Kull Wahad’s Karama prevention effect is not implemented. CHOAM
Cunning cannot use it yet.
</p>
)}
{reactive && (
<Button
variant="outline"
+2 -2
View File
@@ -56,7 +56,7 @@ export function NexusCardFace({ card, mode, enlarged = false }: {
<DialogTitle>{identity.name} Nexus card</DialogTitle>
<DialogClose render={<Button variant="outline" className="min-h-11 min-w-16" />}>Close</DialogClose>
</div>
<DialogDescription className="sr-only">All three Nexus effects and their faction requirements. Atreides actions appear when available; other card effects are not playable yet.</DialogDescription>
<DialogDescription className="sr-only">All three Nexus effects and their faction requirements. Available actions appear at their required timing; unfinished effects remain unavailable.</DialogDescription>
<div className="min-h-0 overflow-y-auto overscroll-contain p-3 sm:p-5"><NexusCardFace card={card} mode={mode} enlarged /></div>
</DialogPrimitive.Popup>
</DialogPortal>
@@ -118,7 +118,7 @@ export function NexusCards({ game, act, busy }: { game: GameView; act: (action:
<div>
<h3>Nexus cards</h3>
<p className="fine">{offer.deckCount} in deck · {offer.discardCount} discarded · {Object.values(offer.held).filter((held) => held === true).length} held</p>
<p>Playable effects include Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian, Bene Gesserit and Emperor Cunning. Use the controls shown at each effect’s timing; other effects remain unavailable.</p>
<p>Playable effects include Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian, Bene Gesserit, Emperor and CHOAM Cunning. Use the controls shown at each effect’s timing. CHOAM’s Kull Wahad effect and other unfinished effects remain unavailable.</p>
</div>
{game.nexusAtreides && offer.card === 'atreides' && (
<div aria-label="Atreides Nexus effect" className="space-y-2">
+2
View File
@@ -1,5 +1,7 @@
# Component inventory and verification ledger
**CHOAM Nexus Cunning update, 10 September 2026:** the existing physical Nexus can fund one of five supported Worthless effects with an actual held Treachery Card of any category. Shared controls show the cost card separately from the selected effect and retain its full inspector; the engine preserves its canonical identity through allowance, cancellation and legitimate payment loss. No duplicate Worthless piece or artwork is created. The printed family has six effects: Kull Wahad remains unavailable. [NEXUS_CHOAM_RUNTIME.md](NEXUS_CHOAM_RUNTIME.md) records thirty new regressions, browser inspection and private recovery; complete CHOAM and module acceptance remain gated.
**Emperor Nexus Cunning update, 10 September 2026:** five existing ordinary counters can temporarily count as Sardaukar for one Advanced battle after the native Karama response allows the declared enhancement. No starred piece is created or transferred; casualties remain ordinary counters. Shared strength/support limits drive the editor and bots, and public notices distinguish pending versus active roles. This uses existing Nexus and force components without new artwork. Five-counter eligibility, existing commitments and all other Emperor mode gates remain explicit. [NEXUS_SARDAUKAR_RUNTIME.md](NEXUS_SARDAUKAR_RUNTIME.md) records 35 new cases, browser inspection, private recovery and the full-project checks; complete module acceptance remains pending.
**Bene Gesserit Nexus Cunning update, 10 September 2026:** existing physical advisors now have an owned whole-territory conversion checklist with counts, select-all control and explicit blocked reasons. One played Nexus card opens one Karama response for the entire public selection; no additional card, token or artwork is created. Legal selected groups become fighters in place without consuming shipment, movement or spice. Fresh accompanied advisors and storm cases remain explicitly pending rulings. Borrowed Voice, Betrayal and incomplete mode starts remain gated. [NEXUS_ADVISOR_RUNTIME.md](NEXUS_ADVISOR_RUNTIME.md) records the bounded runtime and verification status; final acceptance is pending.
+2
View File
@@ -1,5 +1,7 @@
# Dune implementation status
**CHOAM Nexus Cunning checkpoint, 10 September 2026:** [NEXUS_CHOAM_RUNTIME.md](NEXUS_CHOAM_RUNTIME.md) records five native effects using one actual Treachery Card in Basic and Advanced. Shared controls inspect the real cost independently of the selected effect; all four bots use projected legality. Karama retains the cost and spent Nexus; actual intervening cash-in fizzles and resumes the original operation. Independent review fixed saved movement, revival and storm orders accepting valid-looking replacements. **Types, lint, all 4,042 offline tests, production build and 40 HTTP/session tests pass.** Thirty new cases include real paid Nullentropy, private views, physical disposal and concurrent SQLite outcomes. Two three-seat browser rooms passed phone declarations, allowance/cancellation and all six private refreshes. All **3,484** opening room versions/hashes remain unchanged; only two browser and thirty HTTP rooms were added. The printed family has six effects: Kull Wahad remains unavailable, as do unfinished Secret Ally/Betrayal and full module starts. Existing server reused, no restart due, automation remains removed.
**Grumman stack cancellation checkpoint, 10 September 2026:** [GRUMMAN_PLACEMENT_CANCELLATION.md](GRUMMAN_PLACEMENT_CANCELLATION.md) records the reproduced failure of printed Karama against a native Mentat placement after a legal Collection stack. Cancellation now preserves the existing stack without requiring distinct occupied locations. Independent SQL review also fixed allowance accepting unrelated duplicate physical token IDs; both outcomes validate the same inventory. Existing denial semantics remain unchanged. **Types, lint, all 4,012 offline cases, production build and 40 HTTP/session cases pass.** Six new cases cover Basic/Advanced, low population, unchanged income, competing cancellation/allowance, private restoration and malformed records. Browser phone declaration, desktop/phone cancellation and three-seat refresh pass. The hourly restart preserved all **3,453** opening games and restored twelve earlier private seats; final comparison leaves those same versions/hashes unchanged, with one new browser room and thirty HTTP rooms added. New [Ixian replacement](NEXUS_IXIAN_REPLACEMENT_RULES.md) and [Fremen Nexus](NEXUS_FREMEN_RULES.md) audits record remaining source and privacy boundaries. No new Nexus effect or release gate was enabled; automation stays removed.
**Emperor Nexus Cunning checkpoint, 10 September 2026:** [NEXUS_SARDAUKAR_RUNTIME.md](NEXUS_SARDAUKAR_RUNTIME.md) records five ordinary counters temporarily using Sardaukar strength and support in one Advanced battle. Physical counters, starred reserves and ordinary losses remain distinct. Shared editor and bot calculations handle the Fremen exception and Salusa support. Review fixed a canceled-enhancement Prescience deadlock and retained durable winner casualty choices across competing SQLite requests. **Types, lint, all 4,006 offline tests, production build and 40 HTTP/session tests pass.** Thirty-five new cases cover independent counter arithmetic, real Homeworld thresholds, future commitments, hidden information and recovery. Two three-seat browser rooms passed desktop/phone allowance, cancellation, reanswer, sealed planning and private refresh. All **3,421** opening room versions/hashes remain unchanged; only thirty HTTP rooms and two browser rooms were added. Fewer-than-five interpretation, Emperor Secret Ally, Betrayal and complete module acceptance remain gated. No release gate opened; no restart was due, and automation remains removed.
+76
View File
@@ -0,0 +1,76 @@
# CHOAM Nexus Cunning
Source audit: 2026-09-10. This source audit defines native Cunning and its saved-receipt contract. The separate [runtime checkpoint](NEXUS_CHOAM_RUNTIME.md) implements five effects; this audit does not resolve the other CHOAM Nexus panels or lift any [Nexus/expansion release gate](NEXUS_CARD_RUNTIME.md).
## Authority and printed scope
The [original GF9 Nexus component photograph](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani), independently inspected at `/tmp/dune-nexus-cards.jpg`, permits native CHOAM to discard any Treachery Card to use any Worthless Card special effect. The choice is one held physical card and one effect. It does not require holding the corresponding named Worthless card, transform the chosen card's printed identity, grant money, or supply a phase-long conversion permission.
The [GF9 Ecaz & Moritani rulebook, p.11](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=11) classifies Cunning as enhancement of a native advantage. The holder must remain unallied, and a played Nexus Card is discarded. Those common conditions still apply. Publisher-indexed text and the previously fetched [publisher-authored E3 mirror](https://gamers-hq.de/media/pdf/0f/7a/86/Dune_EcazMoritani_Rulebook_EN.pdf) were used; no community compilation supplies a ruling.
CHOAM's Treachery advantage is on the ordinary faction-rules page, before its Advanced advantages. Therefore this Cunning is usable in Basic and Advanced; it is not CHOAM's separate once-per-game special Karama. [GF9 CHOAM & Richese rules, pp.7–8](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf#page=7)
## Six effects and their timing
E2 lists **six**, not five. Five have current ordinary engine effect handlers; Kull Wahad retains its separate unfinished reaction subsystem.
| Chosen effect | Printed outcome and timing |
| --- | --- |
| Baliset | During Shipment and Movement, prevent a player moving into a territory CHOAM occupies; shipment remains permitted. |
| Jubba Cloak | Protect CHOAM forces in one territory from the moving storm. |
| Kull Wahad | On a player's attempted Karama play, prevent that player playing Karama during this phase. |
| Kulon | One extra territory of movement on CHOAM's own Shipment and Movement turn. |
| La La La | During Revival, prevent a player taking Free Revival. |
| Trip to Gamont | During Mentat Pause, return one other player's force to its reserves. |
None lists an additional spice fee. Cunning changes the required card identity, not timing, targets or quantity. It does not grant an extra movement action, make Jubba protect spice, or make a shipment count as Baliset movement. [E2 p.7](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf#page=7)
A purchased or gifted card already in CHOAM's hand can supply the discard, including a Worthless card, weapon, defense, special card, Cheap Hero or Richese card. Existing reservations and binding commitments still matter. Do not require the selected card's name to match the effect or physically create a named Worthless card. Playing a printed Karama as this discard cost does not also activate its Karama power; no additional once-per-game use is earned or spent.
## Cancellation and physical disposal
E2's Karama table permits prevention of CHOAM's special-effect Worthless discard. Applying that rule to Cunning's enhancement is ordinary native-advantage composition: the selected Treachery card remains held when its discard is prevented, the chosen effect does not occur, and the already played Nexus stays spent. Use one ordinary CHOAM-effect response, not separate cancellation windows for conversion and effect. [E2 p.12](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf#page=12), [E3 p.11](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=11)
This differs from Bene Gesserit's expressly discarded Worthless-as-Karama when that conversion is prevented. That specific FAQ consequence does not transfer to CHOAM. The general FAQ permits stopping one native-ability use; CHOAM's later table supplies its own phase-scoped wording. [GF9 November FAQ, p.7](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=7)
On an allowed use, the **original physical card** crosses into discard exactly once. Its canonical descriptor remains intact. For example, a poison weapon discarded to obtain Kulon still qualifies for any applicable actual-discard Ecaz income; hand-discard Chemistry is not promoted to a poison weapon. A canceled declaration creates no such discard event, income or Semuta opportunity. Reuse the [Ecaz physical-discard contract](ECAZ_POISON_INCOME_RULES.md) and ordinary fresh-discard continuation; a selected effect label must never overwrite card type.
The effect choice and native use may be public without revealing the rest of CHOAM's hand. Do not add a public poison-income message that identifies a privately discarded category. Preserve existing discard visibility. Successful disposal must pass through the current Semuta/reservation handling, and an interruption must resume the chosen effect and its original parent once. A later retrieval of the discarded card does not undo an already earned effect or permit replaying its receipt.
## Existing boundaries to preserve
- **Kull:** [the existing source update](CHOAM_KULL_SOURCE_UPDATE.md) leaves counter priority, BG conversion timing, interrupted-card custody and unfunded overbid recovery unresolved. Cunning does not settle those interactions. No new question was sent. Omitting Kull from a supported five-effect checkpoint must be explicit; it cannot be presented as all CHOAM Cunning.
- **Phase cancellation scope:** the existing [Worthless continuation audit](CHOAM_WORTHLESS_CONTINUATION_AUDIT.md) records unresolved scope across other physical copies or effects. Current runtime binds a canceled physical card for the phase. A new Nexus use should preserve the selected policy rather than silently broaden or erase that historical restriction. The spent Nexus itself cannot be retried.
- **Inherited effect boundaries:** fixed Ornithopter range versus Kulon, reactive movement/Revival/storm parents, special force custody, No-Field Gamont and other already documented exclusions remain properties of the selected effect. They are not reasons to reject an ordinary source-supported Cunning use with a different effect.
- **No fresh hidden reaction policy:** native CHOAM's existing effect opportunities and public circumstances supply the interaction points. Extend those owner's controls without pausing only because a secret Nexus card exists, adding compulsory no-choice confirmations, or reopening another player's completed action. Kull retains its separate pending policy.
## Pure saved receipt and integration contract
This is engineering design, separate from publisher rules. Keep the authorization receipt independent from the physical card and the existing effect's continuation.
```ts
type NexusChoamEffect =
| 'baliset' | 'jubba' | 'kull' | 'kulon' | 'laLaLa' | 'gamont';
type NexusChoamReceipt = {
version: 1;
event: string;
owner: string;
turn: number;
phase: number;
effect: NexusChoamEffect;
card: string; // original physical ID; server only
roster: { id: string; faction: FactionId }[];
signature: string;
};
```
[game/nexus-choam.ts](../game/nexus-choam.ts) exports the six-name `CHOAM_NEXUS_EFFECTS` map, `NexusChoamEffect`, `NexusChoamContext`, `NexusChoamReceipt`, `createNexusChoam(context, owner, phase, card, effect)` and `validateNexusChoam(context, receipt)`. Context contains the current turn and ID/faction roster only. Intrinsic validation checks exact keys, effect/phase compatibility, original native owner/roster, nonempty card ID, safe numbers and signature. The event is `JSON.stringify(['nexusChoam', turn, phase, owner, card, effect])`. Kull's intrinsic timing admits phases 0–8; this does not authorize playing its still-gated effect. Historical validation neither reads private hands nor requires the card still held, CHOAM still unallied or the old Nexus still in discard. The engine owns canonical card validation, held Nexus/unallied eligibility, exact original effect source and physical custody.
The engine should separately bind progress (`pending`, `canceled`, `discarded`, `complete`) and the exact original resume context. An independently retained latest-progress marker prevents deleting or rewinding the detailed frame from restoring the Nexus or causing another discard. Bind target, territory/sector, elite type, No-Field event and movement/revival/storm parent only where the selected effect uses them. Avoid freezing unrelated hidden hands, resource changes or nested response passes in a signature.
Integrate through the existing native effect validator with an explicit selected effect, preserving the original selected Card. Do not construct a fake Worthless hand entry merely to satisfy name-based checks. On cancellation, validate and resume the existing denied suffix without applying or paying for the chosen effect. On allowed disposal, record the physical event before yielding to a fresh-discard child; the continuation must know whether the card and effect have already committed. Reject stale actor/event/effect/target/custody before costs or private output.
Four focused pure tests in [tests/nexus-choam.test.ts](../tests/nexus-choam.test.ts) pass: six-effect timing, later-turn/reordered-roster recovery with private getter traps, edited receipt rejection, and malformed/foreign creation. Type-aware lint and whitespace checks pass. These are receipt checks, not gameplay integration certification.
Meaningful integration tests should cover all five currently supported effects in their genuine producers, Basic/Advanced parity, arbitrary canonical card families, successful and canceled selected-card custody, normal Worthless parity, denied-card phase behavior, poison-income and fresh-discard children, real BG/Box interruption, exact saved parent, private owner projection, bots, JSON/CAS recovery and failed-action immutability. Kull is a named remaining effect until its own rules and runtime are resolved. No completed engine integration or gameplay verification is claimed here.
+43
View File
@@ -0,0 +1,43 @@
# CHOAM Nexus Cunning runtime
This checkpoint integrates native CHOAM Cunning with five existing Worthless effects, in Basic and Advanced. It does not complete CHOAM, Nexus or the expansions. The [source contract](NEXUS_CHOAM_RULES.md) lists **six** effects: Kull Wahad's Karama-prevention reaction remains explicitly unavailable. CHOAM Secret Ally and Betrayal remain outside this implementation.
## Physical cost and timing
An unallied CHOAM player holding the CHOAM Nexus chooses one actual held Treachery Card and one supported effect. The card need not have the corresponding Worthless name. The engine preserves its original identity, ownership and any reservations; it does not manufacture a named Worthless card or grant a turn-long conversion ability.
The existing timing and target controls remain authoritative:
- Jubba Cloak protects CHOAM forces in one threatened territory during its moving-storm response.
- La La La prevents a selected player's free force revival, including the existing response before a mixed free/paid revival commits.
- Baliset prevents movement into a CHOAM-occupied territory, including the existing declared-movement response. It does not prevent shipment.
- Kulon adds one territory of range before an available CHOAM move, without granting another movement action. The fixed Ornithopter-range combination retains its existing ruling boundary.
- Trip to Gamont returns one selected opposing physical force during Mentat. Existing typed-counter, No-Field and deferred-victory behavior remains in force.
Playing Cunning spends its physical Nexus card and opens one ordinary CHOAM-effect Karama response. Cancellation leaves the selected Treachery Card held and the Nexus spent. Allowance discards the actual selected card once and applies the chosen effect. Its real discard category governs other consequences, including applicable private Ecaz income; the selected Worthless effect does not change that category.
## Controls, policy and private information
The engine's owner-only `choamWorthless.plays` separates `source`, `effect`, the actual `card`, an optional Nexus `event`, and a blocking reason. The original printed-card descriptor remains available. [Shared options](../game/choam-power-options.ts) construct ordinary card actions unchanged and add the explicit effect/event only for a Nexus use. They fence stale events, actor identity and current interruptions, and use the server's source-specific blocking reason.
The [cost selector](../components/choam-power-cost.tsx) is reused by the existing [storm](../components/choam-storm.tsx), [revival/range](../components/choam-worthless.tsx), [movement prevention](../components/choam-baliset.tsx) and [force return](../components/choam-gamont.tsx) panels. It names and inspects the real cost card separately from the effect, supports transport-busy state, and exposes blocked choices without offering an illegal action. Kull Wahad has a visible unavailable explanation. Public response text identifies the chosen effect rather than disclosing the private cost card's name.
All four bot profiles use the same projected plays. They preserve printed effects first, then prefer lower-value legal Nexus fuel, retaining their existing public-target policies. Easy's existing policy of allowing free revival remains unchanged. The ordinary Trip to Gamont preservation policy still reserves that printed card during a closing market or cash-in even though playing it must wait for the next legal window. These are legal policy extensions, not a new AI calibration claim.
Existing reactive windows depend on public circumstances rather than whether CHOAM holds a matching card. Cunning uses those same windows, so holding a private Nexus card does not introduce an identifying public wait. Opponents receive no private cost list. Saved engine receipts, cancellation and physical disposal are handled by the engine; consumer code does not reconstruct an opportunity from card names or old saves. The existing fresh-discard/Semuta priority remains unfinished and gated; this checkpoint does not implement a Semuta interruption for these effects.
## Recovery evidence
The saved receipt binds the physical card, selected effect, original phase and owner, declared target and the specific suspended movement, revival, storm or Mentat context. Independent review reproduced legal-looking replacements of a pending movement quantity, revival quantity/price and storm distance. Those edits now reject before reads, automatic normalization or either outcome can write state. Historical completed records do not freeze unrelated future resources.
Actual paid Nullentropy searches can suspend and restore the response. If CHOAM legitimately consumes the selected card in an intervening cash-in, Cunning fizzles with its Nexus still spent and resumes the original operation once. Cancellation retains the card and applies the existing phase block. Production SQLite tests cover private refresh and simultaneous cancellation versus the final pass; exactly one version wins.
## Verification
Five new consumer regressions are in [controls](../tests/nexus-choam-controls.test.ts) and [bots](../tests/nexus-choam-bots.test.ts). They exercise actual fixture-generated windows for all five supported effects, submitted source actions, the real cost-card inspector, blocked Kull and reserved cards, stale events, actor/interrupt fences, private-field traps, all four policies, and unchanged printed actions. The combined consumer, existing Worthless/storm/movement/Gamont, Nexus guidance and reference selection passed **150/150** tests. Type-aware lint passed for all owned code and tests; all nine local links in this document resolve. The log is `/tmp/dune-choam-consumer-tests.log`.
Final integrated checks pass: **types, lint, all 4,042 offline tests, production build and 40 HTTP/session tests**. Thirty new cases cover the pure receipt, engine, consumers, independent saved-parent review and production SQLite matrices. Logs are `/tmp/dune-choam-{check,build,http}.log`.
Two isolated three-seat browser rooms passed actual phone declarations, desktop/phone response inspection and all six private refreshes. `GDP8NNRE` version 4 allowed Baliset: the pending two-force movement was prevented, the three original source forces remained and the real Stunner payment was discarded. `WJTUH62T` version 5 canceled Jubba Cloak: Slip Tip stayed held, the Nexus stayed spent, and continuing the original storm removed four CHOAM and three opposing forces. No phone overflow or page errors were observed. `/tmp/dune-choam-restore.cjs` performs read-only private restoration checks.
The opening backup and final comparison preserve all **3,484** older room versions and state hashes. Only two browser rooms and thirty HTTP-test rooms were added, for 3,516 total. The existing server was reused; no hourly restart was due. Automation remains removed. These checks do not certify a complete six-effect CHOAM advantage or full module play.
+22 -40
View File
@@ -1,3 +1,4 @@
import { choamPowerAction, choamPowerBotPlay } from './choam-power-options';
import { nexusCardBotActions } from './nexus-card-options';
import { nexusTraitorBotActions } from './nexus-traitor-options';
import { nexusTleilaxuBotActions } from './nexus-tleilaxu-options';
@@ -940,8 +941,8 @@ function plans(g: GameView): Action[] {
// This policy receives a personalized view, never the authoritative decks or rival hands.
function gamontAction(g: GameView): Action | undefined {
const me = g.players.find((p) => p.id === g.me)!;
const card = g.choamWorthless?.cards.find((c) => c.name === 'Trip to Gamont');
if (!card || g.phase !== 8) return;
const play = choamPowerBotPlay(g, 'gamont', (card) => technologyCardValue(g, card));
if (!play || g.phase !== 8) return;
const options = g
.choamWorthless!.gamont.filter((o) => o.target !== me.ally)
.map((o) => {
@@ -966,14 +967,9 @@ function gamontAction(g: GameView): Action | undefined {
.sort((a, b) => b.score - a.score);
const choice = options[0];
if (choice)
return {
type: 'card',
mode: 'choam',
card: card.id,
target: choice.target,
from: choice.key,
elite: choice.elite,
};
return choamPowerAction(g, play, {
target: choice.target, from: choice.key, elite: choice.elite,
}) ?? undefined;
}
/** Supported, server-derived timing only; no rival hand or sealed bid is inspected. */
function saphoAction(g: GameView): Action | null {
@@ -1155,10 +1151,10 @@ function policyActions(g: GameView): Action[] {
const preserve =
g.choamWorthless?.pending ??
(g.decision?.kind === 'choamMovement'
? g.choamWorthless?.cards.find((c) => c.name === 'Baliset')?.id
? choamPowerBotPlay(g, 'baliset', (card) => technologyCardValue(g, card))?.card.id
: g.decision?.kind === 'choamStorm'
? g.choamWorthless?.cards.find((c) => c.name === 'Jubba Cloak')?.id
: gamontAction(g)?.card);
? choamPowerBotPlay(g, 'jubba', (card) => technologyCardValue(g, card))?.card.id
: (gamontAction(g)?.card ?? g.choamWorthless?.cards.find((card) => card.name === 'Trip to Gamont')?.id));
const selected = g.choamCashIn.cards.filter(
(c) => c.kind === 'worthless' && c.id !== card.id && c.id !== preserve,
);
@@ -1356,7 +1352,7 @@ function policyActions(g: GameView): Action[] {
}
if (g.biddingEnd && !g.decision)
return biddingEndActions(
g, level, (card) => technologyCardValue(g, card), gamontAction(g)?.card,
g, level, (card) => technologyCardValue(g, card), (gamontAction(g)?.card ?? g.choamWorthless?.cards.find((card) => card.name === 'Trip to Gamont')?.id),
);
if (g.decision) {
if (g.decision.player !== me.id) return [];
@@ -1755,38 +1751,24 @@ function policyActions(g: GameView): Action[] {
: [{ type: 'decision', decline: true }];
}
if (d.kind === 'choamStorm') {
const card = g.choamWorthless?.cards.find(
(c) => c.name === 'Jubba Cloak',
);
const play = choamPowerBotPlay(g, 'jubba', (card) => technologyCardValue(g, card));
const target = [...d.territories].sort(
(a, b) => b.amount - a.amount || a.territory.localeCompare(b.territory),
)[0];
return [
card && target
? {
type: 'card',
mode: 'choam',
card: card.id,
territory: target.territory,
}
: { type: 'decision', decline: true },
];
const action = play && target ? choamPowerAction(g, play, { territory: target.territory }) : null;
return [action ?? { type: 'decision', decline: true }];
}
if (d.kind === 'choamMovement') {
const card = g.choamWorthless?.cards.find((c) => c.name === 'Baliset');
return [
card && d.mover !== me.ally
? { type: 'card', mode: 'choam', card: card.id }
: { type: 'decision', decline: true },
];
const play = choamPowerBotPlay(g, 'baliset', (card) => technologyCardValue(g, card));
const action = play && d.mover !== me.ally ? choamPowerAction(g, play) : null;
return [action ?? { type: 'decision', decline: true }];
}
if (d.kind === 'choamMentat')
return [gamontAction(g) ?? { type: 'decision', done: true }];
if (d.kind === 'choamFreeRevival') {
const card = g.choamWorthless?.cards.find((c) => c.name === 'La La La');
if (card && d.recipient !== me.ally && level > 0)
return [{ type: 'card', mode: 'choam', card: card.id }];
return [{ type: 'decision', decline: true }];
const play = choamPowerBotPlay(g, 'laLaLa', (card) => technologyCardValue(g, card));
const action = play && d.recipient !== me.ally && level > 0 ? choamPowerAction(g, play) : null;
return [action ?? { type: 'decision', decline: true }];
}
if (d.kind === 'choamBattleFunding') {
const ally = g.players.find((p) => p.id === me.ally)!;
@@ -1805,7 +1787,7 @@ function policyActions(g: GameView): Action[] {
}
if (d.kind === 'choamMarket')
return [choamMarketPolicy(
g, (card) => technologyCardValue(g, card), gamontAction(g)?.card,
g, (card) => technologyCardValue(g, card), (gamontAction(g)?.card ?? g.choamWorthless?.cards.find((card) => card.name === 'Trip to Gamont')?.id),
)];
if (d.kind === 'choamTradeReply') {
const returned = [...(me.hand ?? [])].sort(
@@ -2821,14 +2803,14 @@ function policyActions(g: GameView): Action[] {
return actions;
}
if (g.phase === 5) {
const kulon = g.choamWorthless?.cards.find((c) => c.name === 'Kulon');
const kulon = choamPowerBotPlay(g, 'kulon', (card) => technologyCardValue(g, card));
if (
kulon &&
g.active === me.id &&
Object.keys(me.forces).length &&
(me.moved ?? 0) < (me.movesAllowed ?? 1)
)
return [{ type: 'card', mode: 'choam', card: kulon.id }];
return [choamPowerAction(g, kulon)!];
if (g.active !== me.id) return [];
const cardMoves = ornithopterMoves(g);
if (
+128
View File
@@ -0,0 +1,128 @@
import { CHOAM_NEXUS_EFFECTS, type NexusChoamEffect } from './nexus-choam';
import type { Card } from './cards';
import type { Action, GameView } from './engine';
export type ChoamPowerEffect = NexusChoamEffect;
export type ChoamPowerPlay = {
source: 'printed' | 'nexus';
effect: ChoamPowerEffect;
card: Card;
event?: string;
blocked: string | null;
};
export const CHOAM_POWER_NAMES = CHOAM_NEXUS_EFFECTS;
export const choamPowerKey = (play: ChoamPowerPlay) =>
`${play.source}/${play.effect}/${play.card.id}/${play.event ?? ''}`;
/** Private server choices keep the physical cost separate from the effect. */
export function choamPowerPlays(
game: GameView,
effect: ChoamPowerEffect,
): ChoamPowerPlay[] {
if (game.players.find((player) => player.id === game.me)?.faction !== 'choam')
return [];
const state = game.choamWorthless as
| (NonNullable<GameView['choamWorthless']> & { plays?: ChoamPowerPlay[] })
| null;
if (!state) return [];
// Older personalized fixtures retain the printed-only descriptor.
const plays =
state.plays ??
state.cards
.filter((card) => card.name === CHOAM_POWER_NAMES[effect])
.map((card) => ({
source: 'printed' as const,
effect,
card,
blocked: null,
}));
return plays.filter((play) => play.effect === effect);
}
export function choamPowerAction(
game: GameView,
requested: ChoamPowerPlay,
selection: Record<string, unknown> = {},
): Action | null {
if (
Object.keys(selection).some(
(key) =>
!['target', 'territory', 'from', 'elite', 'amount'].includes(key),
)
)
return null;
const owner = game.players.find((player) => player.id === game.me);
if (
owner?.faction !== 'choam' ||
game.status !== 'playing' ||
game.response ||
game.truthtrance ||
game.phaseOpening ||
game.automaticContinuationPending ||
game.nexusTraitors?.pending ||
game.nexusCards?.waiting.length
)
return null;
const play = choamPowerPlays(game, requested.effect).find(
(candidate) => choamPowerKey(candidate) === choamPowerKey(requested),
);
if (!play || play.blocked || play.effect === 'kull') return null;
const phases = { kulon: 5, laLaLa: 4, gamont: 8, baliset: 5, jubba: 0 };
if (game.phase !== phases[play.effect]) return null;
const decisions = {
kulon: null,
laLaLa: 'choamFreeRevival',
gamont: 'choamMentat',
baliset: 'choamMovement',
jubba: 'choamStorm',
};
if (
game.decision &&
(game.decision.kind !== decisions[play.effect] ||
game.decision.player !== game.me)
)
return null;
if (play.effect === 'jubba' && game.decision?.kind !== 'choamStorm')
return null;
if (
play.effect === 'kulon' &&
(game.active !== game.me || (owner.moved ?? 0) >= (owner.movesAllowed ?? 1))
)
return null;
if (
play.source === 'nexus' &&
(!play.event || owner.ally || game.nexusCards?.card !== 'choam')
)
return null;
if (
play.source === 'printed' &&
(play.card.kind !== 'worthless' ||
play.card.name !== CHOAM_POWER_NAMES[play.effect])
)
return null;
return {
...selection,
type: 'card',
mode: 'choam',
card: play.card.id,
...(play.source === 'nexus'
? { nexus: play.event, effect: play.effect }
: {}),
};
}
/** Preserve printed powers first, then choose the cheapest legal Nexus fuel. */
export function choamPowerBotPlay(
game: GameView,
effect: ChoamPowerEffect,
value: (card: Card) => number,
): ChoamPowerPlay | undefined {
return choamPowerPlays(game, effect)
.filter((play) => choamPowerAction(game, play))
.sort(
(a, b) =>
Number(a.source === 'nexus') - Number(b.source === 'nexus') ||
value(a.card) - value(b.card) ||
a.card.id.localeCompare(b.card.id),
)[0];
}
+133 -12
View File
@@ -4,6 +4,7 @@ import { replaceNexusFaceDancers, validateNexusFaceDancerHistory, type NexusFace
import { createNexusSuboids, validateNexusSuboids, nexusSuboidsActive, type NexusSuboidReceipt } from './nexus-suboids';
import { quoteNexusAdvisors, createNexusAdvisors, validateNexusAdvisors, type NexusAdvisorReceipt } from './nexus-advisors';
import { createNexusSardaukar, validateNexusSardaukar, type NexusSardaukarReceipt } from './nexus-sardaukar';
import { CHOAM_NEXUS_EFFECTS, createNexusChoam, validateNexusChoam, type NexusChoamEffect, type NexusChoamReceipt } from './nexus-choam';
import { createTraitorDeclaration, validateTraitorDeclarations, type TraitorDeclaration, type TraitorDeclarationContext } from './traitor-declarations';
import { createNexusCards, validateNexusCards, drawNexusCard, replaceNexusCard, discardNexusCard, projectNexusCards, nexusCardMode, type NexusState } from './nexus-cards';
import { createNexusCardPhase, markNexusCardOccurred, validateNexusCardPhase, closeNexusCardPhase, nexusCardChoices, finishNexusCardChoice, type NexusCardPhase, type NexusCardChoice } from './nexus-card-phase';
@@ -1167,6 +1168,7 @@ export type Game = {
noFieldEvent?: string;
target?: string;
revival: boolean;
nexusEvent?: string;
} | null;
pendingChoamBattleIncome?: { owner: string; amount: number } | null;
pendingAuditor?: {
@@ -1273,6 +1275,8 @@ export type Game = {
nexusSardaukarHistory?: {receipt: NexusSardaukarReceipt; stage: 'pending' | 'active' | 'canceled'; parent: string; signature: string;
casualties?: {forces: CombatForces; dial: number; support: number; options: Casualties[]; outcome: 'pending' | 'complete'}}[];
nexusSardaukarLast?: {event: string; stage: 'pending' | 'active' | 'canceled'};
nexusChoamHistory?: {receipt: NexusChoamReceipt; stage: 'pending' | 'canceled' | 'complete' | 'fizzled'; frame: string; parent: string; signature: string}[];
nexusChoamLast?: {event: string; stage: 'pending' | 'canceled' | 'complete' | 'fizzled'};
nexusTraitorPending?: string | null;
nexusTraitorParent?: { event: string; signature: string } | null;
/** Null/absent disables the module; custody is installed at force placement. */
@@ -12078,6 +12082,7 @@ function marketGholaIntegrity(g: Game) {
nexusSuboidIntegrity(g);
nexusAdvisorIntegrity(g);
nexusSardaukarIntegrity(g);
nexusChoamIntegrity(g);
traitorDeclarationIntegrity(g);
nexusInspectionIntegrity(g);
homeworldHistoryIntegrity(g);
@@ -14546,9 +14551,10 @@ function finishResponse(g: Game, canceled: boolean) {
}
if (choamCancellation) {
const { declaration, storm, revival } = choamCancellation;
finishNexusChoam(g,g.pendingChoamWorthless!,'canceled');
g.pendingChoamWorthless = declaration.pendingChoamWorthless;
g.choamWorthlessBlocked = declaration.blocked;
log(g, 'CHOAM’s Worthless card effect was prevented for this phase.');
log(g, 'CHOAM’s Worthless card effect was prevented for this phase. The declared Treachery card is retained. Any Nexus card spent on the declaration remains spent.');
if (storm) commitChoamStormOffer(g, storm);
else if (revival) offerRevivalStop(g, revival);
else if (declaration.resume.kind === 'movement') resumeChoamMovement(g);
@@ -14801,6 +14807,7 @@ function finishResponse(g: Game, canceled: boolean) {
choamStormOptions(g).some((t) => t.territory === pending.location))
) {
discard(g, choam, pending.card);
finishNexusChoam(g,pending,'complete');
if (pending.effect === 'kulon') {
g.choamMovement = {
turn: g.turn,
@@ -14872,11 +14879,13 @@ function finishResponse(g: Game, canceled: boolean) {
);
}
}
} else
} else {
finishNexusChoam(g,pending,'fizzled');
log(
g,
'The declared card or force is no longer available; no Worthless effect occurs.',
);
}
if (pending.storm && g.stormResolution) offerChoamStorm(g);
if (pending.revival && g.pendingRevival) offerRevivalStop(g);
if (pending.movement && g.pendingChoamMove) resumeChoamMovement(g);
@@ -16106,6 +16115,104 @@ function choamWorthlessBlocked(g: Game, card: string) {
g.choamWorthlessBlocked.cards.includes(card)
);
}
function nexusChoamSignature(record: NonNullable<Game['nexusChoamHistory']>[number]) {
return JSON.stringify([record.receipt.signature, record.stage, record.frame, record.parent]);
}
function nexusChoamParent(g: Game, pending: NonNullable<Game['pendingChoamWorthless']>) {
return JSON.stringify({
movement: pending.movement ? g.pendingChoamMove : undefined,
revival: pending.revival ? g.pendingRevival : undefined,
storm: pending.storm ? g.stormResolution : undefined,
mentat: pending.mentat ? g.choamMentatPending : undefined,
});
}
function nexusChoamIntegrity(g: Game) {
const pending = g.pendingChoamWorthless;
const continuation = g.pendingTreacheryDiscard?.continuation;
const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume,
g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume,
continuation && 'resume' in continuation ? continuation.resume : null];
const responses = contexts.flatMap(context => {
const karama = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null;
return [context?.response, karama?.use.kind === 'cancel' ? karama.use.response : null];
}).filter(response => response?.kind === 'choamWorthless');
const history = g.nexusChoamHistory;
if (history === undefined) {
requireRule(!g.nexusChoamLast && !pending?.nexusEvent, 'CHOAM Cunning has lost its original saved history.');
return;
}
requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0,
'CHOAM Cunning history requires its original Nexus module.');
const events = new Set<string>();
for (const [index, record] of history.entries()) {
requireRule(record && typeof record === 'object' &&
Object.keys(record).sort().join(',') === 'frame,parent,receipt,signature,stage' &&
['pending','canceled','complete','fizzled'].includes(record.stage) && typeof record.frame === 'string' && typeof record.parent === 'string' &&
record.signature === nexusChoamSignature(record) && !events.has(record.receipt?.event) &&
(record.stage !== 'pending' || index === history.length - 1),
'CHOAM Cunning has a changed or duplicated outcome record.');
nexusRule(() => validateNexusChoam(g,record.receipt));
events.add(record.receipt.event);
}
const last = history.at(-1)!;
requireRule(JSON.stringify(g.nexusChoamLast) === JSON.stringify({event:last.receipt.event,stage:last.stage}),
'CHOAM Cunning has lost its latest completed or pending outcome.');
requireRule((last.stage === 'pending') === !!pending?.nexusEvent,
'CHOAM Cunning has lost or reopened its declared effect.');
if (last.stage === 'pending') {
requireRule(pending && pending.nexusEvent === last.receipt.event &&
pending.owner === last.receipt.owner && pending.card === last.receipt.card && pending.effect === last.receipt.effect &&
g.turn === last.receipt.turn && g.phase === last.receipt.phase &&
JSON.stringify(pending) === last.frame && nexusChoamParent(g,pending) === last.parent && responses.length > 0 && g.nexusCards.cards.discard.includes('choam'),
'CHOAM Cunning no longer matches its original card, selected effect or response.');
for (const response of responses) {
try { quoteChoamWorthlessCancellation(g,response!); }
catch(error) {
if (error instanceof ChoamWorthlessCancellationError) throw new RuleError(error.message);
throw error;
}
}
}
}
function recordNexusChoam(g: Game, p: Player, card: Card, effect: NexusChoamEffect) {
const receipt = nexusRule(() => createNexusChoam(g,p.id,g.phase,card.id,effect));
const record = {receipt,stage:'pending' as const,frame:JSON.stringify(g.pendingChoamWorthless),parent:nexusChoamParent(g,g.pendingChoamWorthless!),signature:''};
record.signature = nexusChoamSignature(record);
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,p.id,g.players));
(g.nexusChoamHistory ??= []).push(record);
g.nexusChoamLast = {event:receipt.event,stage:record.stage};
}
function finishNexusChoam(g: Game, pending: NonNullable<Game['pendingChoamWorthless']>, stage: 'canceled' | 'complete' | 'fizzled') {
if (!pending.nexusEvent) return;
const record = g.nexusChoamHistory?.find(record => record.receipt.event === pending.nexusEvent);
requireRule(record?.stage === 'pending' && record.frame === JSON.stringify(pending) && record.parent === nexusChoamParent(g,pending),
'CHOAM Cunning has lost its original declared effect.');
record.stage = stage; record.signature = nexusChoamSignature(record);
g.nexusChoamLast = {event:record.receipt.event,stage};
}
function choamPowerPlays(g: Game, p: Player) {
const printed = p.hand.filter(card => card.kind === 'worthless' &&
Object.values(CHOAM_NEXUS_EFFECTS).includes(card.name as typeof CHOAM_NEXUS_EFFECTS[NexusChoamEffect]) && !choamWorthlessBlocked(g,card.id))
.map(card => ({source:'printed' as const,card,effect:Object.entries(CHOAM_NEXUS_EFFECTS).find(([,name]) => name === card.name)![0] as NexusChoamEffect,
blocked:card.name === 'Kull Wahad' ? 'Kull Wahad’s reaction and discard sequence is still being implemented.' : null}));
if (p.faction !== 'choam' || p.ally || g.nexusCards?.cards?.hands[p.id] !== 'choam') return printed;
return [...printed, ...p.hand.flatMap(card => (Object.keys(CHOAM_NEXUS_EFFECTS) as NexusChoamEffect[]).map(effect => {
let blocked: string | null = null;
if (effect === 'kull') blocked = 'Kull Wahad’s reaction and discard sequence is still being implemented.';
else if (g.status !== 'playing' || g.truthtrance || g.response || g.phaseOpening || g.pendingKarama || g.pendingNullentropy || g.pendingTreacheryDiscard || g.choamMarket)
blocked = 'Finish the current interaction before declaring CHOAM Cunning.';
else if (choamWorthlessBlocked(g,card.id)) blocked = 'This card’s special-effect use is blocked for this phase.';
else if (giftReserved(g,p.id,card.id)) blocked = 'This physical card is reserved for a pending gift.';
else if (({kulon:5,laLaLa:4,gamont:8,baliset:5,jubba:0} as const)[effect] !== g.phase)
blocked = 'Use this special effect in its normal phase.';
else if (g.decision && !(g.decision.player === p.id && ['choamStorm','choamFreeRevival','choamMovement','choamMentat'].includes(g.decision.kind)))
blocked = 'Finish the current decision before declaring this effect.';
else if (effect === 'kulon' && (g.active !== p.id || p.moved >= movesAllowed(g,p))) blocked = 'Use Kulon before an available move on your own turn.';
else if (effect === 'kulon' && g.ornithopter?.mode === 'range3' && g.ornithopter.player === p.id) blocked = 'Kulon combined with fixed Ornithopter range awaits a ruling.';
else if (effect === 'jubba' && !(g.decision?.kind === 'choamStorm' && g.decision.player === p.id)) blocked = 'Use Jubba Cloak in your moving-storm response.';
return {source:'nexus' as const,card,effect,event:JSON.stringify(['nexusChoam',g.turn,g.phase,p.id,card.id,effect]),blocked};
}))];
}
function gamontAvailable(
g: Game,
target: string,
@@ -16154,19 +16261,28 @@ function playChoamWorthless(g: Game, p: Player, action: Action) {
(!g.pendingRevival || reactive),
'CHOAM must finish the current decision before playing this Worthless effect.',
);
const card = p.hand.find(
(c) => c.id === action.card && c.kind === 'worthless',
);
const nexus = action.nexus !== undefined;
const card = p.hand.find((c) => c.id === action.card && (nexus || c.kind === 'worthless'));
requireRule(
card && !choamWorthlessBlocked(g, card.id),
'Choose an owned Worthless card whose effect is not blocked this phase.',
);
let effectName = card.name;
if (nexus) {
requireRule(Object.keys(action).every(key => ['type','mode','card','nexus','effect','target','territory','amount','from','elite'].includes(key)),
'Choose one physical card, special effect and its current target.');
const play = choamPowerPlays(g,p).find(play => play.source === 'nexus' && play.card.id === card.id &&
play.effect === action.effect && 'event' in play && play.event === action.nexus);
requireRule(play && !play.blocked, play?.blocked ?? 'Choose your current CHOAM Nexus Cunning effect.');
effectName = CHOAM_NEXUS_EFFECTS[play.effect];
}
requireRule(!giftReserved(g,p.id,card.id), 'This physical card is reserved for a pending gift.');
let effect: 'kulon' | 'laLaLa' | 'gamont' | 'baliset' | 'jubba';
let target: string | undefined;
let key: string | undefined;
let elite: number | undefined;
let noFieldEvent: string | undefined;
if (card.name === 'Kulon') {
if (effectName === 'Kulon') {
requireRule(
g.ornithopter?.mode !== 'range3' || g.ornithopter.player !== p.id,
'Kulon combined with fixed Ornithopter range awaits a ruling.',
@@ -16176,7 +16292,7 @@ function playChoamWorthless(g: Game, p: Player, action: Action) {
'Use Kulon on your movement turn before an available move.',
);
effect = 'kulon';
} else if (card.name === 'Jubba Cloak') {
} else if (effectName === 'Jubba Cloak') {
requireRule(
g.phase === 0 && storming && g.stormResolution,
'Use Jubba Cloak when CHOAM responds to the moving storm.',
@@ -16192,7 +16308,7 @@ function playChoamWorthless(g: Game, p: Player, action: Action) {
);
target = p.id;
effect = 'jubba';
} else if (card.name === 'Baliset') {
} else if (effectName === 'Baliset') {
requireRule(
g.phase === 5,
'Baliset is played during Shipment and Movement.',
@@ -16220,7 +16336,7 @@ function playChoamWorthless(g: Game, p: Player, action: Action) {
'That player and territory are already restricted.',
);
effect = 'baliset';
} else if (card.name === 'Trip to Gamont') {
} else if (effectName === 'Trip to Gamont') {
requireRule(
g.phase === 8 && (action.amount === undefined || action.amount === 1),
'Trip to Gamont returns exactly one force during Mentat Pause.',
@@ -16261,7 +16377,7 @@ function playChoamWorthless(g: Game, p: Player, action: Action) {
effect = 'gamont';
} else {
requireRule(
card.name === 'La La La' && g.phase === 4,
effectName === 'La La La' && g.phase === 4,
'This Worthless effect is not available in this phase.',
);
effect = 'laLaLa';
@@ -16287,7 +16403,9 @@ function playChoamWorthless(g: Game, p: Player, action: Action) {
elite,
mentat,
...(noFieldEvent ? { noFieldEvent } : {}),
...(nexus ? {nexusEvent:action.nexus as string} : {}),
};
if (nexus) recordNexusChoam(g,p,card,effect);
if (reactive || mentat || moving || storming) g.decision = null;
g.ready = [];
g.response = {
@@ -16312,10 +16430,12 @@ function playChoamWorthless(g: Game, p: Player, action: Action) {
...(elite !== undefined ? { elite } : {}),
}
: {}),
intent: card.name,
intent: effectName,
passed: [],
};
log(g, `${p.name} declared ${card.name} for its special effect.`);
log(g, nexus
? `${p.name} spent CHOAM Nexus Cunning and declared ${effectName}. Its chosen Treachery card stays private and is discarded only if the effect occurs; Karama may prevent this native advantage.`
: `${p.name} declared ${effectName} for its special effect.`);
}
/** Internal normalized command; never accept this object directly from a route. */
export type SpecialKaramaIntent = {
@@ -20944,6 +21064,7 @@ export function viewGame(state: Game, id: string) {
].includes(c.name) &&
!choamWorthlessBlocked(g, c.id),
),
plays: choamPowerPlays(g,me),
}
: null,
balisetRestrictions:
+152
View File
@@ -0,0 +1,152 @@
import type { FactionId } from './catalog';
export const CHOAM_NEXUS_EFFECTS = Object.freeze({
kulon: 'Kulon',
laLaLa: 'La La La',
gamont: 'Trip to Gamont',
baliset: 'Baliset',
jubba: 'Jubba Cloak',
kull: 'Kull Wahad',
} as const);
export type NexusChoamEffect = keyof typeof CHOAM_NEXUS_EFFECTS;
export type NexusChoamContext = {
turn: number;
players: readonly { id: string; faction: FactionId }[];
};
export type NexusChoamReceipt = {
version: 1;
event: string;
turn: number;
phase: number;
owner: string;
card: string;
effect: NexusChoamEffect;
roster: { id: string; faction: FactionId }[];
signature: string;
};
function requireChoam(value: unknown, message: string): asserts value {
if (!value) throw new Error(message);
}
function identifier(value: unknown): value is string {
return (
typeof value === 'string' &&
value.trim().length > 0 &&
!['__proto__', 'constructor', 'prototype'].includes(value)
);
}
function roster(context: NexusChoamContext) {
requireChoam(
Number.isSafeInteger(context.turn) &&
context.turn > 0 &&
Array.isArray(context.players) &&
context.players.length > 0 &&
context.players.every(
(p) => p && identifier(p.id) && identifier(p.faction),
) &&
new Set(context.players.map((p) => p.id)).size ===
context.players.length &&
new Set(context.players.map((p) => p.faction)).size ===
context.players.length,
'CHOAM Nexus needs its original faction roster and turn.',
);
return context.players
.map(({ id, faction }) => ({ id, faction }))
.sort((a, b) => a.id.localeCompare(b.id));
}
function phaseMatches(effect: NexusChoamEffect, phase: number): boolean {
if (!Number.isSafeInteger(phase) || phase < 0 || phase > 8) return false;
switch (effect) {
case 'kulon':
case 'baliset':
return phase === 5;
case 'laLaLa':
return phase === 4;
case 'gamont':
return phase === 8;
case 'jubba':
return phase === 0;
// This describes printed timing only; the engine must separately authorize
// a real Karama attempt and retains Kull's unresolved implementation gate.
case 'kull':
return true;
default:
return false;
}
}
function signature(receipt: NexusChoamReceipt): string {
return JSON.stringify([
receipt.version,
receipt.event,
receipt.turn,
receipt.phase,
receipt.owner,
receipt.card,
receipt.effect,
receipt.roster.map((p) => [p.id, p.faction]),
]);
}
/** Historical evidence is independent of later card custody or alliances.
* Current eligibility, canonical card inventory and progress belong to the engine. */
export function validateNexusChoam(
context: NexusChoamContext,
receipt: NexusChoamReceipt,
): void {
const seats = roster(context);
requireChoam(
receipt &&
typeof receipt === 'object' &&
!Array.isArray(receipt) &&
[Object.prototype, null].includes(Object.getPrototypeOf(receipt)) &&
Object.keys(receipt).sort().join(',') ===
'card,effect,event,owner,phase,roster,signature,turn,version' &&
receipt.version === 1 &&
Number.isSafeInteger(receipt.turn) &&
receipt.turn > 0 &&
receipt.turn <= context.turn &&
identifier(receipt.card) &&
Object.hasOwn(CHOAM_NEXUS_EFFECTS, receipt.effect) &&
phaseMatches(receipt.effect, receipt.phase) &&
seats.some((p) => p.id === receipt.owner && p.faction === 'choam') &&
receipt.event ===
JSON.stringify([
'nexusChoam',
receipt.turn,
receipt.phase,
receipt.owner,
receipt.card,
receipt.effect,
]) &&
JSON.stringify(receipt.roster) === JSON.stringify(seats) &&
receipt.signature === signature(receipt),
'CHOAM Nexus has lost its original card, chosen effect, timing or owner.',
);
}
export function createNexusChoam(
context: NexusChoamContext,
owner: string,
phase: number,
card: string,
effect: NexusChoamEffect,
): NexusChoamReceipt {
const receipt: NexusChoamReceipt = {
version: 1,
event: JSON.stringify([
'nexusChoam',
context.turn,
phase,
owner,
card,
effect,
]),
turn: context.turn,
phase,
owner,
card,
effect,
roster: roster(context),
signature: '',
};
receipt.signature = signature(receipt);
validateNexusChoam(context, receipt);
return receipt;
}
+8 -6
View File
@@ -3340,7 +3340,7 @@ export const RULE_TOPICS: RuleTopic[] = [
searchText: NEXUS_CARD_REFERENCE.map(card => `${card.faction} ${card.betrayal} ${card.cunning} ${card.secretAlly}`).join(' '),
category: 'Advanced & expansions',
coverage: 'Partial',
summary: 'A separate twelve-card deck rewards remaining unallied. Private draws, Atreides inspections, Harkonnen exchanges and native Tleilaxu, Ixian, Bene Gesserit and Emperor Cunning are integrated. Other effects and complete module play remain in development.',
summary: 'A separate twelve-card deck rewards remaining unallied. Private draws, Atreides inspections, Harkonnen exchanges and native Tleilaxu, Ixian, Bene Gesserit, Emperor and five CHOAM Cunning effects are integrated. Other effects and complete module play remain in development.',
steps: [
'This optional module can be combined with Basic or Advanced rules independently of the factions and other expansion modules selected. Include one Nexus Card for each of the twelve factions.',
'At the end of the entire Spice Blow and Nexus phase, if a Nexus occurred and at least one alliance exists, each unallied player may draw one card or discard their held card and draw a replacement. You may decline. The two Advanced spice piles do not grant two closing draws.',
@@ -3364,15 +3364,17 @@ export const RULE_TOPICS: RuleTopic[] = [
'Bene Gesserit Secret Ally borrowed Voice remains unavailable pending its cancellation and timing interpretation. Its printed permission does not make the other unfinished effects available.',
'Emperor Cunning requires an Advanced battle with at least five ordinary Emperor counters and no actual Sardaukar, before submitting your plan. The played Nexus card opens one Karama response. Allowing it makes five ordinary counters count as Sardaukar for this battle; cancellation prevents the entire enhancement and leaves the Nexus card spent.',
'Temporary Sardaukar use the applicable strength and support rules, including the Fremen exception, but remain physically ordinary counters. Their losses go to Tanks as ordinary counters, without changing starred reserves or Tanks. Existing inspected or promised plans remain binding. The role ends after this battle; fewer-than-five groups and other Emperor Nexus modes remain unavailable.',
'CHOAM Cunning spends its Nexus card to use one held Treachery Card for one chosen Worthless effect at that effect’s normal timing, in Basic and Advanced. Choose the physical card separately from the effect. Kulon, La La La, Baliset, Jubba Cloak and Trip to Gamont are supported; Kull Wahad’s Karama-prevention reaction remains unavailable.',
'One Karama response covers the CHOAM effect. Cancellation leaves the chosen Treachery Card in hand and the Nexus spent. An allowed use discards the actual card once, retaining its printed identity and applicable discard consequences; it does not create a named Worthless card or a turn-long conversion permission.',
'Betrayal reactions await a private response timing decision. Other factions’ Nexus effects and complete module games are unfinished, so public module starts remain disabled.',
],
related: ['ecaz-modules', 'spice-blow', 'alliance-funding'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, revealed Face Dancer refresh, turn-wide Suboid strength, whole-territory advisor conversion and temporary Emperor Sardaukar. Borrowed Voice, Betrayal, Tleilaxu revival and remaining effects are unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation and counted advisor-territory selection and temporary Sardaukar with native Karama responses. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All profiles handle the integrated effects through private offers, authorized choices and projected forces. Advisor choices use public opposing fighter counts. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and the integrated effect families retain explicit unresolved boundaries, including fresh advisors, storm and borrowed Voice.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, support solver, territory-selection controls, bot and recovery regressions; Emperor Cunning browser checks pass while complete module games remain a separate requirement.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts'] },
{ area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, revealed Face Dancer refresh, turn-wide Suboid strength, whole-territory advisor conversion, temporary Emperor Sardaukar and five source-aware CHOAM Cunning effects. Borrowed Voice, Betrayal, Tleilaxu revival and remaining effects are unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation and counted advisor-territory selection, temporary Sardaukar and CHOAM physical-card/effect selection with native Karama responses. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All profiles handle the integrated effects through private offers, authorized choices and projected forces. Advisor choices use public opposing fighter counts. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and the integrated effect families retain explicit unresolved boundaries, including fresh advisors, storm and borrowed Voice.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, support solver, territory-selection controls, bot and recovery regressions; Emperor Cunning browser checks pass while complete module games remain a separate requirement.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts'] },
],
},
...[
+228
View File
@@ -0,0 +1,228 @@
import assert from 'node:assert/strict';
import { applyAction, type Action, type Game } from '../game/engine';
import { gameDistance, splitLocation, TERRITORIES } from '../game/board';
import { treacheryDeck, type Card } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { newRevivalRules } from '../game/revival';
import {
nexusTraitorFixture,
nexusTraitorInventory,
} from './fixture-nexus-traitors';
import { nexusAllow, nexusReload } from './fixture-nexus-cards';
export type NexusChoamEffect =
| 'kulon'
| 'laLaLa'
| 'gamont'
| 'baliset'
| 'jubba';
export const NEXUS_CHOAM_EFFECTS: NexusChoamEffect[] = [
'kulon',
'laLaLa',
'gamont',
'baliset',
'jubba',
];
export type NexusChoamFixture = {
g: Game;
owner: string;
target: string;
observer: string;
cost: Card;
karama?: string;
effect: NexusChoamEffect;
selection: Record<string, unknown>;
};
export function holdNexusChoamCard(g: Game, owner: string, kind: string): Card {
const index = g.deck.findIndex(
(c) => c.kind === kind || c.effect === kind || c.name === kind,
);
assert.ok(index >= 0, `Missing physical ${kind}`);
const card = g.deck.splice(index, 1)[0];
g.players.find((p) => p.id === owner)!.hand.push(card);
return card;
}
/** Final identities, faction roster, Advanced mode and Nexus components precede
* genuine setup. Only conserved later board positions/phase boundaries are
* staged; the public revival, move, storm and Mentat choices use real actions. */
export function nexusChoamFixture(
effect: NexusChoamEffect,
options: {
seatIds?: [string, string, string];
advanced?: boolean;
opponentFaction?: 'guild' | 'emperor' | 'richese';
costKind?: string;
karama?: boolean;
} = {},
): NexusChoamFixture {
const [owner, target, observer] = options.seatIds ?? ['p', 'q', 'r'];
const phase =
effect === 'laLaLa'
? 4
: effect === 'gamont'
? 7
: effect === 'jubba'
? 0
: 5;
let g = nexusTraitorFixture({
ownerFaction: 'choam',
opponentFaction: options.opponentFaction,
advanced: options.advanced ?? true,
seatIds: [owner, target, observer],
phase,
});
const cards = g.nexusCards!.cards!,
index = cards.deck.indexOf('choam');
assert.ok(index >= 0);
cards.deck[index] = cards.hands[owner]!;
cards.hands[owner] = 'choam';
for (const p of g.players) {
Object.assign(p, {
forces: {},
reserves: 20,
tanks: 0,
spice: 20,
moved: 0,
shipped: true,
revived: 0,
});
if (p.elites)
Object.assign(p.elites, {
forces: {},
reserves: p.faction === 'emperor' ? 5 : 3,
tanks: 0,
revived: 0,
});
}
Object.assign(g, {
active: owner,
order: [owner, target, observer],
ready: [],
phaseOpening: null,
response: null,
decision: null,
pendingRevival: null,
choamMarket: null,
movementRemaining: [owner, target, observer],
spice: {},
});
const cost = holdNexusChoamCard(g, owner, options.costKind ?? 'projectile');
const karama =
options.karama === false
? undefined
: holdNexusChoamCard(g, target, 'karama').id;
const p = g.players[0],
q = g.players[1];
let selection: Record<string, unknown> = {};
if (effect === 'laLaLa') {
g.revivalRules = newRevivalRules();
q.tanks = 4;
q.reserves = 16;
g = applyAction(g, target, { type: 'revive', amount: 3 });
assert.equal(g.decision?.kind, 'choamFreeRevival');
selection = { target };
} else if (effect === 'jubba') {
g.storm = 5;
g.stormPending = 3;
p.forces = { 'red_chasm:7': 4 };
p.reserves = 16;
q.forces = { 'red_chasm:7': 3 };
q.reserves = 17;
g.spice = { 'red_chasm:7': 8 };
for (const id of g.order) g = applyAction(g, id, { type: 'ready' });
assert.equal(g.decision?.kind, 'choamStorm');
selection = { territory: 'red_chasm' };
} else if (effect === 'baliset') {
const source = 'red_chasm:7';
const to = TERRITORIES.flatMap((t) =>
t.sectors.map((s) => `${t.id}:${s}`),
).find((key) => {
const loc = splitLocation(key);
return (
loc.territory !== 'red_chasm' &&
loc.sector !== g.storm &&
gameDistance(
g,
source,
key,
(k) => splitLocation(k).sector === g.storm,
) === 1
);
});
assert.ok(to);
p.forces = { [to]: 1 };
p.reserves = 19;
q.forces = { [source]: 3 };
q.reserves = 17;
g.active = target;
g = applyAction(g, target, {
type: 'move',
from: source,
amount: 2,
...splitLocation(to),
});
assert.equal(g.decision?.kind, 'choamMovement');
selection = { target, territory: splitLocation(to).territory };
} else if (effect === 'gamont') {
q.forces = { 'arrakeen:10': 2 };
q.reserves = 18;
for (const id of g.order)
if (g.phase === 7) g = applyAction(g, id, { type: 'ready' });
assert.equal(g.decision?.kind, 'choamMarket');
g = applyAction(g, owner, { type: 'decision', done: true });
for (const id of g.order)
if (!g.ready.includes(id)) g = applyAction(g, id, { type: 'ready' });
assert.equal(g.decision?.kind, 'choamMarket');
g = applyAction(g, owner, { type: 'decision', done: true });
assert.equal(g.decision?.kind, 'choamMentat');
selection = { target, from: 'arrakeen:10', elite: 0 };
} else {
p.forces = { 'red_chasm:7': 3 };
p.reserves = 17;
}
nexusChoamInventory(g);
return { g, owner, target, observer, cost, karama, effect, selection };
}
export function nexusChoamRequest(f: NexusChoamFixture, g = f.g): Action {
return {
type: 'card',
mode: 'choam',
card: f.cost.id,
effect: f.effect,
nexus: JSON.stringify([
'nexusChoam',
g.turn,
g.phase,
f.owner,
f.cost.id,
f.effect,
]),
...f.selection,
};
}
export function nexusChoamInventory(g: Game) {
if (!g.richeseCache) return nexusTraitorInventory(g);
const extra = richeseCards(),
ids = new Set(extra.map((card) => card.id));
assert.deepEqual(
[
...g.deck,
...g.discard,
...g.players.flatMap((p) => p.hand),
...g.richeseCache,
]
.map((card) => card.id)
.sort(),
[...treacheryDeck(g.expansions), ...extra].map((card) => card.id).sort(),
);
// The shared traitor fixture checks the ordinary deck. Above we independently
// include every native Richese component, then reuse its other physical checks.
const ordinary = structuredClone(g);
ordinary.deck = ordinary.deck.filter((card) => !ids.has(card.id));
ordinary.discard = ordinary.discard.filter((card) => !ids.has(card.id));
for (const p of ordinary.players)
p.hand = p.hand.filter((card) => !ids.has(card.id));
nexusTraitorInventory(ordinary);
}
export const nexusChoamAllow = nexusAllow;
export const nexusChoamReload = nexusReload;
+2 -8
View File
@@ -63,10 +63,7 @@ export function nexusTraitorFixture(
}
assert.equal(g.status, 'playing');
for (const player of g.players) g.deck.push(...player.hand.splice(0));
if (
options.ix ||
(g.advanced && g.players.some((p) => p.faction === 'choam'))
) {
if (options.ix || g.players.some((p) => p.faction === 'choam')) {
// Resolve actual CHOAM markets and Ix phase openings before advancing.
for (let i = 0; g.phase === 0 && i < 30; i++) {
let next: Game | undefined;
@@ -88,10 +85,7 @@ export function nexusTraitorFixture(
assert.equal(g.phase, 1);
} else g = enterNexusSpice(g);
orderNexusSpice(g, ['land', 'land']);
if (
options.ix ||
(g.advanced && g.players.some((p) => p.faction === 'choam'))
) {
if (options.ix || g.players.some((p) => p.faction === 'choam')) {
for (let i = 0; g.phase === 1 && i < 40; i++) {
let next: Game | undefined;
for (const p of g.players) {
+2 -2
View File
@@ -123,8 +123,8 @@ void test('the owned face highlights its mode from the public roster and all thr
void test('controls expose public counts and waiting players, disable while busy, and offer no effect play action', () => {
const view = fixture();
const html = markup(view, true);
assert.match(html, /Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian, Bene Gesserit and Emperor Cunning/);
assert.match(html, /other effects remain unavailable/);
assert.match(html, /Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian, Bene Gesserit, Emperor and CHOAM Cunning/);
assert.match(html, /other unfinished effects remain unavailable/);
assert.doesNotMatch(html, /other card effects are not playable yet/);
assert.match(html, /10 in deck · 0 discarded · 2 held/);
assert.match(html, /Waiting for Nexus choices: Harkonnen/);
+55
View File
@@ -0,0 +1,55 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
viewGame,
normalizeAutomaticGame,
type Game,
} from '../game/engine';
import {
nexusChoamFixture,
nexusChoamRequest,
nexusChoamReload,
} from './fixture-nexus-choam';
for (const effect of ['laLaLa', 'baliset', 'jubba'] as const) {
void test(`CHOAM Cunning ${effect} binds its original suspended operation before a valid-looking saved replacement`, () => {
const f = nexusChoamFixture(effect);
const pending = applyAction(f.g, f.owner, nexusChoamRequest(f));
const g = nexusChoamReload(pending);
// Each replacement would be legal by itself. It was not the operation
// which earned this genuine reaction and cannot replace that saved parent.
if (effect === 'laLaLa') {
assert.equal(g.pendingRevival!.amount, 3);
g.pendingRevival!.amount = 2;
g.pendingRevival!.normalCost = 2;
g.pendingRevival!.cost = 2;
} else if (effect === 'baliset') {
assert.equal(g.pendingChoamMove!.total, 2);
g.pendingChoamMove!.total = 1;
g.pendingChoamMove!.group[0][1] = 1;
} else {
assert.equal(g.stormResolution!.distance, 3);
g.stormResolution!.distance = 4;
}
const before = JSON.stringify(g);
const checks: ((state: Game) => unknown)[] = [
...g.players.map((p) => (state: Game) => viewGame(state, p.id)),
(state) => normalizeAutomaticGame(state),
(state) =>
applyAction(state, f.target, {
type: 'card',
mode: 'cancel',
card: f.karama,
}),
(state) => applyAction(state, f.owner, { type: 'passResponse' }),
];
for (const check of checks) {
assert.throws(
() => check(g),
/CHOAM|Cunning|original|parent|source|continuation/i,
);
assert.equal(JSON.stringify(g), before);
}
});
}
+74
View File
@@ -0,0 +1,74 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import {
choamPowerAction,
choamPowerBotPlay,
choamPowerPlays,
} from '../game/choam-power-options';
import {
NEXUS_CHOAM_EFFECTS,
nexusChoamFixture,
nexusChoamInventory,
holdNexusChoamCard,
} from './fixture-nexus-choam';
void test('all four CHOAM profiles use projected Cunning fuel at existing effect timing and preserve the Easy revival policy', () => {
for (const effect of NEXUS_CHOAM_EFFECTS)
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const f = nexusChoamFixture(effect);
const view = viewGame(f.g, f.owner);
view.players.find((player) => player.id === f.owner)!.bot = profile;
const before = structuredClone(view);
const action = botActions(view)[0];
assert.ok(action, `${profile}/${effect}`);
if (effect === 'laLaLa' && profile === 'Easy')
assert.deepEqual(action, { type: 'decision', decline: true });
else {
assert.equal(
action.nexus,
choamPowerPlays(view, effect).find(
(play) => play.card.id === f.cost.id && play.source === 'nexus',
)!.event,
);
assert.equal(action.card, f.cost.id);
assert.equal(action.effect, effect);
}
const result = applyAction(f.g, f.owner, action);
nexusChoamInventory(result);
assert.deepEqual(view, before);
}
});
void test('CHOAM fuel ranking favors printed powers then low-value private costs and never consumes blocked cards', () => {
const f = nexusChoamFixture('kulon');
const fuel = holdNexusChoamCard(f.g, f.owner, 'Baliset');
const value = (card: { id: string }) => (card.id === fuel.id ? 0 : 10);
const view = viewGame(f.g, f.owner);
const selected = choamPowerBotPlay(view, 'kulon', value)!;
assert.equal(selected.source, 'nexus');
assert.equal(selected.card.id, fuel.id);
for (const p of view.players.filter((player) => player.id !== f.owner))
for (const key of ['hand', 'spice', 'traitors'])
Object.defineProperty(p, key, {
get() {
throw new Error('Private opponent field read');
},
});
assert.equal(choamPowerBotPlay(view, 'kulon', value)!.card.id, fuel.id);
for (const play of view.choamWorthless!.plays)
if (play.card.id === fuel.id) play.blocked = 'Reserved';
assert.equal(choamPowerBotPlay(view, 'kulon', value)!.card.id, f.cost.id);
assert.equal(choamPowerAction(view, selected), null);
const printed = holdNexusChoamCard(f.g, f.owner, 'Kulon');
const regular = choamPowerBotPlay(viewGame(f.g, f.owner), 'kulon', (card) =>
card.id === printed.id ? 100 : 0,
)!;
assert.equal(regular.source, 'printed');
assert.deepEqual(choamPowerAction(viewGame(f.g, f.owner), regular), {
type: 'card',
mode: 'choam',
card: printed.id,
});
});
+142
View File
@@ -0,0 +1,142 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, viewGame, type GameView } from '../game/engine';
import { ChoamWorthless } from '../components/choam-worthless';
import { ChoamBaliset } from '../components/choam-baliset';
import { ChoamStorm } from '../components/choam-storm';
import { ChoamGamont } from '../components/choam-gamont';
import {
CHOAM_POWER_NAMES,
choamPowerAction,
choamPowerPlays,
} from '../game/choam-power-options';
import {
NEXUS_CHOAM_EFFECTS,
nexusChoamFixture,
nexusChoamInventory,
nexusChoamRequest,
holdNexusChoamCard,
type NexusChoamEffect,
} from './fixture-nexus-choam';
function html(game: GameView, effect: NexusChoamEffect, busy = false) {
const component =
effect === 'jubba'
? ChoamStorm
: effect === 'baliset'
? ChoamBaliset
: effect === 'gamont'
? ChoamGamont
: ChoamWorthless;
return renderToStaticMarkup(
createElement(component, { game, busy, act() {} }),
);
}
void test('five actual CHOAM windows show physical weapon fuel separately from the chosen Worthless effect and submit the real source action', () => {
for (const effect of NEXUS_CHOAM_EFFECTS) {
const f = nexusChoamFixture(effect);
const view = viewGame(f.g, f.owner),
before = structuredClone(view);
const play = choamPowerPlays(view, effect).find(
(candidate) =>
candidate.source === 'nexus' && candidate.card.id === f.cost.id,
)!;
assert.ok(play, effect);
assert.equal(play.blocked, null, effect);
const markup = html(view, effect);
assert.ok(markup.includes(f.cost.name), effect);
assert.ok(markup.includes(CHOAM_POWER_NAMES[effect]), effect);
assert.match(markup, /Inspect card/);
assert.match(markup, /spends CHOAM Nexus Cunning once/);
assert.match(markup, /the Nexus stays spent/);
assert.match(html(view, effect, true), /disabled=""/);
const action = choamPowerAction(view, play, f.selection);
assert.deepEqual(action, nexusChoamRequest(f));
const pending = applyAction(f.g, f.owner, action!);
assert.equal(pending.response?.intent, CHOAM_POWER_NAMES[effect]);
assert.notEqual(pending.response?.intent, f.cost.name);
assert.deepEqual(view, before);
nexusChoamInventory(pending);
}
});
void test('CHOAM cost selection keeps printed powers unchanged and exposes explicit source blocks including Kull', () => {
const f = nexusChoamFixture('kulon');
const printed = holdNexusChoamCard(f.g, f.owner, 'Kulon');
const view = viewGame(f.g, f.owner);
const regular = choamPowerPlays(view, 'kulon').find(
(play) => play.source === 'printed',
)!;
assert.deepEqual(choamPowerAction(view, regular), {
type: 'card',
mode: 'choam',
card: printed.id,
});
assert.equal(
choamPowerAction(view, regular, { nexus: 'forged', effect: 'jubba' }),
null,
);
assert.match(html(view, 'kulon'), /Card to use for Kulon/);
assert.match(html(view, 'kulon'), /printed power/);
const blocked = structuredClone(view);
for (const play of blocked.choamWorthless!.plays)
if (play.effect === 'kulon')
play.blocked = 'This card is reserved for another committed action.';
assert.match(html(blocked, 'kulon'), /reserved for another committed action/);
assert.match(html(blocked, 'kulon'), /disabled=""[^>]*>Use Kulon/);
for (const play of choamPowerPlays(blocked, 'kulon'))
assert.equal(choamPowerAction(blocked, { ...play, blocked: null }), null);
const kull = choamPowerPlays(view, 'kull').find(
(play) => play.source === 'nexus',
)!;
assert.ok(kull.blocked);
assert.equal(choamPowerAction(view, kull), null);
assert.match(html(view, 'kulon'), /Kull Wahad.*not implemented/);
});
void test('CHOAM shared actions fence stale source events, actors and interruption windows without reading outsider private choices', () => {
const f = nexusChoamFixture('kulon');
const view = viewGame(f.g, f.owner);
const play = choamPowerPlays(view, 'kulon').find(
(candidate) => candidate.source === 'nexus',
)!;
assert.equal(choamPowerAction(view, { ...play, event: 'stale' }), null);
for (const change of [
(v: GameView) => {
v.phase = 4;
},
(v: GameView) => {
v.active = f.target;
},
(v: GameView) => {
v.automaticContinuationPending = true;
},
(v: GameView) => {
v.decision = { kind: 'choamMentat', player: f.owner };
},
(v: GameView) => {
v.players.find((p) => p.id === f.owner)!.ally = f.target;
},
(v: GameView) => {
v.nexusCards!.card = null;
},
]) {
const blocked = structuredClone(view);
change(blocked);
assert.equal(choamPowerAction(blocked, play), null);
}
for (const id of [f.target, f.observer]) {
const other = viewGame(f.g, id);
assert.equal(other.choamWorthless, null);
Object.defineProperty(other, 'choamWorthless', {
get() {
throw new Error('Opponent cost choices read');
},
});
assert.deepEqual(choamPowerPlays(other, 'kulon'), []);
assert.equal(choamPowerAction(other, play), null);
}
});
+269
View File
@@ -0,0 +1,269 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Game, type Action } from '../game/engine';
import {
NEXUS_CHOAM_EFFECTS,
nexusChoamFixture,
holdNexusChoamCard,
nexusChoamRequest,
nexusChoamAllow,
nexusChoamInventory,
nexusChoamReload,
} from './fixture-nexus-choam';
function reject(g: Game, owner: string, action: Action) {
const before = JSON.stringify(g);
assert.throws(() => applyAction(g, owner, action));
assert.equal(JSON.stringify(g), before);
}
for (const effect of NEXUS_CHOAM_EFFECTS) {
void test(`CHOAM Nexus ${effect} spends Nexus before the response and applies its real effect once`, () => {
const f = nexusChoamFixture(effect),
before = structuredClone(f.g);
const offer = viewGame(f.g, f.owner).choamWorthless!.plays.find(
(play) =>
play.source === 'nexus' &&
play.effect === effect &&
play.card.id === f.cost.id,
)!;
assert.ok(offer && offer.source === 'nexus');
assert.equal(offer.blocked, null);
assert.equal(offer.event, nexusChoamRequest(f).nexus);
let g = applyAction(f.g, f.owner, nexusChoamRequest(f));
assert.equal(g.response?.kind, 'choamWorthless');
assert.equal(g.nexusCards!.cards!.hands[f.owner], null);
assert.equal(
g.nexusCards!.cards!.discard.filter((card) => card === 'choam').length,
1,
);
assert.deepEqual(
g.players[0].hand.find((card) => card.id === f.cost.id),
f.cost,
);
assert.deepEqual(
g.players.map((p) => p.forces),
before.players.map((p) => p.forces),
);
g = nexusChoamAllow(nexusChoamReload(g));
assert.equal(g.nexusChoamLast?.stage, 'complete');
assert.deepEqual(
g.discard.find((card) => card.id === f.cost.id),
f.cost,
);
assert.equal(g.discard.filter((card) => card.id === f.cost.id).length, 1);
assert.equal(
g.players[0].hand.some((card) => card.id === f.cost.id),
false,
);
if (effect === 'kulon')
assert.deepEqual(g.choamMovement, { turn: g.turn, bonus: 1 });
if (effect === 'laLaLa') {
assert.equal(g.players[1].tanks, 4);
assert.equal(g.players[1].spice, 20);
assert.equal(g.pendingRevival, null);
}
if (effect === 'gamont') {
assert.equal(g.players[1].forces['arrakeen:10'], 1);
assert.equal(g.players[1].reserves, 19);
assert.equal(g.decision?.kind, 'choamMentat');
}
if (effect === 'baliset') {
assert.deepEqual(g.players[1].forces, before.players[1].forces);
assert.equal(g.players[1].moved, 0);
assert.equal(g.pendingChoamMove, null);
}
if (effect === 'jubba') {
assert.equal(g.players[0].forces['red_chasm:7'], 4);
assert.equal(g.players[1].tanks, 3);
assert.equal(g.spice['red_chasm:7'], undefined);
}
reject(g, f.owner, nexusChoamRequest(f));
nexusChoamInventory(g);
for (const p of g.players)
assert.deepEqual(viewGame(nexusChoamReload(g), p.id), viewGame(g, p.id));
});
void test(`CHOAM Nexus ${effect} Karama retains the actual cost and resumes the original public context`, () => {
const f = nexusChoamFixture(effect);
let g = applyAction(f.g, f.owner, nexusChoamRequest(f));
g = applyAction(nexusChoamReload(g), f.target, {
type: 'card',
mode: 'cancel',
card: f.karama,
});
assert.equal(g.nexusChoamLast?.stage, 'canceled');
assert.deepEqual(
g.players[0].hand.find((card) => card.id === f.cost.id),
f.cost,
);
assert.equal(
g.discard.some((card) => card.id === f.cost.id),
false,
);
assert.equal(g.discard.filter((card) => card.id === f.karama).length, 1);
assert.equal(g.nexusCards!.cards!.hands[f.owner], null);
if (effect === 'kulon') assert.equal(g.choamMovement, undefined);
if (effect === 'laLaLa') {
assert.equal(g.players[1].reserves, 19);
assert.equal(g.players[1].tanks, 1);
assert.equal(g.players[1].spice, 16);
}
if (effect === 'gamont') {
assert.equal(g.players[1].forces['arrakeen:10'], 2);
assert.equal(g.decision?.kind, 'choamMentat');
}
if (effect === 'baliset') {
assert.equal(g.players[1].forces['red_chasm:7'], 1);
assert.equal(g.players[1].moved, 1);
}
if (effect === 'jubba') {
assert.equal(g.decision?.kind, 'choamStorm');
g = applyAction(g, f.owner, { type: 'decision', decline: true });
assert.equal(g.players[0].tanks, 4);
assert.equal(g.players[1].tanks, 3);
}
reject(g, f.owner, nexusChoamRequest(f));
nexusChoamInventory(g);
});
}
void test('CHOAM Nexus rejects stale events, wrong owner/effect, unsupported Kull and malformed costs immutably', () => {
const f = nexusChoamFixture('kulon'),
action = nexusChoamRequest(f);
for (const bad of [
{ ...action, nexus: 'stale' },
{ ...action, effect: 'kull' },
{ ...action, card: 'unknown' },
{ ...action, effect: 'gamont' },
])
reject(f.g, f.owner, bad);
reject(f.g, f.target, action);
const unsupported = viewGame(f.g, f.owner).choamWorthless!.plays.find(
(play) => play.effect === 'kull',
);
assert.ok(unsupported?.blocked);
});
void test('CHOAM Nexus hidden physical cost kinds have identical opponent views until actual discard', () => {
const f = nexusChoamFixture('kulon'),
other = structuredClone(f.g);
const index = other.deck.findIndex((card) => card.kind === 'poison');
assert.ok(index >= 0);
const cost = other.deck[index];
other.deck[index] = other.players[0].hand[0];
other.players[0].hand[0] = cost;
const alternate = { ...f, g: other, cost };
for (const id of [f.target, f.observer])
assert.deepEqual(viewGame(f.g, id), viewGame(other, id));
const a = applyAction(f.g, f.owner, nexusChoamRequest(f)),
b = applyAction(other, f.owner, nexusChoamRequest(alternate));
for (const id of [f.target, f.observer])
assert.deepEqual(viewGame(a, id), viewGame(b, id));
for (const g of [a, b])
for (const id of [f.target, f.observer]) {
assert.equal(Object.hasOwn(viewGame(g, id), 'nexusChoamHistory'), false);
assert.equal(viewGame(g, id).choamWorthless, null);
}
});
void test('CHOAM Nexus can discard a real Ghola as payment without activating its printed revival effect in Basic or Advanced', () => {
for (const advanced of [false, true]) {
const f = nexusChoamFixture('kulon', { advanced, costKind: 'ghola' });
const before = f.g.players.map((p) => ({
reserves: p.reserves,
tanks: p.tanks,
revived: p.revived,
}));
const g = nexusChoamAllow(applyAction(f.g, f.owner, nexusChoamRequest(f)));
assert.deepEqual(
g.players.map((p) => ({
reserves: p.reserves,
tanks: p.tanks,
revived: p.revived,
})),
before,
);
assert.equal(g.discard.find((c) => c.id === f.cost.id)?.effect, 'ghola');
assert.equal(g.choamMovement?.bonus, 1);
nexusChoamInventory(g);
}
});
void test('CHOAM Nexus Baliset survives a real paid Nullentropy search and restores its original movement response before allow or cancellation', () => {
for (const cancel of [false, true]) {
const f = nexusChoamFixture('baliset', { opponentFaction: 'richese' });
let g = f.g;
const index = g.richeseCache!.findIndex(
(card) => card.effect === 'nullentropyBox',
);
assert.ok(index >= 0);
const box = g.richeseCache!.splice(index, 1)[0];
g.players[1].hand.push(box);
for (const kind of ['shield', 'projectile']) {
const i = g.deck.findIndex((card) => card.kind === kind);
assert.ok(i >= 0);
g.discard.push(g.deck.splice(i, 1)[0]);
}
g = applyAction(g, f.owner, nexusChoamRequest(f));
const response = structuredClone(g.response),
move = structuredClone(g.pendingChoamMove);
g = applyAction(g, f.target, { type: 'card', card: box.id });
assert.equal(g.decision?.kind, 'nullentropy');
assert.deepEqual(g.pendingNullentropy!.resume.response, response);
assert.deepEqual(g.pendingChoamMove, move);
assert.equal(g.players[1].spice, 18);
nexusChoamInventory(g);
for (const p of g.players)
assert.deepEqual(viewGame(nexusChoamReload(g), p.id), viewGame(g, p.id));
const selected = g.discard[0].id;
g = applyAction(nexusChoamReload(g), f.target, {
type: 'decision',
event: g.pendingNullentropy!.event,
card: selected,
});
assert.deepEqual(g.response, response);
g = cancel
? applyAction(g, f.target, {
type: 'card',
mode: 'cancel',
card: f.karama,
})
: nexusChoamAllow(g);
assert.equal(g.nexusChoamLast?.stage, cancel ? 'canceled' : 'complete');
assert.equal(g.players[1].forces['red_chasm:7'], cancel ? 1 : 3);
assert.equal(g.players[1].spice, 18);
assert.equal(g.discard.filter((card) => card.id === box.id).length, 1);
assert.ok(g.players[1].hand.some((card) => card.id === selected));
nexusChoamInventory(g);
}
});
void test('CHOAM Nexus legitimately fizzles after native Karama cash-in spends its payment, resuming movement or storm once', () => {
for (const effect of ['baliset', 'jubba'] as const) {
const f = nexusChoamFixture(effect);
const karama = holdNexusChoamCard(f.g, f.owner, 'karama');
let g = applyAction(f.g, f.owner, nexusChoamRequest(f));
g = applyAction(g, f.owner, {
type: 'card',
mode: 'special',
card: karama.id,
cards: [f.cost.id],
});
g = nexusChoamAllow(nexusChoamReload(g));
assert.equal(g.nexusChoamLast?.stage, 'fizzled');
assert.equal(
g.nexusCards!.cards!.discard.filter((card) => card === 'choam').length,
1,
);
assert.equal(g.discard.filter((card) => card.id === f.cost.id).length, 1);
assert.equal(g.discard.filter((card) => card.id === karama.id).length, 1);
assert.equal(g.players[0].spice, 23);
if (effect === 'baliset') {
assert.equal(g.players[1].forces['red_chasm:7'], 1);
assert.equal(g.players[1].moved, 1);
} else {
assert.equal(g.decision?.kind, 'choamStorm');
g = applyAction(g, f.owner, { type: 'decision', decline: true });
assert.equal(g.players[0].tanks, 4);
assert.equal(g.players[1].tanks, 3);
}
reject(g, f.owner, nexusChoamRequest(f));
nexusChoamInventory(g);
}
});
+229
View File
@@ -0,0 +1,229 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { unitStore } from './fixture-nexus-room-store';
import {
NEXUS_CHOAM_EFFECTS,
nexusChoamFixture,
nexusChoamRequest,
nexusChoamInventory,
type NexusChoamEffect,
} from './fixture-nexus-choam';
import { applyAction, viewGame, type Game, type Action } from '../game/engine';
import type { RoomsClock } from '../db/rooms';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
async function fixture(effect: NexusChoamEffect) {
const store = unitStore(),
made = await store.rooms.createRoom('CHOAM SQL', 'harkonnen', false, []),
code = made.view.code,
tokens = [made.token];
for (const faction of ['guild', 'fremen'] as const)
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
const auths = await Promise.all(
tokens.map((token) => store.rooms.authenticate(code, token)),
);
const ids = auths.map((auth) => auth.playerId) as [string, string, string];
const f = nexusChoamFixture(effect, { seatIds: ids });
f.g.code = code;
f.g.version = (await store.rooms.readRoom(code)).version;
store.sqlite
.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(f.g), f.g.version, code);
store.writes.length = 0;
return { ...store, ...f, code, tokens, auths, ids };
}
type Fixture = Awaited<ReturnType<typeof fixture>>;
const row = (f: Fixture) =>
f.sqlite
.prepare('SELECT state,version FROM rooms WHERE code = ?')
.get(f.code);
async function restored(f: Fixture, g: Game) {
const before = row(f),
rooms = f.restart();
for (const [i, token] of f.tokens.entries()) {
const auth = await rooms.authenticate(f.code, token),
v = await rooms.readSeatView(f.code, auth);
assert.deepEqual(v, viewGame(g, f.ids[i]));
assert.equal(Object.hasOwn(v, 'nexusChoamHistory'), false);
if (i !== 0) assert.equal(v.choamWorthless, null);
}
await rooms.continueRoomAutomatic(f.code, clock);
assert.deepEqual(row(f), before);
nexusChoamInventory(g);
}
async function act(f: Fixture, seat: number, action: Action) {
const g = await f.restart().readRoom(f.code);
await f.restart().act(f.code, f.auths[seat], g.version, action, clock);
return f.restart().readRoom(f.code);
}
void test('SQLite all five CHOAM Nexus effects recover private pending payment and settle each allowed/canceled parent once', async () => {
for (const effect of NEXUS_CHOAM_EFFECTS)
for (const cancel of [false, true]) {
const f = await fixture(effect);
try {
// Drop the successful response, then recover only through a new production module.
await f.rooms.act(
f.code,
f.auths[0],
f.g.version,
nexusChoamRequest(f),
clock,
);
const pending = await f.restart().readRoom(f.code);
assert.equal(pending.nexusChoamLast?.stage, 'pending');
assert.deepEqual(
pending.players[0].hand.find((c) => c.id === f.cost.id),
f.cost,
);
await restored(f, pending);
const decision: Action = cancel
? { type: 'card', mode: 'cancel', card: f.karama }
: { type: 'passResponse' };
const expected = applyAction(pending, f.target, decision);
const done = await act(f, 1, decision);
expected.version = done.version;
assert.deepEqual(done, JSON.parse(JSON.stringify(expected)));
assert.equal(
done.nexusChoamLast?.stage,
cancel ? 'canceled' : 'complete',
);
assert.equal(
done.discard.filter((c) => c.id === f.cost.id).length,
cancel ? 0 : 1,
);
assert.equal(
done.nexusCards!.cards!.discard.filter((card) => card === 'choam')
.length,
1,
);
await restored(f, done);
const before = row(f);
await assert.rejects(
f.rooms.act(
f.code,
f.auths[0],
done.version,
nexusChoamRequest(f),
clock,
),
);
assert.deepEqual(row(f), before);
} finally {
f.sqlite.close();
}
}
});
void test('SQLite simultaneous last-holder pass and printed Karama settle one CHOAM Nexus outcome and preserve the other request as stale', async () => {
for (const effect of NEXUS_CHOAM_EFFECTS) {
const f = await fixture(effect);
try {
const pending = await act(f, 0, nexusChoamRequest(f));
f.writes.length = 0;
let arrivals = 0,
release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
f.hooks.beforeWrite = async () => {
if (++arrivals === 2) release();
await gate;
};
const timer = setTimeout(release, 2000);
const results = await Promise.allSettled(
[
{ type: 'passResponse' },
{ type: 'card', mode: 'cancel', card: f.karama },
].map((action) =>
f
.restart()
.act(f.code, f.auths[1], pending.version, action as Action, clock),
),
);
clearTimeout(timer);
delete f.hooks.beforeWrite;
assert.equal(arrivals, 2);
assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
assert.deepEqual(
f.writes.map((w) => w.changes).sort((a, b) => a - b),
[0, 1],
);
const done = await f.restart().readRoom(f.code);
assert.equal(done.version, pending.version + 1);
assert.equal(done.nexusChoamHistory!.length, 1);
const canceled = done.nexusChoamLast!.stage === 'canceled';
assert.equal(
done.discard.filter((c) => c.id === f.cost.id).length,
canceled ? 0 : 1,
);
assert.equal(
done.discard.filter((c) => c.id === f.karama).length,
canceled ? 1 : 0,
);
await restored(f, done);
} finally {
f.sqlite.close();
}
}
});
void test('SQLite damaged CHOAM Nexus payment, virtual effect, parent, turn or completion proof is rejected without a write', async () => {
const f = await fixture('baliset');
try {
const pending = await act(f, 0, nexusChoamRequest(f));
const corruptions: ((g: Game) => void)[] = [
(g) => {
g.pendingChoamWorthless!.card = 'unknown';
},
(g) => {
g.pendingChoamWorthless!.effect = 'kulon';
},
(g) => {
g.pendingChoamWorthless!.nexusEvent = 'stale';
},
(g) => {
g.pendingChoamMove!.total = 1;
g.pendingChoamMove!.group[0][1] = 1;
},
(g) => {
g.turn++;
},
(g) => {
g.nexusChoamHistory![0].stage = 'complete';
},
(g) => {
delete g.nexusChoamLast;
},
(g) => {
delete g.nexusChoamHistory;
},
(g) => {
g.response = null;
},
];
for (const [index, corrupt] of corruptions.entries()) {
const bad = structuredClone(pending);
corrupt(bad);
f.sqlite
.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(bad), bad.version, f.code);
const before = row(f);
f.writes.length = 0;
for (const action of [
{ type: 'passResponse' },
{ type: 'card', mode: 'cancel', card: f.karama },
] as Action[]) {
await assert.rejects(
f.restart().act(f.code, f.auths[1], bad.version, action, clock),
`corruption ${index}`,
);
assert.deepEqual(row(f), before);
assert.equal(f.writes.length, 0);
}
for (const auth of f.auths)
await assert.rejects(
f.restart().readSeatView(f.code, auth),
`read corruption ${index}`,
);
}
} finally {
f.sqlite.close();
}
});
+218
View File
@@ -0,0 +1,218 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
CHOAM_NEXUS_EFFECTS,
createNexusChoam,
validateNexusChoam,
type NexusChoamContext,
type NexusChoamEffect,
type NexusChoamReceipt,
} from '../game/nexus-choam';
const context: NexusChoamContext = {
turn: 3,
players: [
{ id: 'c', faction: 'choam' },
{ id: 'a', faction: 'atreides' },
{ id: 'h', faction: 'harkonnen' },
],
};
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value));
void test('CHOAM Nexus records all six printed effects with their distinct phase windows', () => {
assert.deepEqual(
Object.values(CHOAM_NEXUS_EFFECTS).sort(),
[
'Baliset',
'Jubba Cloak',
'Kull Wahad',
'Kulon',
'La La La',
'Trip to Gamont',
].sort(),
);
for (const [effect, phase] of [
['kulon', 5],
['laLaLa', 4],
['gamont', 8],
['baliset', 5],
['jubba', 0],
] as const) {
const receipt = createNexusChoam(
context,
'c',
phase,
'physical-poison-1',
effect,
);
validateNexusChoam(context, clone(receipt));
for (let wrong = 0; wrong <= 8; wrong++)
if (wrong !== phase)
assert.throws(
() =>
createNexusChoam(context, 'c', wrong, 'physical-poison-1', effect),
/timing/,
);
}
for (let phase = 0; phase <= 8; phase++)
validateNexusChoam(
context,
createNexusChoam(context, 'c', phase, 'physical-shield-1', 'kull'),
);
});
void test('CHOAM historical receipt survives later turns and roster ordering without inspecting private state', () => {
const receipt = createNexusChoam(
context,
'c',
5,
'physical-poison-1',
'kulon',
);
const before = clone(receipt);
const later = {
turn: 4,
players: [...context.players].reverse().map((p) =>
Object.defineProperties(
{ ...p },
{
hand: {
get() {
throw new Error('private hand read');
},
},
ally: {
get() {
throw new Error('historical alliance read');
},
},
spice: {
get() {
throw new Error('private balance read');
},
},
},
),
),
};
validateNexusChoam(later, clone(receipt));
assert.deepEqual(receipt, before);
const second = createNexusChoam(
context,
'c',
5,
'physical-shield-1',
'kulon',
);
assert.notEqual(receipt.event, second.event);
assert.notEqual(
receipt.event,
createNexusChoam(context, 'c', 5, receipt.card, 'baliset').event,
);
});
void test('CHOAM receipt rejects changed target, effect, turn, roster or identity without changing evidence', () => {
const receipt = createNexusChoam(
context,
'c',
5,
'physical-poison-1',
'kulon',
);
const edits: ((r: NexusChoamReceipt) => void)[] = [
(r) => {
r.card = 'other';
},
(r) => {
r.effect = 'baliset';
},
(r) => {
r.turn++;
},
(r) => {
r.owner = 'a';
},
(r) => {
r.event = 'other';
},
(r) => {
r.phase = 8;
},
(r) => {
r.roster.pop();
},
(r) => {
r.roster[0].faction = 'guild';
},
(r) => {
r.signature = 'changed';
},
(r) => {
delete (r as Partial<NexusChoamReceipt>).card;
},
(r) => {
Object.assign(r, { stage: 'complete' });
},
];
for (const edit of edits) {
const bad = clone(receipt);
edit(bad);
const before = clone(bad);
assert.throws(() => validateNexusChoam(context, bad), /CHOAM Nexus/);
assert.deepEqual(bad, before);
}
assert.throws(
() => validateNexusChoam({ ...context, turn: 2 }, receipt),
/CHOAM Nexus/,
);
assert.throws(
() =>
validateNexusChoam(
{
...context,
players: context.players.map((p) =>
p.id === 'c' ? { ...p, faction: 'guild' } : p,
),
},
receipt,
),
/CHOAM Nexus/,
);
});
void test('CHOAM receipt creation rejects malformed or foreign inputs before producing a grant', () => {
const before = clone(context);
for (const owner of ['a', 'missing', '__proto__'])
assert.throws(
() => createNexusChoam(context, owner, 5, 'card', 'kulon'),
/CHOAM Nexus/,
);
for (const phase of [-1, 9, 1.5, NaN])
assert.throws(
() => createNexusChoam(context, 'c', phase, 'card', 'kull'),
/CHOAM Nexus/,
);
for (const card of ['', ' ', '__proto__'])
assert.throws(
() => createNexusChoam(context, 'c', 5, card, 'kulon'),
/CHOAM Nexus/,
);
for (const effect of ['unknown', 'constructor', '__proto__'])
assert.throws(
() =>
createNexusChoam(context, 'c', 5, 'card', effect as NexusChoamEffect),
/CHOAM Nexus/,
);
assert.throws(
() =>
createNexusChoam(
{ ...context, players: [...context.players, context.players[0]] },
'c',
5,
'card',
'kulon',
),
/roster/,
);
assert.deepEqual(context, before);
});