Connect CHOAM Nexus Betrayal random discard

This commit is contained in:
Quintine committed 2026-09-27 21:54:55 +10:00
1 parent 90c5c7af25
commit 0bf61c8067
12 files changed
+709

No files matched your search

+2
View File
@@ -29,6 +29,7 @@ import { DiscoveryPanel, DiscoveryDiscardDecision } from './discoveries';
import { DiscoveryEntryDecision } from './discovery-entry';
import { DiscoveryStormDecision } from './discovery-storm';
import { NexusChoamTrade } from './nexus-choam-trade';
import { NexusChoamBetrayal } from './nexus-choam-betrayal';
import { NexusMoritaniBetrayal } from './nexus-moritani-betrayal';
import { buildRevision, buildRevisionLabel } from '@/lib/build-revision';
import { NexusEcazBetrayal } from './nexus-ecaz-betrayal';
@@ -1587,6 +1588,7 @@ export function GameTable({
<RihaniHistory game={g} />
<MentatHistory game={g} />
<NexusChoamTrade game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
<NexusChoamBetrayal game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
<NexusMoritaniBetrayal game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
<NexusEcazBetrayal game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
<NexusTleilaxu game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
+41
View File
@@ -0,0 +1,41 @@
'use client';
import type { Action, GameView } from '@/game/engine';
import { nexusChoamBetrayalAction } from '@/game/nexus-choam-betrayal-options';
import { Button } from '@/components/ui/button';
export function NexusChoamBetrayal({
game,
act,
busy,
}: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
}) {
const offer = game.nexusChoamBetrayal;
if (!offer || game.nexusCards?.card !== 'choam') return null;
const action = nexusChoamBetrayalAction(game);
const disabled = busy || !action;
return (
<section className="min-w-0 space-y-3" aria-label="CHOAM Nexus Betrayal">
<h3 className="font-serif text-xl">CHOAM Betrayal</h3>
<p className="text-base leading-7">
Spend your CHOAM Nexus card to make {offer.target.name} discard one
uniformly random held Treachery card.{' '}
{offer.target.handSize !== null &&
`CHOAM holds ${offer.target.handSize} ${offer.target.handSize === 1 ? 'card' : 'cards'} during Bidding. `}
You do not choose or see the card before the ordinary discard reveal.
CHOAM receives no spice.
</p>
{offer.blocked && <p className="notice">{offer.blocked}</p>}
<Button
className="game-action min-h-11 whitespace-normal"
disabled={disabled}
onClick={() => { if (!disabled && action) act(action); }}
>
Spend CHOAM Nexus for random discard
</Button>
</section>
);
}
+4
View File
@@ -1,5 +1,9 @@
# Dune implementation status
## 27 September 2026 — Bounded CHOAM Nexus Betrayal
The [CHOAM Betrayal panel](NEXUS_CARD_RULES.md#bounded-choam-betrayal-runtime) now has a playable Basic/Advanced path when CHOAM is seated. An unallied rival spends the physical CHOAM Nexus card at a clean boundary; the server samples and discards one actual held CHOAM Treachery Card without spice compensation. The holder-only control never offers a rival card choice or identities; its hand count appears only during Bidding. Bots act only when that count is public. A saved discard receipt completes once after JSON restore, including later same-turn Nexus recycling. Focused quote, controls, bot, physical inventory, rejection and interruption cases pass; the isolated browser table rendered the enabled holder control without room writes. Complete Nexus, combined-module and deployed revision acceptance remain open.
## 26 September 2026 — Bounded Ecaz Nexus Betrayal and build revision
The [Ecaz source contract](NEXUS_ECAZ_RULES.md#bounded-betrayal-runtime)
+6
View File
@@ -53,6 +53,12 @@ Every row below is a semantic transcription of the photographed face, not a repr
Source for the entire table: [the twelve original printed components](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani). All card identities appear once; the photograph is arranged Moritani/Tleilaxu/Richese/Ixians, Atreides/Bene Gesserit/CHOAM/Emperor, Guild/Ecaz/Harkonnen/Fremen.
## Bounded CHOAM Betrayal runtime
The unallied holder of the CHOAM Nexus card may use its Betrayal panel when CHOAM is seated and holds at least one Treachery Card. The printed panel names no phase, so the current prototype offers it at a clean playing boundary, not during another decision, response or automatic continuation. The server samples one card uniformly from CHOAM's actual hand; neither the holder nor the bot chooses its identity. The physical Nexus card and sampled Treachery Card are discarded once. CHOAM receives no spice, and this panel does not create an additional Karama reaction. The ordinary fresh-discard continuation handles any applicable discard consequence.
The private holder offer contains CHOAM's name; it includes the hand count only during Bidding, when the base rules make that count public. Outside Bidding neither the count nor empty-hand availability is passively exposed. An attempted use against an empty hand rejects without consuming the Nexus card; bots act only with the public Bidding count to avoid blind invalid actions. Other seats do not receive the offer, and the public action log does not name the sampled card. An integrity receipt records the original card ID, owner, target, turn, phase and pending discard stage; its deterministic signature is not a cryptographic authentication of a saved game. JSON restore finishes the already-committed discard once without drawing new randomness or repaying the cost. A later same-turn Nexus recycle may move the spent card from discard without invalidating the completed use. Basic and Advanced use the same bounded path. This is not certification of CHOAM Cunning, its second Secret Ally effect, any other Nexus panel or combined-module play.
## Effect integration boundaries
Later focused source contracts cover [Emperor strength, purchases and revival](NEXUS_EMPEROR_RULES.md), [Ixian purchased-card replacement](NEXUS_IXIAN_REPLACEMENT_RULES.md), and [Fremen worm and revival effects](NEXUS_FREMEN_RULES.md). These audits distinguish implemented work from remaining source/timing questions; the current runtime evidence is tracked in [implementation status](IMPLEMENTATION_STATUS.md).
+3
View File
@@ -11,6 +11,7 @@ import { discoveryEntryBotActions } from './discovery-entry-options';
import { discoveryStormActions } from './discovery-storm-options';
import { greatMakerBotActions } from './great-maker-options';
import { nexusChoamTradeBotActions } from './nexus-choam-trade-options';
import { nexusChoamBetrayalBotActions } from './nexus-choam-betrayal-options';
import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildHajrAction, nexusGuildMovementAvailable, nexusGuildShipmentAvailable, nexusGuildSkipShipmentAction } from './nexus-guild-cunning-options';
import { nexusMoritaniBotActions } from './nexus-moritani-options';
import { moritaniBetrayalBotActions } from './nexus-moritani-betrayal-options';
@@ -3848,6 +3849,8 @@ export function botActions(g: GameView): Action[] {
if (discovery.length) return discovery;
const trade = nexusChoamTradeBotActions(g);
if (trade.length) return trade;
const choamBetrayal = nexusChoamBetrayalBotActions(g);
if (choamBetrayal.length) return choamBetrayal;
const moritaniBetrayal = moritaniBetrayalBotActions(g);
if (moritaniBetrayal.length) return moritaniBetrayal;
const ecazBetrayal = ecazBetrayalBotActions(g);
+87
View File
@@ -32,6 +32,7 @@ import { createDiscoveryState, validateDiscoveryState, placeDiscovery, rememberD
import { quoteJacurutuBattleIncome } from './discovery-battle';
import { greatMakerSignature, greatMakerMajority, validateGreatMaker, type GreatMaker } from './great-maker';
import { quoteNexusChoamTrade, validateNexusChoamTrade, nexusChoamTradeSignature, type NexusChoamTrade } from './nexus-choam-trade';
import { quoteNexusChoamBetrayal, validateNexusChoamBetrayal, nexusChoamBetrayalSignature, type NexusChoamBetrayal } from './nexus-choam-betrayal';
import { EMPEROR_NEXUS_REVIVALS, emperorNexusEvent, emperorNexusSignature, emperorNexusModeSupported, emperorNexusPools, emperorNexusRevivalElites, validateEmperorNexusRevival, type NexusEmperorRevival } from './nexus-emperor-secret-ally';
import { FREMEN_NEXUS_FREE_FORCES, fremenNexusRevivalOffer, fremenNexusRevivalPools, fremenNexusRevivalSignature, validateFremenNexusRevival, type FremenNexusRevival } from './nexus-fremen-revival';
import { captureFremenCunningOccurrence, quoteFremenCunning, quoteFremenCunningRide, validateFremenCunningSelection, type FremenCunningOccurrence, type FremenCunningRideAuthorization, type FremenCunningSelection } from './nexus-fremen-cunning';
@@ -1028,6 +1029,8 @@ export type Game = {
greatMaker?: GreatMaker;
nexusChoamTrades?: NexusChoamTrade[];
nexusChoamTradeLast?: { event: string; stage: NexusChoamTrade['stage'] };
nexusChoamBetrayals?: NexusChoamBetrayal[];
nexusChoamBetrayalLast?: { event: string; stage: NexusChoamBetrayal['stage'] };
nexusEmperorSecretHistory?: NexusEmperorRevival[];
nexusEmperorSecretEvents?: string[];
nexusFremenRevivalHistory?: FremenNexusRevival[];
@@ -1042,6 +1045,7 @@ export type Game = {
| { kind: 'ambassador'; entry: NonNullable<Game['pendingAmbassador']> }
| { kind: 'discoveryStash'; event: string; owner: string; card: string }
| { kind: 'nexusChoamTrade'; event: string; owner: string; card: string; spiceAfter: number }
| { kind: 'nexusChoamBetrayal'; event: string; owner: string; target: string; card: string; handBefore: number }
| { kind: 'kaitainDiscard'; owner: string; event: string; cost: number; spiceAfter: number }
| { kind: 'winnerMandatoryDiscard'; event: string; player: string; territory: string; optional: string[]; commitment: NonNullable<Game['pendingWinnerDiscards']> }
| {
@@ -1756,6 +1760,42 @@ function nexusChoamTradeIntegrity(g: Game) {
pending.spiceAfter === last.spiceBefore + 2 && getPlayer(g, last.owner).spice === pending.spiceAfter,
'The CHOAM Nexus trade no longer matches its committed payment.');
}
function nexusChoamBetrayalIntegrity(g: Game) {
const history = g.nexusChoamBetrayals;
const continuation = g.pendingTreacheryDiscard?.continuation;
const pending = continuation?.kind === 'nexusChoamBetrayal' ? continuation : null;
if (history === undefined) {
requireRule(!g.nexusChoamBetrayalLast && !pending,
'The CHOAM Nexus Betrayal has lost its spent card history.');
return;
}
requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0,
'CHOAM Nexus Betrayal requires its physical card and saved history.');
const events = new Set<string>();
const physical = physicalTreacheryCards(g);
for (const [index, record] of history.entries()) {
nexusRule(() => validateNexusChoamBetrayal(g, record, physical));
requireRule(!events.has(record.event) &&
(record.stage === 'complete' || index === history.length - 1),
'The CHOAM Nexus Betrayal has a duplicated or unfinished prior discard.');
events.add(record.event);
}
const last = history.at(-1)!;
requireRule(JSON.stringify(g.nexusChoamBetrayalLast) ===
JSON.stringify({ event: last.event, stage: last.stage }) &&
(last.stage === 'discard') === !!pending,
'The CHOAM Nexus Betrayal has lost or reopened its committed discard.');
if (pending)
requireRule(g.nexusCards.cards.discard.includes('choam'),
'The pending CHOAM Nexus Betrayal has lost its spent card.');
if (pending)
requireRule(g.status === 'playing' && last.turn === g.turn &&
pending.event === last.event && pending.owner === last.owner &&
pending.target === last.target && pending.card === last.card &&
pending.handBefore === last.handBefore &&
getPlayer(g, last.target).hand.length === last.handBefore - 1,
'The CHOAM Nexus Betrayal no longer matches its random discard.');
}
function currentNexusChoamTrade(g: Game, owner: string) {
return nexusRule(() => quoteNexusChoamTrade(g, owner,
g.phase === 7 && grummanCollectionAutomatic(g)));
@@ -2099,6 +2139,32 @@ function playNexusChoamTrade(g: Game, p: Player, action: Action) {
log(g, `${p.name} spent CHOAM Nexus Secret Ally and discarded ${card.name} during Spice Collection to receive 2 spice from the bank. Both cards are spent.`,
{faction: p.faction, name: 'CHOAM Nexus trade'});
}
function playNexusChoamBetrayal(g: Game, p: Player, action: Action) {
const offer = quoteNexusChoamBetrayal(g, p.id,
g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g));
requireRule(Object.keys(action).sort().join(',') === 'event,type' &&
offer && !offer.blocked && action.event === offer.event,
offer?.blocked ?? 'Choose the current CHOAM Nexus Betrayal.');
const target = getPlayer(g, offer.target.id);
requireRule(target.hand.length > 0, 'CHOAM Betrayal has no legal card to discard.');
const card = target.hand[Math.floor(random() * target.hand.length)];
const record: NexusChoamBetrayal = {
event: offer.event, turn: g.turn, phase: g.phase, owner: p.id,
target: target.id, card: card.id, handBefore: target.hand.length,
stage: 'discard', signature: '',
};
record.signature = nexusChoamBetrayalSignature(record);
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players));
const discarded = discard(g, target, card.id);
(g.nexusChoamBetrayals ??= []).push(record);
g.nexusChoamBetrayalLast = { event: record.event, stage: record.stage };
stageTreacheryDiscard(g, 'nexus:choamBetrayal',
[{ card: discarded, discardedBy: target.id, publicFace: false }],
{ kind: 'nexusChoamBetrayal', event: record.event, owner: p.id,
target: target.id, card: card.id, handBefore: record.handBefore });
log(g, `${p.name} spent CHOAM Nexus Betrayal. ${target.name} discarded one randomly selected Treachery card without receiving spice.`,
{ faction: p.faction, name: 'CHOAM Nexus Betrayal' });
}
function playMoritaniBetrayal(g: Game, p: Player, action: Action) {
const offer = moritaniBetrayalOffer(g, p.id,
g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g));
@@ -4962,6 +5028,13 @@ function treacheryDiscardIntegrity(g: Game) {
batch.entries[0].discardedBy === c.owner && batch.entries[0].publicFace &&
batch.entries[0].card.id === c.card && batch.entries[0].card.kind === 'worthless',
'The CHOAM Nexus trade has changed its discarded Worthless card.');
} else if (continuation?.kind === 'nexusChoamBetrayal') {
const c = continuation;
nexusChoamBetrayalIntegrity(g);
requireRule(batch.cause === 'nexus:choamBetrayal' && batch.entries.length === 1 &&
batch.entries[0].discardedBy === c.target && !batch.entries[0].publicFace &&
batch.entries[0].card.id === c.card && g.nexusChoamBetrayals?.at(-1)?.event === c.event,
'The CHOAM Nexus Betrayal has changed its random physical discard.');
} else if (continuation?.kind === 'kaitainDiscard') {
const c = continuation;
requireRule(g.phase === 3 && g.biddingEnd?.event === c.event &&
@@ -5021,6 +5094,12 @@ function finishTreacheryDiscard(g: Game) {
g.nexusChoamTradeLast = { event: record.event, stage: record.stage };
return;
}
if (next.kind === 'nexusChoamBetrayal') {
const record = g.nexusChoamBetrayals!.at(-1)!;
record.stage = 'complete'; record.signature = nexusChoamBetrayalSignature(record);
g.nexusChoamBetrayalLast = { event: record.event, stage: record.stage };
return;
}
if (next.kind === 'kaitainDiscard') return;
if (next.kind === 'ambassador') {
const p = getPlayer(g, next.entry.beneficiary!);
@@ -21194,6 +21273,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
discoveryIntegrity(state);
greatMakerIntegrity(state);
nexusChoamTradeIntegrity(state);
nexusChoamBetrayalIntegrity(state);
marketGholaIntegrity(state);
homeworldRule(() => homeworldGameIntegrity(state));
homeworldBattleLossIntegrity(state);
@@ -21352,6 +21432,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
discoveryIntegrity(g);
greatMakerIntegrity(g);
nexusChoamTradeIntegrity(g);
nexusChoamBetrayalIntegrity(g);
marketGholaIntegrity(g);
homeworldRule(() => homeworldGameIntegrity(g));
homeworldBattleLossIntegrity(g);
@@ -21464,6 +21545,7 @@ export function normalizeAutomaticGame(state: Game): Game {
discoveryIntegrity(state);
greatMakerIntegrity(state);
nexusChoamTradeIntegrity(state);
nexusChoamBetrayalIntegrity(state);
marketGholaIntegrity(state);
homeworldRule(() => homeworldGameIntegrity(state));
homeworldBattleLossIntegrity(state);
@@ -21494,6 +21576,7 @@ export function normalizeAutomaticGame(state: Game): Game {
discoveryIntegrity(g);
greatMakerIntegrity(g);
nexusChoamTradeIntegrity(g);
nexusChoamBetrayalIntegrity(g);
marketGholaIntegrity(g);
homeworldRule(() => homeworldGameIntegrity(g));
homeworldBattleLossIntegrity(g);
@@ -21592,6 +21675,7 @@ function applyActionInner(
}
if (t === 'discovery') { playDiscovery(g, p, action); return g; }
if (t === 'nexusChoamTrade') { playNexusChoamTrade(g, p, action); return g; }
if (t === 'nexusChoamBetrayal') { playNexusChoamBetrayal(g, p, action); return g; }
if (t === 'nexusEmperorRevive') { playNexusEmperorRevive(g,p,action); return g; }
if (t === 'nexusEmperorBetrayal') { playNexusEmperorBetrayal(g,p,action); return g; }
if (t === 'nexusBgBetrayal') { playNexusBgBetrayal(g,p,action); return g; }
@@ -25201,6 +25285,7 @@ export function viewGame(state: Game, id: string) {
discoveryIntegrity(state);
greatMakerIntegrity(state);
nexusChoamTradeIntegrity(state);
nexusChoamBetrayalIntegrity(state);
marketGholaIntegrity(state);
homeworldRule(() => homeworldGameIntegrity(state));
homeworldBattleLossIntegrity(state);
@@ -25510,6 +25595,8 @@ export function viewGame(state: Game, id: string) {
greatMaker: g.greatMaker ? { event:g.greatMaker.event, turn:g.greatMaker.turn, stage:g.greatMaker.stage, votes:structuredClone(g.greatMaker.votes), order:[...g.greatMaker.order], ride:greatMakerRideOptions(g,id) } : null,
nexusCards: projectedNexusCards(g, id),
nexusChoamTrade: currentNexusChoamTrade(g, id),
nexusChoamBetrayal: quoteNexusChoamBetrayal(g, id,
g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)),
nexusEmperorSecretAlly: nexusEmperorSecretAllyOffer(g,id),
nexusEmperorBetrayal: nexusEmperorBetrayalOffer(g, id),
nexusBgBetrayal: nexusBgBetrayalOffer(g, id),
+26
View File
@@ -0,0 +1,26 @@
import type { Action, GameView } from './engine';
/** Bind the action to the private projected offer, never to a client-picked hand card. */
export function nexusChoamBetrayalAction(game: GameView): Action | null {
const offer = game.nexusChoamBetrayal;
const owner = game.players.find((player) => player.id === game.me);
if (
game.status !== 'playing' ||
game.automaticContinuationPending ||
!owner ||
owner.ally ||
game.nexusCards?.card !== 'choam' ||
!offer ||
!offer.event ||
offer.blocked ||
!game.players.some((player) => player.id === offer.target.id && player.faction === 'choam')
) return null;
return { type: 'nexusChoamBetrayal', event: offer.event };
}
/** Bots use only Bidding's public hand count, avoiding blind invalid attempts. */
export function nexusChoamBetrayalBotActions(game: GameView): Action[] {
if (game.phase !== 3) return [];
const action = nexusChoamBetrayalAction(game);
return action ? [action] : [];
}
+105
View File
@@ -0,0 +1,105 @@
import type { Card } from './cards';
import type { Game } from './engine';
import { nexusCardMode } from './nexus-cards';
export type NexusChoamBetrayalOffer = Readonly<{
event: string;
blocked: string | null;
target: Readonly<{ id: string; name: string; handSize: number | null }>;
}>;
/** The sampled card is a private, committed Treachery identity, not offer data. */
export type NexusChoamBetrayal = {
event: string;
turn: number;
phase: number;
owner: string;
target: string;
card: string;
handBefore: number;
stage: 'discard' | 'complete';
signature: string;
};
export function nexusChoamBetrayalEvent(
turn: number, phase: number, owner: string, target: string,
): string {
return JSON.stringify(['nexusChoamBetrayal', turn, phase, owner, target]);
}
export function nexusChoamBetrayalSignature(record: NexusChoamBetrayal): string {
return JSON.stringify([record.event, record.turn, record.phase, record.owner,
record.target, record.card, record.handBefore, record.stage]);
}
/** The hand count is public during Bidding only; identities are never offer data. */
export function quoteNexusChoamBetrayal(
g: Game, owner: string, automaticPending = false,
): NexusChoamBetrayalOffer | null {
const holder = g.players.find(player => player.id === owner);
const choam = g.players.find(player => player.faction === 'choam');
if (!holder || !choam || choam.id === owner ||
g.nexusCards?.cards?.hands[owner] !== 'choam' ||
nexusCardMode('choam', holder.faction, g.players.map(player => player.faction)) !== 'betrayal')
return null;
const handSize = g.phase === 3 ? choam.hand.length : null;
let blocked: string | null = null;
if (holder.ally) blocked = 'Use a Nexus card while unallied.';
else if (g.status !== 'playing') blocked = 'Use CHOAM Betrayal during play.';
else if (automaticPending || g.response || g.decision || g.truthtrance || g.phaseOpening ||
g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy ||
g.pendingExchange || g.pendingAmbassador || g.pendingRicheseGift ||
g.pendingRichesePurchaseIncome || g.pendingRevival || g.pendingCapture ||
g.pendingShipment || g.pendingHomeworldShipment || g.pendingIxMove ||
g.pendingFremenMove || g.pendingMobileMove || g.pendingTerrorEntry ||
g.pendingMoritaniPlacement || g.pendingEcazPlacement || g.pendingChoamMove ||
g.pendingChoamWorthless || g.pendingChoamBattleIncome ||
g.pendingChoamMarketGhola || g.pendingAuditor || g.pendingFaceDance ||
g.pendingTech || g.pendingIxTechnology || g.pendingIxAlly ||
g.pendingIxSubstitution || g.pendingIxRicheseTechnology ||
g.pendingWinnerDiscards || g.pendingSukRescue || g.ornithopter ||
g.summonedWorm || g.battle || g.auction || g.richeseAuction ||
g.choamMarket || g.nexusTraitorPending ||
g.nexusTraitorExchanges?.some(record => record.stage === 'return') ||
g.nexusCards?.phase?.stage === 'drawing')
blocked = 'Finish the current interaction before using CHOAM Betrayal.';
else if (handSize === 0) blocked = 'CHOAM has no Treachery cards to discard.';
return {
event: nexusChoamBetrayalEvent(g.turn, g.phase, owner, choam.id),
blocked,
target: { id: choam.id, name: choam.name, handSize },
};
}
/** Verify intrinsic history against the original seated Betrayal mode and one
* conserved physical card, without requiring its former hand to remain held. */
export function validateNexusChoamBetrayal(
g: Game, record: NexusChoamBetrayal, physicalCards: readonly Card[],
): void {
const keys = 'card,event,handBefore,owner,phase,signature,stage,target,turn';
const owner = record && g.players.find(player => player.id === record.owner);
const target = record && g.players.find(player => player.id === record.target);
if (!record || typeof record !== 'object' || Array.isArray(record) ||
Object.keys(record).sort().join(',') !== keys ||
!owner || !target || owner.id === target.id || target.faction !== 'choam' ||
owner.faction === 'choam' ||
nexusCardMode('choam', owner.faction, g.players.map(player => player.faction)) !== 'betrayal' ||
!Number.isSafeInteger(g.turn) || g.turn < 1 ||
!Number.isSafeInteger(g.phase) || g.phase < 0 || g.phase > 8 ||
!Number.isSafeInteger(record.turn) || record.turn < 1 || record.turn > g.turn ||
!Number.isSafeInteger(record.phase) || record.phase < 0 || record.phase > 8 ||
(record.turn === g.turn && record.phase > g.phase) ||
!Number.isSafeInteger(record.handBefore) || record.handBefore < 1 ||
typeof record.card !== 'string' || !record.card.trim() ||
(record.stage !== 'discard' && record.stage !== 'complete') ||
record.event !== nexusChoamBetrayalEvent(record.turn, record.phase,
record.owner, record.target) ||
record.signature !== nexusChoamBetrayalSignature(record) ||
!Array.isArray(physicalCards))
throw new Error('CHOAM Betrayal has lost its original roster, event or physical Treachery card.');
let matches = 0;
for (const card of physicalCards)
if (card?.id === record.card) matches++;
if (matches !== 1)
throw new Error('CHOAM Betrayal has lost its original physical Treachery card.');
}
+23
View File
@@ -3955,6 +3955,28 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Verification', status: 'Partial', detail: 'Focused rules, private controls, AI, saved continuation and competing SQLite actions. Complete family acceptance remains open.', evidence: ['tests/nexus-choam-trade.test.ts', 'tests/nexus-choam-trade-recovery.test.ts'] },
],
},
{
id: 'nexus-choam-betrayal',
title: 'CHOAM Nexus Betrayal: random discard',
category: 'Advanced & expansions',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'With CHOAM seated, an unallied rival may spend its CHOAM Nexus card to discard one random physical Treachery Card from CHOAM without paying spice.',
steps: [
'Hold the CHOAM Nexus card while CHOAM is seated and holds at least one Treachery Card. Finish other choices or automatic continuations before using Betrayal; the printed panel has no phase restriction.',
'The server chooses uniformly from CHOAM’s held cards, not from a client choice. Discard both the Nexus card and the selected Treachery Card; CHOAM receives no spice.',
'Only the holder sees the Betrayal control. CHOAM’s hand count is shown only during Bidding; no rival card identity is disclosed before the ordinary discard reveal. Outside Bidding an empty-hand attempt rejects without spending the card.',
'Basic and Advanced share this bounded effect. Other Nexus effects and complete expansion combinations retain their separate gates.',
],
related: ['nexus-cards', 'choam-modules'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Physical random discard, spent Nexus custody, clean action boundary and saved discard receipt are connected. Full CHOAM Nexus family and module acceptance remain open.', evidence: ['game/nexus-choam-betrayal.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Private holder control shows CHOAM’s name, Bidding-only public hand count, blocked reasons and spent-card cost without a non-Bidding count leak.', evidence: ['components/nexus-choam-betrayal.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four profiles use the legal server-quoted action during Bidding when CHOAM’s count is public. Other phases avoid blind hidden-hand attempts; the server samples the actual card.', evidence: ['game/nexus-choam-betrayal-options.ts', 'game/bots.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Printed outcome, bounded timing, custody, privacy and remaining gates are explicit.', evidence: ['docs/NEXUS_CARD_RULES.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused Basic/Advanced custody, rejected actions, private controls, legal bots and JSON discard continuation are covered; complete module acceptance is not.', evidence: ['tests/nexus-choam-betrayal-engine.test.ts', 'tests/nexus-choam-betrayal-quote.test.ts', 'tests/nexus-choam-betrayal-controls.test.tsx'] },
],
},
{
id: 'nexus-emperor-secret-ally',
title: 'Emperor Nexus Secret Ally: extra revivals',
@@ -4013,6 +4035,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'With Fremen absent, an unallied holder may spend Fremen Secret Ally during Revival to return exactly three eligible forces from Tanks to reserves at no spice cost. Unlike Emperor Secret Ally, these three consume the ordinary force and free-force allowances, including the Advanced one-elite cap. This first path requires base factions and Nexus alone, before any normal force returns, without combined revival effects. Partial returns, prior returns, higher free rates and separate sandworm protection remain unavailable.',
'Native Fremen Cunning has a bounded Nexus-only path: when a natural, accepted additional, or Advanced special-Karama summoned worm appears in a territory initially containing no forces, Fremen may spend their own Nexus card before the worm resolves. The special summon retains its original Spice Blow parent. After the applicable Nexus, choose some forces from one occupied desert territory and ride to a legal Arrakis destination without using normal movement. The choice appears even without the secret card, so declining does not disclose custody. Karama provisionally prevents only the additional remote ride; the original worm still resolves and the card remains spent. An interrupted existing Cunning control is restored, and a selected ride waits for the summoned Nexus without granting a second Cunning offer; Great Maker and combined optional modules await integration.',
'CHOAM Secret Ally has a working Collection trade: when CHOAM is absent, an unallied holder may discard one Worthless card and the Nexus card for two bank spice. Its after-victory inspection remains unfinished.',
'With CHOAM seated, an unallied holder may spend CHOAM Betrayal at a clean play boundary to discard one uniformly random held CHOAM Treachery Card without spice compensation. The target name is public, but its hand count is offered only during Bidding; the holder cannot pick or inspect the card before the ordinary discard reveal.',
'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.',
'Moritani Cunning modifies its normal Mentat placement: choose one available supply token and a territory on the printed Arrakis board, including a territory already containing Terror. The explicit Cunning toggle keeps ordinary placement and relocation unchanged. Homeworlds, the Hidden Mobile Stronghold, Cunning relocation and combined Grumman use are unavailable.',
@@ -0,0 +1,121 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import type { GameView } from '../game/engine';
import {
nexusChoamBetrayalAction,
nexusChoamBetrayalBotActions,
} from '../game/nexus-choam-betrayal-options';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
});
// Register the Next alias hook before loading the component under test.
const { NexusChoamBetrayal } = await import('../components/nexus-choam-betrayal');
function ownerView(overrides: Record<string, unknown> = {}): GameView {
return {
me: 'owner',
players: [
{ id: 'owner', name: 'House Rival', faction: 'atreides', ally: null, bot: 'Easy' },
{ id: 'choam', name: 'CHOAM', faction: 'choam', ally: null },
{ id: 'observer', name: 'Observer', faction: 'guild', ally: null },
],
status: 'playing',
phase: 3,
automaticContinuationPending: false,
nexusCards: { card: 'choam' },
nexusChoamBetrayal: {
event: '["nexusChoamBetrayal",1,3,"owner","choam"]',
blocked: null,
target: { id: 'choam', name: 'CHOAM', handSize: 3 },
},
...overrides,
} as unknown as GameView;
}
function markup(game: GameView, busy = false): string {
return renderToStaticMarkup(createElement(NexusChoamBetrayal, { game, act() {}, busy }));
}
void test('unallied CHOAM card owner offers one server-random discard action at every bot profile', () => {
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = ownerView();
view.players[0].bot = profile;
const action = { type: 'nexusChoamBetrayal', event: view.nexusChoamBetrayal!.event };
assert.deepEqual(nexusChoamBetrayalAction(view), action);
assert.deepEqual(nexusChoamBetrayalBotActions(view), [action]);
}
const projected = ownerView();
Object.defineProperty(projected.players[1], 'hand', {
get() { throw new Error('Rival hand identity read'); },
});
const html = markup(projected);
assert.match(html, /CHOAM Nexus Betrayal/);
assert.match(html, /CHOAM discard one/);
assert.match(html, /CHOAM holds 3 cards during Bidding/);
assert.match(html, /uniformly random/);
assert.match(html, /CHOAM receives no spice/);
assert.match(html, /Spend CHOAM Nexus for random discard/);
assert.doesNotMatch(html, /disabled=""/);
});
void test('blocked and busy private offers explain the restriction and disable the action', () => {
const blocked = ownerView({ nexusChoamBetrayal: {
event: 'offer', blocked: 'Finish the current response first.',
target: { id: 'choam', name: 'CHOAM', handSize: 1 },
} });
assert.equal(nexusChoamBetrayalAction(blocked), null);
assert.deepEqual(nexusChoamBetrayalBotActions(blocked), []);
assert.match(markup(blocked), /Finish the current response first/);
assert.match(markup(blocked), /CHOAM holds 1 card during Bidding/);
assert.match(markup(blocked), /disabled=""[^>]*>Spend CHOAM Nexus/);
assert.match(markup(ownerView(), true), /disabled=""[^>]*>Spend CHOAM Nexus/);
for (const changes of [
{ status: 'finished' },
{ automaticContinuationPending: true },
{ nexusCards: { card: null } },
{ players: [
{ id: 'owner', faction: 'atreides', ally: 'observer' },
{ id: 'choam', faction: 'choam', ally: null },
] },
]) {
const view = ownerView(changes);
assert.equal(nexusChoamBetrayalAction(view), null);
assert.deepEqual(nexusChoamBetrayalBotActions(view), []);
}
assert.match(markup(ownerView({ automaticContinuationPending: true })), /disabled=""[^>]*>Spend CHOAM Nexus/);
const concealed = ownerView({
phase: 2,
nexusChoamBetrayal: {
event: '["nexusChoamBetrayal",1,2,"owner","choam"]',
blocked: null,
target: { id: 'choam', name: 'CHOAM', handSize: null },
},
});
assert.ok(nexusChoamBetrayalAction(concealed));
assert.deepEqual(nexusChoamBetrayalBotActions(concealed), []);
assert.doesNotMatch(markup(concealed), /holds [0-9]+ cards/);
});
void test('nonowner projection exposes neither the offer nor the rival hand or target control', () => {
const observer = ownerView({
me: 'observer',
nexusCards: { card: null },
nexusChoamBetrayal: null,
});
Object.defineProperty(observer.players[1], 'hand', {
get() { throw new Error('Opponent hand read'); },
});
assert.equal(nexusChoamBetrayalAction(observer), null);
assert.deepEqual(nexusChoamBetrayalBotActions(observer), []);
assert.equal(markup(observer), '');
assert.equal(markup(ownerView({ nexusChoamBetrayal: null })), '');
assert.equal(markup(ownerView({ nexusCards: { card: null } })), '');
});
aliases.deregister();
+147
View File
@@ -0,0 +1,147 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { drawNexusCard } from '../game/nexus-cards';
import { nexusChoamBetrayalAction } from '../game/nexus-choam-betrayal-options';
import { nexusChoamBetrayalSignature } from '../game/nexus-choam-betrayal';
import { nexusTraitorFixture, nexusTraitorInventory } from './fixture-nexus-traitors';
function position(advanced = false, phase = 2) {
const g = nexusTraitorFixture({ ownerFaction: 'atreides', opponentFaction: 'choam', advanced, phase });
const owner = 'p', target = 'q', observer = 'r';
const cards = g.nexusCards!.cards!;
const index = cards.deck.indexOf('choam');
assert.ok(index >= 0);
cards.deck[index] = cards.hands[owner]!;
cards.hands[owner] = 'choam';
const victim = g.players.find(player => player.id === target)!;
victim.hand.push(...g.deck.splice(0, 3));
nexusTraitorInventory(g);
const action = nexusChoamBetrayalAction(viewGame(g, owner));
assert.ok(action);
return { g, owner, target, observer, action };
}
function reload(g: Game): Game {
return JSON.parse(JSON.stringify(g)) as Game;
}
function reject(g: Game, owner: string, action: { type: string; [key: string]: unknown }) {
const before = reload(g);
assert.throws(() => applyAction(g, owner, action));
assert.deepEqual(g, before);
}
void test('CHOAM Betrayal spends one Nexus card and randomly discards one actual held card without spice in Basic and Advanced', () => {
for (const advanced of [false, true]) {
const { g, owner, target, observer, action } = position(advanced);
const before = reload(g);
const beforeLog = g.log.length;
const done = applyAction(g, owner, action);
assert.deepEqual(g, before);
const victim = done.players.find(player => player.id === target)!;
const original = before.players.find(player => player.id === target)!;
const removed = original.hand.filter(card => !victim.hand.some(held => held.id === card.id));
assert.equal(removed.length, 1);
assert.deepEqual(victim.hand, original.hand.filter(card => card.id !== removed[0].id));
assert.equal(done.discard.filter(card => card.id === removed[0].id).length, 1);
assert.equal(victim.spice, original.spice);
assert.equal(done.nexusCards!.cards!.hands[owner], null);
assert.equal(done.nexusCards!.cards!.discard.filter(card => card === 'choam').length, 1);
assert.equal(done.nexusChoamBetrayals?.at(-1)?.stage, 'complete');
assert.equal(done.nexusChoamBetrayals?.at(-1)?.card, removed[0].id);
assert.equal(done.pendingTreacheryDiscard, null);
assert.equal(done.log.slice(beforeLog).some(row => row.text.includes(removed[0].name)), false);
assert.equal(viewGame(done, observer).nexusChoamBetrayal, null);
assert.equal(Object.hasOwn(viewGame(done, observer), 'nexusChoamBetrayals'), false);
nexusTraitorInventory(done);
reject(done, owner, action);
assert.deepEqual(normalizeAutomaticGame(reload(done)), done);
}
});
void test('CHOAM Betrayal hides non-Bidding hand count; all bot levels take a legal action during public-count Bidding', () => {
const { g, owner, target, observer } = position();
const offer = viewGame(g, owner).nexusChoamBetrayal!;
assert.equal(offer.blocked, null);
assert.equal(offer.target.id, target);
assert.equal(offer.target.handSize, null);
assert.equal(viewGame(g, target).nexusChoamBetrayal, null);
assert.equal(viewGame(g, observer).nexusChoamBetrayal, null);
for (const card of g.players.find(player => player.id === target)!.hand)
assert.equal(JSON.stringify(offer).includes(card.id), false);
const publicCount = position(false, 3);
const biddingOffer = viewGame(publicCount.g, owner).nexusChoamBetrayal!;
assert.equal(biddingOffer.target.handSize, publicCount.g.players.find(player => player.id === target)!.hand.length);
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = viewGame(publicCount.g, owner);
view.players.find(player => player.id === owner)!.bot = difficulty;
assert.deepEqual(botActions(view)[0], publicCount.action);
}
const botPlayed = applyAction(publicCount.g, owner, publicCount.action);
assert.equal(botPlayed.nexusChoamBetrayals?.at(-1)?.stage, 'complete');
nexusTraitorInventory(botPlayed);
});
void test('CHOAM Betrayal rejects forged, stale, foreign and interrupted plays without spending the card', () => {
const { g, owner, target, observer, action } = position();
reject(g, target, action);
reject(g, observer, action);
reject(g, owner, { ...action, event: 'stale' });
reject(g, owner, { ...action, card: 'forged' });
const pending = reload(g);
pending.decision = { kind: 'choamMarket', player: target };
assert.ok(viewGame(pending, owner).nexusChoamBetrayal?.blocked);
reject(pending, owner, action);
const empty = reload(g);
empty.deck.push(...empty.players.find(player => player.id === target)!.hand.splice(0));
assert.equal(viewGame(empty, owner).nexusChoamBetrayal?.blocked, null);
reject(empty, owner, action);
assert.equal(g.nexusCards!.cards!.hands[owner], 'choam');
});
void test('a committed random discard resumes exactly once after JSON restore and rejects damaged saved proof', () => {
const { g, owner, target, action } = position();
const done = applyAction(g, owner, action);
const paused = reload(done), record = paused.nexusChoamBetrayals!.at(-1)!;
record.stage = 'discard'; record.signature = nexusChoamBetrayalSignature(record);
paused.nexusChoamBetrayalLast = { event: record.event, stage: record.stage };
const sequence = paused.treacheryDiscardSequence!;
paused.resolvedTreacheryDiscardSequence = sequence - 1;
paused.pendingTreacheryDiscard = {
sequence,
batch: {
event: `discard:${paused.turn}:${paused.phase}:${sequence}`,
turn: paused.turn,
phase: paused.phase,
cause: 'nexus:choamBetrayal',
entries: [{ card: structuredClone(paused.discard.find(card => card.id === record.card)!), discardedBy: target, publicFace: false }],
},
continuation: { kind: 'nexusChoamBetrayal', event: record.event, owner, target, card: record.card, handBefore: record.handBefore },
};
assert.equal(viewGame(paused, owner).automaticContinuationPending, true);
assert.deepEqual(normalizeAutomaticGame(reload(paused)), done);
reject(paused, owner, action);
for (const damage of [
(state: Game) => { state.nexusChoamBetrayals![0].card = 'forged'; },
(state: Game) => { state.pendingTreacheryDiscard!.continuation = { kind: 'nexusChoamBetrayal', event: record.event, owner, target, card: 'forged', handBefore: record.handBefore }; },
(state: Game) => { state.pendingTreacheryDiscard = null; state.resolvedTreacheryDiscardSequence = sequence; },
]) {
const bad = reload(paused); damage(bad); const before = reload(bad);
assert.throws(() => viewGame(bad, owner));
assert.deepEqual(bad, before);
}
});
void test('a completed Betrayal remains valid when its Nexus discard is recycled by a later draw in the same turn', () => {
const { g, owner, action } = position(false, 0);
const done = applyAction(g, owner, action);
const recycled = reload(done), cards = recycled.nexusCards!.cards!;
cards.discard.push(...cards.deck.splice(0));
recycled.nexusCards!.cards = drawNexusCard(cards, owner, recycled.players, () => 0);
assert.equal(recycled.nexusCards!.cards!.discard.includes('choam'), false);
nexusTraitorInventory(recycled);
assert.doesNotThrow(() => viewGame(recycled, owner));
assert.deepEqual(normalizeAutomaticGame(reload(recycled)), recycled);
});
+144
View File
@@ -0,0 +1,144 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { Card } from '../game/cards';
import type { Game } from '../game/engine';
import {
nexusChoamBetrayalSignature, quoteNexusChoamBetrayal,
validateNexusChoamBetrayal, type NexusChoamBetrayal,
} from '../game/nexus-choam-betrayal';
import { nexusTraitorFixture } from './fixture-nexus-traitors';
import { nexusReload } from './fixture-nexus-cards';
function position(phase = 5) {
const g = nexusTraitorFixture({ ownerFaction: 'atreides', opponentFaction: 'choam', phase });
const owner = g.players[0].id, target = g.players[1].id;
const cards = g.nexusCards!.cards!;
const index = cards.deck.indexOf('choam');
assert.ok(index >= 0);
cards.deck[index] = cards.hands[owner]!;
cards.hands[owner] = 'choam';
const selected = g.deck.splice(0, 2);
assert.equal(selected.length, 2);
g.players[1].hand.push(...selected);
return { g, owner, target, selected };
}
void test('outside Bidding, a CHOAM Betrayal offer reads neither hidden hand count nor identities', () => {
const { g, owner, target, selected } = position();
const offer = quoteNexusChoamBetrayal(g, owner)!;
assert.deepEqual(offer.target, { id: target, name: g.players[1].name, handSize: null });
assert.equal(offer.blocked, null);
assert.deepEqual(JSON.parse(offer.event), ['nexusChoamBetrayal', g.turn, g.phase, owner, target]);
for (const card of selected) assert.ok(!JSON.stringify(offer).includes(card.id));
for (const count of [0, 1, 3]) {
const alternate = nexusReload(g);
alternate.deck.push(...alternate.players[1].hand.splice(0));
alternate.players[1].hand.push(...alternate.deck.splice(0, count));
assert.deepEqual(quoteNexusChoamBetrayal(alternate, owner), offer);
}
g.players[1].hand = new Proxy(g.players[1].hand, {
get() { throw new Error('Private CHOAM hand was read'); },
});
assert.deepEqual(quoteNexusChoamBetrayal(g, owner), offer);
const bidding = position(3);
assert.equal(quoteNexusChoamBetrayal(bidding.g, bidding.owner)!.target.handSize, 2);
bidding.g.deck.push(...bidding.g.players[1].hand.splice(0));
assert.match(quoteNexusChoamBetrayal(bidding.g, bidding.owner)!.blocked!, /no Treachery/);
});
void test('the same clean boundary is available across printed phases, but not through live interactions', () => {
for (const phase of [0, 2, 5, 7, 8]) {
const { g, owner } = position(phase);
assert.equal(quoteNexusChoamBetrayal(g, owner)!.blocked, null);
assert.match(quoteNexusChoamBetrayal(g, owner, true)!.blocked!, /current interaction/);
}
const { g, owner, target } = position();
for (const change of [
(copy: Game) => { copy.response = { kind: 'card' } as never; },
(copy: Game) => { copy.decision = { kind: 'guildTiming' } as never; },
(copy: Game) => { copy.pendingTreacheryDiscard = { sequence: 1 } as never; },
(copy: Game) => { copy.nexusCards!.phase = { stage: 'drawing' } as never; },
(copy: Game) => { copy.battle = { event: 'ongoing' } as never; },
(copy: Game) => { copy.pendingExchange = { response: null, decision: null }; },
]) {
const copy = nexusReload(g); change(copy);
assert.match(quoteNexusChoamBetrayal(copy, owner)!.blocked!, /current interaction/);
}
const empty = nexusReload(g);
empty.deck.push(...empty.players[1].hand.splice(0));
assert.equal(quoteNexusChoamBetrayal(empty, owner)!.blocked, null);
const allied = nexusReload(g);
allied.players[0].ally = allied.players[2].id;
allied.players[2].ally = owner;
assert.match(quoteNexusChoamBetrayal(allied, owner)!.blocked!, /unallied/);
assert.equal(quoteNexusChoamBetrayal(g, target), null);
assert.equal(quoteNexusChoamBetrayal(g, 'foreign'), null);
const noCard = nexusReload(g);
noCard.nexusCards!.cards!.hands[owner] = null;
assert.equal(quoteNexusChoamBetrayal(noCard, owner), null);
const absent = nexusReload(g);
absent.players[1].faction = 'guild';
assert.equal(quoteNexusChoamBetrayal(absent, owner), null);
});
void test('signed sampled-card receipt validates after discard and JSON reload without future hand custody', () => {
const { g, owner, target, selected } = position();
const offer = quoteNexusChoamBetrayal(g, owner)!;
const record: NexusChoamBetrayal = {
event: offer.event, turn: g.turn, phase: g.phase, owner, target,
card: selected[0].id, handBefore: 2, stage: 'discard', signature: '',
};
record.signature = nexusChoamBetrayalSignature(record);
const physical = [...g.deck, ...g.players.flatMap(player => player.hand), ...g.discard];
assert.doesNotThrow(() => validateNexusChoamBetrayal(g, record, physical));
const spent = nexusReload(g);
spent.nexusCards!.cards!.hands[owner] = null;
spent.nexusCards!.cards!.discard.push('choam');
const discarded = spent.players[1].hand.shift()!;
spent.discard.push(discarded);
spent.players[1].hand.push(spent.deck.shift()!);
const restored = nexusReload(spent), receipt = JSON.parse(JSON.stringify(record)) as NexusChoamBetrayal;
assert.doesNotThrow(() => validateNexusChoamBetrayal(restored, receipt,
[...restored.deck, ...restored.discard, ...restored.players.flatMap(player => player.hand)]));
receipt.stage = 'complete'; receipt.signature = nexusChoamBetrayalSignature(receipt);
restored.turn++;
assert.doesNotThrow(() => validateNexusChoamBetrayal(restored, receipt,
[...restored.deck, ...restored.discard, ...restored.players.flatMap(player => player.hand)]));
assert.equal(restored.players[1].hand.length, record.handBefore);
});
void test('malformed, stale or forged source/selection receipts cannot become historical evidence', () => {
const { g, owner, target, selected } = position();
const event = quoteNexusChoamBetrayal(g, owner)!.event;
const receipt: NexusChoamBetrayal = {
event, turn: g.turn, phase: g.phase, owner, target, card: selected[0].id,
handBefore: 2, stage: 'discard', signature: '',
};
receipt.signature = nexusChoamBetrayalSignature(receipt);
const physical = [...g.deck, ...g.players.flatMap(player => player.hand), ...g.discard];
const invalid = (patch: Partial<NexusChoamBetrayal>, cards: readonly Card[] = physical) =>
assert.throws(() => validateNexusChoamBetrayal(g, { ...receipt, ...patch }, cards));
invalid({ event: 'forged' });
invalid({ card: selected[1].id });
invalid({ owner: target });
invalid({ target: g.players[2].id });
invalid({ phase: receipt.phase + 1 });
invalid({ turn: receipt.turn + 1 });
invalid({ handBefore: 1 });
invalid({ handBefore: 0 });
invalid({ handBefore: Number.MAX_SAFE_INTEGER + 1 });
invalid({ stage: 'pending' as never });
invalid({ card: 'invented', signature: nexusChoamBetrayalSignature({ ...receipt, card: 'invented' }) });
invalid({}, physical.filter(card => card.id !== receipt.card));
invalid({}, [...physical, selected[0]]);
invalid({ extra: 'hidden' } as never);
const changedRoster = nexusReload(g);
changedRoster.players[1].faction = 'guild';
assert.throws(() => validateNexusChoamBetrayal(changedRoster, receipt, physical));
const foreignOwner = nexusReload(g);
foreignOwner.players[0].faction = 'choam';
assert.throws(() => validateNexusChoamBetrayal(foreignOwner, receipt, physical));
const future = nexusReload(g);
future.phase = 2;
assert.throws(() => validateNexusChoamBetrayal(future, receipt, physical));
});