diff --git a/components/game-table.tsx b/components/game-table.tsx
index be2552df..4e97c969 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -9,6 +9,7 @@ import { HomeworldRevivalDeployment } from './homeworld-revival-deployment';
import { CaladanReinforcement } from './caladan-reinforcement';
import { GrummanCollection } from './grumman-collection';
import { TupileIntelligence } from './tupile-intelligence';
+import { NexusCards } from './nexus-cards';
import { TerrorBoardMarkers } from './terror-board-markers';
import { homeworldRevivalActionBlock } from '@/game/homeworld-revival-deployment-options';
import { GuildHomeworldShipment } from './guild-homeworld-shipment';
@@ -1415,7 +1416,8 @@ export function GameTable({
{g.status === 'finished' ? 'FINAL OUTCOME' : 'YOUR NEXT DECISION'}
-
+
+ {!g.nexusCards?.waiting.length && }
{g.biddingEnd && }
{g.junctionTransport && [g.junctionTransport.owner, g.junctionTransport.recipient].includes(me.id) && (
@@ -1436,7 +1438,9 @@ export function GameTable({
sector={sector}
onSectorChange={setSector}
/>
- {g.truthtrance ? (
+ {g.nexusCards?.waiting.length ? (
+
The next phase begins when the remaining Nexus card choices are finished.
+ ) : g.truthtrance ? (
candidate.faction === card)!;
+ return (
+
+
+ Nexus card
+ {identity.name}
+
+
+
+ {panels.map((panel) => (
+
+
+ {NEXUS_PANEL_NAMES[panel]}
+ {mode === panel && Your applicable mode }
+
+
+ {panel === 'cunning' ? `${identity.name} player only` : panel === 'betrayal' ? `Another player controls ${identity.name}` : `${identity.name} is not in this game`}
+
+ {reference[panel]}
+
+ ))}
+
+ {!enlarged && (
+
+
+ } aria-label={`Inspect ${identity.name} Nexus card`}>
+ Inspect card
+
+
+
+
+
+ {identity.name} Nexus card
+ }>Close
+
+ All three Nexus effects and their faction requirements. Card effects are not playable yet.
+
+
+
+
+
+ )}
+ Ecaz & Moritani · Original Nexus effects
+
+ );
+}
+
+export function NexusCardGallery() {
+ return {NEXUS_CARD_REFERENCE.map((card) => )}
;
+}
+
+/** The server supplies current choices. The client binds the exact offered
+ * turn and held identity; it neither infers an opponent's card nor draws one. */
+export function nexusCardChoiceAction(game: GameView, choice: NexusCardChoice, ownRedraws: 0 | 1 | 2 = 0): Action | null {
+ const offer = game.nexusCards;
+ if (!offer || game.status !== 'playing' || game.phase !== 1 || offer.turn !== game.turn ||
+ !offer.waiting.includes(game.me) || !offer.choices.includes(choice) || ![0, 1, 2].includes(ownRedraws)) return null;
+ const owner = game.players.find((player) => player.id === game.me);
+ if (!owner || (choice === 'draw' && offer.card !== null) ||
+ (choice === 'replace' && offer.card === null)) return null;
+ return nexusCardAction(game, choice, ownRedraws);
+}
+
+export function NexusCards({ game, act, busy }: { game: GameView; act: (action: Action) => void; busy: boolean }) {
+ const [ownRedraws, setOwnRedraws] = useState<0 | 1 | 2>(0);
+ const offer = game.nexusCards;
+ if (!offer) return null;
+ const owner = game.players.find((player) => player.id === game.me);
+ if (!owner) return null;
+ const mode = offer.card ? nexusCardMode(offer.card, owner.faction, game.players.map((player) => player.faction)) : undefined;
+ const labels: Record = {
+ draw: 'Draw a Nexus card', replace: 'Discard and replace',
+ keep: offer.card ? 'Keep this card' : 'Skip this draw',
+ };
+ const waiting = offer.waiting.filter((id) => id !== game.me)
+ .map((id) => game.players.find((player) => player.id === id)?.name).filter(Boolean);
+ return (
+
+
+
Nexus cards
+
{offer.deckCount} in deck · {offer.discardCount} discarded · {Object.values(offer.held).filter((held) => held === true).length} held
+
Drawing and keeping cards is supported; their effects are not playable yet.
+
+
+ {offer.card ?
:
You do not hold a Nexus card.
}
+
+ {(offer.choices.includes('draw') || offer.choices.includes('replace')) && (
+
+ If I draw my faction
+ setOwnRedraws(Number(event.target.value) as 0 | 1 | 2)}>
+ Keep it
+ Redraw once
+ Redraw whenever it appears
+
+ Your choice applies privately during the draw, in Basic or Advanced play.
+
+ )}
+
+ {[...offer.choices].sort((a, b) => Number(a === 'keep') - Number(b === 'keep')).map((choice) => {
+ const action = nexusCardChoiceAction(game, choice, ownRedraws);
+ return { if (!busy && action) act(action); }}>{labels[choice]} ;
+ })}
+
+ {!!waiting.length && Waiting for Nexus choices: {waiting.join(', ')}.
}
+
+ View all 12 Nexus cards
+ This reference includes every faction. Other players’ held card identities stay secret.
+
+
+
+ );
+}
diff --git a/components/rules-reference.tsx b/components/rules-reference.tsx
index 5605824d..6ff62961 100644
--- a/components/rules-reference.tsx
+++ b/components/rules-reference.tsx
@@ -2,6 +2,7 @@
import Link from 'next/link';
import { StrongholdCardGallery } from './stronghold-cards';
import { HomeworldCardGallery } from './homeworld-cards';
+import { NexusCardGallery } from './nexus-cards';
import { createStrongholdCards } from '@/game/stronghold-cards';
import { useSearchParams } from 'next/navigation';
import { Suspense, useState, useEffect, useRef } from 'react';
@@ -187,6 +188,7 @@ function RulesReferenceContent() {
/>
)}
{topic.id === 'homeworlds' && }
+ {topic.id === 'nexus-cards' && }
{topic.id === 'richese-cards' && (
{
diff --git a/game/engine.ts b/game/engine.ts
index 29620580..0c710168 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -1,3 +1,5 @@
+import { createNexusCards, validateNexusCards, drawNexusCard, replaceNexusCard, discardNexusCard, projectNexusCards, type NexusState } from './nexus-cards';
+import { createNexusCardPhase, markNexusCardOccurred, validateNexusCardPhase, closeNexusCardPhase, nexusCardChoices, finishNexusCardChoice, type NexusCardPhase, type NexusCardChoice } from './nexus-card-phase';
import { createHomeworldOccupationHistory, observeHomeworldOccupation, validateHomeworldOccupationHistory, tupileOccupationStatus, type HomeworldOccupationHistory } from './homeworld-occupation-history';
import { tupileIntelligenceTargets, quoteTupileIntelligenceRequest, type TupileIntelligenceCategory } from './tupile-intelligence';
import { quoteTupileIntelligenceAnswer } from './tupile-intelligence-answer';
@@ -1244,6 +1246,8 @@ export type Game = {
sandtrout?: boolean;
techTokens?: TechState | null;
strongholdCards?: StrongholdState | null;
+ /** Optional independent module; complete effect coverage is still release-gated. */
+ nexusCards?: { cards: NexusState | null; phase: NexusCardPhase | null } | null;
/** Null/absent disables the module; custody is installed at force placement. */
homeworlds?: { custody: HomeworldCustody | null; historyVersion?: 1 } | null;
homeworldOccupationHistory?: HomeworldOccupationHistory;
@@ -1389,6 +1393,97 @@ export type Game = {
aid: Record;
};
export class RuleError extends Error {}
+function nexusRule(work: () => T): T {
+ try { return work(); } catch (error) {
+ throw new RuleError(error instanceof Error ? error.message : 'Invalid Nexus card operation.');
+ }
+}
+function nexusCardsIntegrity(g: Game) {
+ const nexus = g.nexusCards;
+ if (!nexus) return;
+ requireRule(Object.keys(nexus).sort().join(',') === 'cards,phase', 'Invalid Nexus module fields.');
+ if (g.status === 'lobby') {
+ requireRule(nexus.cards === null && nexus.phase === null, 'Nexus cards are dealt only after setup begins.');
+ return;
+ }
+ requireRule(nexus.cards, 'The Nexus inventory is missing.');
+ nexusRule(() => validateNexusCards(nexus.cards!, g.players));
+ requireRule(g.players.every(p => !p.ally || nexus.cards!.hands[p.id] === null),
+ 'Allied players cannot retain Nexus cards.');
+ const phase = nexus.phase;
+ if (!phase) {
+ requireRule(g.turn === 1 && g.phase === 0, 'The Nexus phase record is missing.');
+ return;
+ }
+ nexusRule(() => validateNexusCardPhase(phase, g.players, nexus.cards!));
+ requireRule(phase.turn <= g.turn && phase.turn >= g.turn - 1 &&
+ (phase.stage === 'complete' || (phase.turn === g.turn && g.phase === 1)) &&
+ (g.phase !== 1 || phase.turn === g.turn), 'The Nexus record does not match its phase.');
+ if (phase.stage === 'drawing')
+ requireRule(!g.decision && !g.response && !g.phaseOpening && !g.spiceSequence && !g.spiceWindow &&
+ !g.spiceResolution && !g.summonedWorm && !g.wormRides.length && !g.choamMarket &&
+ !g.truthtrance && !g.pendingKarama && !g.pendingTreacheryDiscard,
+ 'Finish the preceding phase interaction before drawing Nexus cards.');
+}
+function markNexusOccurred(g: Game) {
+ if (!g.nexusCards) return;
+ const phase = g.nexusCards.phase;
+ requireRule(phase?.turn === g.turn && phase.stage === 'spice', 'A Nexus needs an open Spice Blow phase.');
+ g.nexusCards.phase = nexusRule(() => markNexusCardOccurred(phase));
+}
+function discardAllianceNexusCards(g: Game, ...players: Player[]) {
+ if (!g.nexusCards?.cards) return;
+ for (const player of players) {
+ if (!g.nexusCards.cards.hands[player.id]) continue;
+ g.nexusCards.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, player.id, g.players));
+ log(g, `${player.name} discarded their held Nexus Card to enter the alliance.`,
+ { faction: player.faction, name: 'Nexus alliance discard' });
+ }
+}
+function closeNexusCards(g: Game): boolean {
+ const nexus = g.nexusCards;
+ if (g.phase !== 1 || !nexus) return false;
+ requireRule(nexus.phase?.turn === g.turn && nexus.cards, 'The Nexus phase record is missing.');
+ if (nexus.phase.stage === 'spice') {
+ nexus.phase = nexusRule(() => closeNexusCardPhase(nexus.phase!, g.players));
+ if (nexus.phase.stage === 'drawing')
+ log(g, 'Spice Blow and Nexus is ending. Unallied factions may draw a secret Nexus Card, replace their held card, or keep their current position. Alliances are settled.');
+ }
+ return nexus.phase.stage === 'drawing';
+}
+function decideNexusCard(g: Game, p: Player, action: Action) {
+ const nexus = g.nexusCards;
+ requireRule(g.status === 'playing' && g.phase === 1 && nexus?.cards && nexus.phase?.stage === 'drawing' &&
+ action.turn === nexus.phase.turn && action.card === nexus.cards.hands[p.id] &&
+ Object.keys(action).sort().join(',') === 'card,choice,ownRedraws,turn,type' &&
+ [0, 1, 2].includes(action.ownRedraws as number), 'Choose your current Nexus card opportunity.');
+ const choice = action.choice as NexusCardChoice;
+ requireRule(nexusCardChoices(nexus.phase, nexus.cards, p.id).includes(choice), 'That Nexus choice is unavailable.');
+ requireRule(choice !== 'keep' || action.ownRedraws === 0, 'Keeping your card does not draw a replacement.');
+ if (choice === 'draw') nexus.cards = nexusRule(() => drawNexusCard(nexus.cards!, p.id, g.players, random));
+ else if (choice === 'replace')
+ nexus.cards = nexusRule(() => replaceNexusCard(nexus.cards!, p.id, g.players, random));
+ if (choice !== 'keep') {
+ // The player preselects this optional policy knowing their own faction's
+ // printed effect. No private redraw prompt leaks its identity through
+ // whether the public phase waits or advances. At most two redraws are
+ // possible: twelve cards and at most six held cards leave other cards in
+ // the recycled deck after drawing the same own card for a second time.
+ for (let remaining = action.ownRedraws as number;
+ remaining > 0 && nexus.cards.hands[p.id] === p.faction; remaining--)
+ nexus.cards = nexusRule(() => replaceNexusCard(nexus.cards!, p.id, g.players, random));
+ }
+ nexus.phase = nexusRule(() => finishNexusCardChoice(nexus.phase!, g.players, nexus.cards!, p.id, choice));
+ log(g, `${p.name} ${choice === 'keep' ? 'finished their Nexus card choice' : choice === 'draw' ? 'drew a secret Nexus Card' : 'discarded a Nexus Card and drew a secret replacement'}.`);
+ if (nexus.phase.stage === 'complete') completePhase(g);
+}
+function projectedNexusCards(g: Game, id: string) {
+ const nexus = g.nexusCards;
+ if (!nexus?.cards) return null;
+ return { ...projectNexusCards(nexus.cards, id, g.players), turn: nexus.phase?.turn ?? null,
+ choices: nexusCardChoices(nexus.phase, nexus.cards, id),
+ waiting: nexus.phase?.stage === 'drawing' ? nexus.phase.eligible.filter(owner => !nexus.phase!.done.includes(owner)) : [] };
+}
function homeworldRule(quote: () => T): T {
try {
return quote();
@@ -3908,6 +4003,7 @@ function start(g: Game) {
);
requireRule(!g.advanced, 'Advanced rules are still being implemented.');
requireRule(!g.homeworlds, 'Homeworld gameplay is still being implemented.');
+ requireRule(!g.nexusCards, 'Nexus card effects are still being implemented.');
requireRule(
g.players.every((p) => faction(p.faction).expansion === 'base'),
'Expansion factions are still being implemented.',
@@ -3919,6 +4015,7 @@ function start(g: Game) {
initializeSetup(g);
}
function initializeSetup(g: Game) {
+ if (g.nexusCards) g.nexusCards = { cards: createNexusCards(g.players, random), phase: null };
const choam = byFaction(g, 'choam');
if (choam && g.advanced && !choam.leaders.some(isAuditorLeader))
choam.leaders.push(createAuditorLeader());
@@ -4093,7 +4190,14 @@ export function initializeHomeworldGameForAudit(state: Game): Game {
);
return initializeSetupGameForAudit(state, true);
}
-function initializeSetupGameForAudit(state: Game, homeworlds: boolean): Game {
+/** Offline-only seam for the independent Nexus module; public starts remain gated. */
+export function initializeNexusGameForAudit(state: Game): Game {
+ requireRule(!!state.nexusCards && state.nexusCards.cards === null && state.nexusCards.phase === null,
+ 'Enable Nexus cards in a fresh audit lobby first.');
+ return initializeSetupGameForAudit(state, false, true);
+}
+function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = false): Game {
+ nexusCardsIntegrity(state);
homeworldRule(() => homeworldGameIntegrity(state));
homeworldBattleLossIntegrity(state);
homeworldSubstitutionIntegrity(state);
@@ -4114,13 +4218,14 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean): Game {
'The audit initializer requires two through six distinct ready players and an existing host.',
);
requireRule(
- (homeworlds || g.expansions.length === 0) &&
+ (homeworlds || nexus || g.expansions.length === 0) &&
+ (nexus || !g.nexusCards) &&
!g.techTokens &&
!g.strongholdCards &&
(homeworlds || !g.homeworlds) &&
g.players.every((p) =>
FACTIONS.some(
- (f) => f.id === p.faction && (homeworlds || f.expansion === 'base'),
+ (f) => f.id === p.faction && (homeworlds || nexus || f.expansion === 'base'),
),
),
homeworlds
@@ -4495,6 +4600,7 @@ function afterWorm(g: Game) {
Object.assign(g, pending.resume);
g.summonedWorm = null;
g.wormRides = [...g.wormRides, ...rides];
+ markNexusOccurred(g);
g.ready = [];
if (g.spiceWindow?.territory === pending.territory) {
g.spiceWindow.harvested = true;
@@ -4582,6 +4688,7 @@ function continueSpice(g: Game, injected?: SpiceCard) {
);
} else {
g.nexus = true;
+ markNexusOccurred(g);
g.spiceDiscard[sequence.pile].push(card);
if (previous && 'worm' in previous && g.advanced) {
const fremen = byFaction(g, 'fremen');
@@ -8520,6 +8627,7 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
target.allySinceTurn = member.allySinceTurn;
}
g.allianceOffers = quote.allianceOffers;
+ discardAllianceNexusCards(g, owner, entrant);
g.ready = [];
log(
g,
@@ -8959,6 +9067,7 @@ function formTerrorAlliance(g: Game, owner: Player, entrant: Player) {
owner.ally = entrant.id;
entrant.ally = owner.id;
owner.allySinceTurn = entrant.allySinceTurn = g.turn;
+ discardAllianceNexusCards(g, owner, entrant);
g.ready = [];
}
function decideTerror(g: Game, p: Player, action: Action) {
@@ -9923,6 +10032,7 @@ function finishEcazPlacement(g: Game) {
completePhase(g);
}
function completePhase(g: Game) {
+ if (closeNexusCards(g)) return;
if (g.phase === 7 && g.grummanCollection?.turn === g.turn && g.grummanCollection.stage === 'waiting') {
g.grummanCollection.stage = 'complete';
g.grummanCollection.outcome = 'expired';
@@ -9941,6 +10051,8 @@ function completePhase(g: Game) {
}
/** Open the same public window for every Ix table, independently of who holds Amal. */
function openPhase(g: Game, initialize = true) {
+ if (g.phase === 1 && g.nexusCards && g.nexusCards.phase?.turn !== g.turn)
+ g.nexusCards.phase = createNexusCardPhase(g.turn);
if (initialize && g.phase === 4 && g.homeworlds) {
g.homeworldRevival = homeworldRule(() => snapshotHomeworldRevival(g));
if (g.homeworldRevival?.tleilaxu.low)
@@ -11225,6 +11337,7 @@ function gholaOptions(g: Game, p: Player) {
};
}
function marketGholaIntegrity(g: Game) {
+ nexusCardsIntegrity(g);
homeworldHistoryIntegrity(g);
grummanCollectionIntegrity(g);
homeworldVictoryReturnIntegrity(g);
@@ -16409,6 +16522,8 @@ function applyActionInner(
requireRule(g.status !== 'finished', 'This game has ended.');
const t = action.type;
if (t === 'advanceBots') return g;
+ if (t === 'nexusCardChoice') { decideNexusCard(g, p, action); return g; }
+ requireRule(g.nexusCards?.phase?.stage !== 'drawing', 'Finish the closing Nexus card choices first.');
requireRule(
!(
t === 'card' &&
@@ -18134,6 +18249,7 @@ function applyActionInner(
p.ally = other.id;
other.ally = id;
p.allySinceTurn = other.allySinceTurn = g.turn;
+ discardAllianceNexusCards(g, p, other);
delete g.allianceOffers[id];
delete g.allianceOffers[other.id];
log(g, `${p.name} and ${other.name} formed an alliance.`);
@@ -19848,6 +19964,7 @@ export function viewGame(state: Game, id: string) {
: null,
techTokens: g.techTokens ?? null,
strongholdCards: g.strongholdCards ?? null,
+ nexusCards: projectedNexusCards(g, id),
homeworldRevivalDeployment: projectedHomeworldRevivalReturn(g, id),
caladanReinforcement: projectedHomeworldVictoryReturn(g, id),
homeworldRevivalBlocks: homeworldRevivalChoiceBlocks(g, me),
@@ -19898,6 +20015,7 @@ export function viewGame(state: Game, id: string) {
spicePile: g.spiceSequence?.pile ?? 0,
beforeSpiceDraw:
g.phase === 1 &&
+ g.nexusCards?.phase?.stage !== 'drawing' &&
!g.spiceSequence &&
!g.spiceResolution &&
!g.spiceWindow &&
diff --git a/game/nexus-card-options.ts b/game/nexus-card-options.ts
new file mode 100644
index 00000000..b3d7df9b
--- /dev/null
+++ b/game/nexus-card-options.ts
@@ -0,0 +1,18 @@
+import type { Action, GameView } from './engine';
+import type { NexusCardChoice } from './nexus-card-phase';
+
+export function nexusCardAction(game: GameView, choice: NexusCardChoice, ownRedraws: 0 | 1 | 2 = 0): Action | null {
+ const offer = game.nexusCards;
+ if (game.status !== 'playing' || game.phase !== 1 || offer?.turn !== game.turn ||
+ !offer.waiting.includes(game.me) || !offer.choices.includes(choice) || ![0, 1, 2].includes(ownRedraws)) return null;
+ return { type: 'nexusCardChoice', turn: offer.turn, card: offer.card, choice, ownRedraws: choice === 'keep' ? 0 : ownRedraws };
+}
+
+/** Common legal ownership policy. Choosing among card effects will be handled
+ * by the faction-aware effect policy once those effects are integrated. */
+export function nexusCardBotActions(game: GameView): Action[] {
+ const choices = game.nexusCards?.choices ?? [];
+ const choice = choices.includes('draw') ? 'draw' : choices.includes('keep') ? 'keep' : null;
+ const action = choice && nexusCardAction(game, choice);
+ return action ? [action] : [];
+}
diff --git a/game/nexus-card-phase.ts b/game/nexus-card-phase.ts
new file mode 100644
index 00000000..b0c328a1
--- /dev/null
+++ b/game/nexus-card-phase.ts
@@ -0,0 +1,197 @@
+import {
+ validateNexusCards,
+ validateNexusPlayers,
+ type NexusPlayer,
+ type NexusState,
+} from './nexus-cards';
+
+/** One closing opportunity for the whole Spice Blow and Nexus phase, including
+ * both Advanced spice piles. Pre-draw own-faction policies resolve privately
+ * within one action, so public progress never identifies a drawn card. */
+export type NexusCardPhase = {
+ turn: number;
+ occurred: boolean;
+ stage: 'spice' | 'drawing' | 'complete';
+ eligible: string[];
+ done: string[];
+ signature: string;
+};
+export type NexusCardChoice = 'draw' | 'replace' | 'keep';
+
+function requireNexus(condition: unknown, message: string): asserts condition {
+ if (!condition) throw new Error(message);
+}
+function signature(phase: Omit): string {
+ return JSON.stringify([
+ 'nexusCardPhase',
+ phase.turn,
+ phase.occurred,
+ phase.stage,
+ phase.eligible,
+ phase.done,
+ ]);
+}
+function validateFrame(phase: NexusCardPhase): void {
+ requireNexus(
+ phase &&
+ typeof phase === 'object' &&
+ !Array.isArray(phase) &&
+ Object.keys(phase).sort().join(',') ===
+ 'done,eligible,occurred,signature,stage,turn' &&
+ Number.isSafeInteger(phase.turn) &&
+ phase.turn > 0 &&
+ typeof phase.occurred === 'boolean' &&
+ ['spice', 'drawing', 'complete'].includes(phase.stage),
+ 'Invalid saved Nexus phase.',
+ );
+ for (const list of [phase.eligible, phase.done])
+ requireNexus(
+ Array.isArray(list) &&
+ new Set(list).size === list.length &&
+ list.every((id) => typeof id === 'string' && id.trim().length > 0),
+ 'Invalid Nexus decision owners.',
+ );
+ requireNexus(
+ phase.done.every((id) => phase.eligible.includes(id)),
+ 'Nexus choices must belong to an eligible player.',
+ );
+ requireNexus(
+ phase.signature === signature(phase),
+ 'The saved Nexus phase has lost its original progress.',
+ );
+ if (phase.stage === 'spice')
+ requireNexus(
+ !phase.eligible.length && !phase.done.length,
+ 'Nexus draws cannot precede the closing opportunity.',
+ );
+ if (!phase.occurred)
+ requireNexus(
+ !phase.eligible.length,
+ 'Nexus draws require an actual Nexus this phase.',
+ );
+ if (phase.stage === 'complete')
+ requireNexus(
+ phase.eligible.length === phase.done.length,
+ 'A completed Nexus opportunity cannot retain a choice.',
+ );
+ if (phase.stage === 'drawing')
+ requireNexus(
+ phase.eligible.some((id) => !phase.done.includes(id)),
+ 'A Nexus draw window needs an unfinished recipient.',
+ );
+}
+function signed(phase: Omit): NexusCardPhase {
+ return { ...phase, signature: signature(phase) };
+}
+export function createNexusCardPhase(turn: number): NexusCardPhase {
+ requireNexus(
+ Number.isSafeInteger(turn) && turn > 0,
+ 'Invalid Nexus phase turn.',
+ );
+ return signed({
+ turn,
+ occurred: false,
+ stage: 'spice',
+ eligible: [],
+ done: [],
+ });
+}
+export function markNexusCardOccurred(phase: NexusCardPhase): NexusCardPhase {
+ validateFrame(phase);
+ requireNexus(
+ phase.stage === 'spice',
+ 'A Nexus occurrence needs its open Spice Blow phase.',
+ );
+ return signed({ ...structuredClone(phase), occurred: true });
+}
+export function validateNexusCardPhase(
+ phase: NexusCardPhase,
+ players: readonly NexusPlayer[],
+ cards: NexusState,
+): void {
+ validateFrame(phase);
+ validateNexusCards(cards, players);
+ requireNexus(
+ phase.eligible.every((id) => players.some((player) => player.id === id)),
+ 'Invalid Nexus decision owners.',
+ );
+ if (phase.stage === 'drawing') {
+ const unallied = players
+ .filter((player) => !player.ally)
+ .map((player) => player.id);
+ requireNexus(
+ players.some((player) => !!player.ally) &&
+ phase.eligible.length === unallied.length &&
+ unallied.every((id) => phase.eligible.includes(id)),
+ 'A Nexus draw window needs every unallied recipient and a settled alliance.',
+ );
+ }
+}
+export function closeNexusCardPhase(
+ phase: NexusCardPhase,
+ players: readonly NexusPlayer[],
+): NexusCardPhase {
+ validateFrame(phase);
+ validateNexusPlayers(players);
+ requireNexus(
+ phase.stage === 'spice',
+ 'This Nexus phase already reached its closing opportunity.',
+ );
+ const eligible =
+ phase.occurred && players.some((player) => !!player.ally)
+ ? players.filter((player) => !player.ally).map((player) => player.id)
+ : [];
+ return signed({
+ ...structuredClone(phase),
+ eligible,
+ stage: eligible.length ? 'drawing' : 'complete',
+ });
+}
+export function nexusCardChoices(
+ phase: NexusCardPhase | null,
+ cards: NexusState,
+ player: string,
+): NexusCardChoice[] {
+ if (!phase) return [];
+ validateFrame(phase);
+ if (
+ phase.stage !== 'drawing' ||
+ !phase.eligible.includes(player) ||
+ phase.done.includes(player)
+ )
+ return [];
+ return cards.hands[player] ? ['keep', 'replace'] : ['keep', 'draw'];
+}
+/** Called only after the selected physical operation (and any preauthorized
+ * own-faction replacements) committed. No private card-dependent progress. */
+export function finishNexusCardChoice(
+ phase: NexusCardPhase,
+ players: readonly NexusPlayer[],
+ cards: NexusState,
+ player: string,
+ choice: NexusCardChoice,
+): NexusCardPhase {
+ validateNexusCardPhase(phase, players, cards);
+ requireNexus(
+ ['draw', 'replace', 'keep'].includes(choice),
+ 'Choose a valid Nexus card operation.',
+ );
+ requireNexus(
+ phase.stage === 'drawing' &&
+ phase.eligible.includes(player) &&
+ !phase.done.includes(player),
+ 'Your Nexus choice is already complete or unavailable.',
+ );
+ requireNexus(
+ choice === 'keep' || cards.hands[player] !== null,
+ 'A completed Nexus draw must have its physical card.',
+ );
+ const done = [...phase.done, player];
+ const next = signed({
+ ...structuredClone(phase),
+ done,
+ stage: done.length === phase.eligible.length ? 'complete' : 'drawing',
+ });
+ validateNexusCardPhase(next, players, cards);
+ return next;
+}
diff --git a/game/nexus-card-reference.ts b/game/nexus-card-reference.ts
new file mode 100644
index 00000000..fb4774be
--- /dev/null
+++ b/game/nexus-card-reference.ts
@@ -0,0 +1,96 @@
+import type { FactionId } from './catalog';
+import type { NexusCardMode } from './nexus-cards';
+
+/** Faithful English rules summaries of all 36 original printed panels, rather
+ * than the changed effects in tournament compilations. See NEXUS_CARD_RULES.md.
+ * Reference availability does not enable an effect in the runtime. */
+export type NexusCardReference = {
+ faction: FactionId;
+ betrayal: string;
+ cunning: string;
+ secretAlly: string;
+};
+export const NEXUS_PANEL_NAMES: Record = {
+ betrayal: 'Betrayal', cunning: 'Cunning', secretAlly: 'Secret Ally',
+};
+export const NEXUS_CARD_SOURCE = 'https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani';
+export const NEXUS_CARD_REFERENCE: readonly NexusCardReference[] = [
+ {
+ faction: 'atreides',
+ betrayal: 'Stop Atreides from inspecting one element of a Battle Plan with their battle advantage.',
+ cunning: 'Require your battle opponent to disclose a second element of their Battle Plan.',
+ secretAlly: 'In your battle, inspect one opposing Battle Plan element of your choice: leader, weapon, defense or number dialed.',
+ },
+ {
+ faction: 'beneGesserit',
+ betrayal: 'Prevent the Bene Gesserit from using Voice.',
+ cunning: 'During your Shipment and Movement turn, you may convert any selected groups of advisors into fighters. Every territory must finish with all your forces in the same stance.',
+ secretAlly: 'In your battle, use Voice to require or forbid a Treachery Card in your opponent’s plan.',
+ },
+ {
+ faction: 'emperor',
+ betrayal: 'During Bidding, require Emperor to pay for the ally’s Treachery purchase, covering at least the necessary spice. Alternatively, while Battle Plans are being made, suppress Emperor’s Sardaukar advantage.',
+ cunning: 'Before making your Battle Plan, you may treat five of your forces as Sardaukar if you have no Sardaukar in that battle.',
+ secretAlly: 'On a Treachery purchase, retain your spice after showing that you hold the required amount. Alternatively, during Revival, revive three extra forces free beyond the revival limit.',
+ },
+ {
+ faction: 'fremen',
+ betrayal: 'Stop Fremen worm riding for this turn, or prevent their two-territory movement during Shipment and Movement.',
+ cunning: 'When a sandworm appears in a territory containing no forces, your forces from one desert territory may ride to any territory, respecting storm and occupancy restrictions.',
+ secretAlly: 'Avoid losing your forces to a sandworm, or revive three forces free during Revival.',
+ },
+ {
+ faction: 'harkonnen',
+ betrayal: 'Cancel a revealed Harkonnen traitor and shuffle that card into the Traitor Deck. Harkonnen draws a replacement during Mentat Pause.',
+ cunning: 'At any time, draw a Traitor Card into your hand, then choose one of your held Traitor Cards and shuffle it into the Traitor Deck.',
+ secretAlly: 'During Mentat Pause, draw two Traitor Cards into your hand, then choose two held Traitor Cards and shuffle them into the Traitor Deck.',
+ },
+ {
+ faction: 'guild',
+ betrayal: 'Take one whole shipment payment otherwise going to the Spacing Guild or Spice Bank, including your own payment. This overrides the income of a faction occupying Junction.',
+ cunning: 'Immediately after your Shipment and Movement, make a second shipment. Its forces cannot move unless you play Hajr.',
+ secretAlly: 'Use Guild shipping prices. Your shipping action may instead cross-ship on the planet or return forces to your reserves.',
+ },
+ {
+ faction: 'ixians',
+ betrayal: 'Prevent either the Ixian Bidding advantage or the Technology advantage. This card cannot prevent both.',
+ cunning: 'Before making your Battle Plan, you may give all your Suboids full strength without spice support in every battle for the rest of this turn.',
+ secretAlly: 'Discard a Treachery Card you have just purchased during Bidding, then draw the next Treachery deck card.',
+ },
+ {
+ faction: 'tleilaxu',
+ betrayal: 'Cancel a revealed Face Dancer. That card stays revealed until Tleilaxu has revealed every Face Dancer.',
+ cunning: 'Set your revealed Face Dancers aside and secretly draw replacements. Then shuffle the set-aside cards into the Traitor Deck.',
+ secretAlly: 'During Revival, revive one of your leaders free and up to five of your forces for one spice each.',
+ },
+ {
+ faction: 'choam',
+ betrayal: 'Make CHOAM discard a random card from its hand without receiving spice for that discard.',
+ cunning: 'Discard any Treachery Card to obtain a Worthless Card special effect of your choice.',
+ secretAlly: 'During Spice Collection, discard a Worthless Card for two spice. Alternatively, after winning a battle, inspect a random card in your opponent’s hand that was not used in that battle.',
+ },
+ {
+ faction: 'richese',
+ betrayal: 'Stop Richese from buying a Richese Treachery Card: discard that card without payment. Alternatively, send the payment for a card Richese sells to the Spice Bank instead.',
+ cunning: 'Use two No-Field tokens in a single shipment, still charged as one force. Immediately reveal one token and place it in front of your shield.',
+ secretAlly: 'Instead of normal shipping, send up to five forces from reserves for the shipping price of one force.',
+ },
+ {
+ faction: 'ecaz',
+ betrayal: 'Before Shipment and Movement, choose a territory shared by Ecaz and its ally. Require the ally to return its forces from that territory to reserves.',
+ cunning: 'Take Duke Vidal for this turn, including from capture, the Tanks or Ghola custody, overriding Moritani. Set him aside normally at the end of the turn.',
+ secretAlly: 'Require one player to disclose whether any of your leaders are among their traitors, without identifying which leaders.',
+ },
+ {
+ faction: 'moritani',
+ betrayal: 'Return a Terror token from the board to Moritani without revealing it.',
+ cunning: 'When placing a Terror token, you may choose any Arrakis territory, including one that already has a Terror token.',
+ secretAlly: 'After losing a battle that has a winner, keep one Treachery Card you played which you could have kept if you had won.',
+ },
+];
+
+export function nexusCardReference(id: FactionId): NexusCardReference {
+ const card = NEXUS_CARD_REFERENCE.find((candidate) => candidate.faction === id);
+ if (!card) throw new Error('Unknown Nexus reference card.');
+ return card;
+}
diff --git a/game/nexus-cards.ts b/game/nexus-cards.ts
new file mode 100644
index 00000000..adcabf8f
--- /dev/null
+++ b/game/nexus-cards.ts
@@ -0,0 +1,280 @@
+import { FACTIONS, type FactionId } from './catalog';
+
+/** Physical inventory is independent of seated factions and enabled modules. */
+export const NEXUS_FACTIONS: readonly FactionId[] = Object.freeze(
+ FACTIONS.map((faction) => faction.id),
+);
+export type NexusPlayer = {
+ id: string;
+ faction: FactionId;
+ ally?: string | null;
+};
+export type NexusState = {
+ version: 1;
+ deck: FactionId[];
+ discard: FactionId[];
+ hands: Record;
+};
+export type NexusCardMode = 'cunning' | 'betrayal' | 'secretAlly';
+export type NexusCardsView = {
+ card: FactionId | null;
+ deckCount: number;
+ discardCount: number;
+ held: Record;
+};
+export class NexusCardsError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'NexusCardsError';
+ }
+}
+function requireCards(condition: unknown, message: string): asserts condition {
+ if (!condition) throw new NexusCardsError(message);
+}
+const faction = (value: unknown): value is FactionId =>
+ NEXUS_FACTIONS.includes(value as FactionId);
+const identifier = (value: unknown): value is string =>
+ typeof value === 'string' &&
+ value.trim().length > 0 &&
+ !['__proto__', 'constructor', 'prototype'].includes(value);
+function plain(value: unknown): value is Record {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
+ const prototype: unknown = Object.getPrototypeOf(value);
+ return prototype === Object.prototype || prototype === null;
+}
+export function validateNexusPlayers(players: readonly NexusPlayer[]): void {
+ requireCards(
+ Array.isArray(players) && players.length >= 2 && players.length <= 6,
+ 'Nexus custody needs two through six seated players.',
+ );
+ const ids = new Set(),
+ factions = new Set();
+ for (const player of players) {
+ requireCards(
+ plain(player) &&
+ identifier(player.id) &&
+ faction(player.faction) &&
+ !ids.has(player.id) &&
+ !factions.has(player.faction),
+ 'Nexus custody needs distinct valid seated identities and factions.',
+ );
+ ids.add(player.id);
+ factions.add(player.faction);
+ }
+ for (const player of players)
+ requireCards(
+ player.ally === undefined ||
+ player.ally === null ||
+ (identifier(player.ally) &&
+ player.ally !== player.id &&
+ ids.has(player.ally)),
+ 'A Nexus player has an invalid seated alliance partner.',
+ );
+ for (const player of players)
+ requireCards(
+ player.ally === undefined ||
+ player.ally === null ||
+ players.find((partner) => partner.id === player.ally)!.ally ===
+ player.id,
+ 'Nexus alliances must identify reciprocal seated partners.',
+ );
+}
+
+/** No alias zones, unknown card, missing seat, duplicated identity or extra
+ * private fields may be reconstructed or silently normalized on read. */
+export function validateNexusCards(
+ state: NexusState,
+ players: readonly NexusPlayer[],
+): void {
+ validateNexusPlayers(players);
+ requireCards(
+ plain(state) &&
+ Object.keys(state).length === 4 &&
+ ['version', 'deck', 'discard', 'hands'].every((key) =>
+ Object.hasOwn(state, key),
+ ) &&
+ state.version === 1 &&
+ Array.isArray(state.deck) &&
+ Array.isArray(state.discard) &&
+ plain(state.hands),
+ 'The saved Nexus inventory has an invalid shape.',
+ );
+ requireCards(
+ Object.keys(state.hands).length === players.length &&
+ players.every((player) => Object.hasOwn(state.hands, player.id)),
+ 'Nexus hands must match the complete seated roster.',
+ );
+ const physical: FactionId[] = [];
+ for (const pile of [state.deck, state.discard])
+ for (const card of pile) {
+ requireCards(
+ faction(card),
+ 'The Nexus inventory contains an unknown physical card.',
+ );
+ physical.push(card);
+ }
+ for (const player of players) {
+ const card = state.hands[player.id];
+ requireCards(
+ card === null || faction(card),
+ 'Each Nexus hand holds at most one physical card.',
+ );
+ if (card !== null) physical.push(card);
+ }
+ requireCards(
+ physical.length === NEXUS_FACTIONS.length &&
+ new Set(physical).size === NEXUS_FACTIONS.length,
+ 'The Nexus inventory must conserve exactly one card for each of the twelve factions.',
+ );
+}
+function shuffle(
+ cards: readonly FactionId[],
+ random: () => number,
+): FactionId[] {
+ requireCards(
+ typeof random === 'function',
+ 'Nexus shuffling needs server randomness.',
+ );
+ const result = [...cards];
+ for (let index = result.length - 1; index > 0; index--) {
+ const value = random();
+ requireCards(
+ Number.isFinite(value) && value >= 0 && value < 1,
+ 'Nexus random draws must be finite from zero inclusive to one exclusive.',
+ );
+ const selected = Math.floor(value * (index + 1));
+ [result[index], result[selected]] = [result[selected], result[index]];
+ }
+ return result;
+}
+export function createNexusCards(
+ players: readonly NexusPlayer[],
+ random: () => number,
+): NexusState {
+ validateNexusPlayers(players);
+ return {
+ version: 1,
+ deck: shuffle(NEXUS_FACTIONS, random),
+ discard: [],
+ hands: Object.fromEntries(players.map((player) => [player.id, null])),
+ };
+}
+function owner(
+ state: NexusState,
+ player: string,
+ players: readonly NexusPlayer[],
+): NexusPlayer {
+ validateNexusCards(state, players);
+ const seat = players.find((candidate) => candidate.id === player);
+ requireCards(seat && identifier(player), 'Choose a seated Nexus card owner.');
+ return seat;
+}
+function requireUnallied(player: NexusPlayer): void {
+ requireCards(
+ player.ally === undefined || player.ally === null,
+ 'An allied player cannot draw or replace a Nexus card.',
+ );
+}
+function drawInto(
+ state: NexusState,
+ player: string,
+ random: () => number,
+): void {
+ if (!state.deck.length) {
+ requireCards(
+ state.discard.length > 0,
+ 'There is no physical Nexus card to draw.',
+ );
+ state.deck = shuffle(state.discard, random);
+ state.discard = [];
+ }
+ state.hands[player] = state.deck.shift()!;
+}
+
+/** The engine authorizes the phase opportunity or immediate own-faction redraw.
+ * Drawing one's faction never discards it automatically in either rules mode. */
+export function drawNexusCard(
+ state: NexusState,
+ player: string,
+ players: readonly NexusPlayer[],
+ random: () => number,
+): NexusState {
+ requireUnallied(owner(state, player, players));
+ requireCards(
+ state.hands[player] === null,
+ 'Discard a held Nexus card before drawing its replacement.',
+ );
+ const result = structuredClone(state);
+ drawInto(result, player, random);
+ return result;
+}
+export function replaceNexusCard(
+ state: NexusState,
+ player: string,
+ players: readonly NexusPlayer[],
+ random: () => number,
+): NexusState {
+ requireUnallied(owner(state, player, players));
+ requireCards(
+ state.hands[player] !== null,
+ 'Hold a Nexus card before replacing it.',
+ );
+ const result = structuredClone(state);
+ result.discard.push(result.hands[player]!);
+ result.hands[player] = null;
+ drawInto(result, player, random);
+ return result;
+}
+/** Used cards and cards forfeited upon joining any alliance take the same
+ * physical route. The caller separately proves which discard is mandatory. */
+export function discardNexusCard(
+ state: NexusState,
+ player: string,
+ players: readonly NexusPlayer[],
+): NexusState {
+ owner(state, player, players);
+ requireCards(
+ state.hands[player] !== null,
+ 'Hold a Nexus card before discarding it.',
+ );
+ const result = structuredClone(state);
+ result.discard.push(result.hands[player]!);
+ result.hands[player] = null;
+ return result;
+}
+
+export function nexusCardMode(
+ card: FactionId,
+ ownerFaction: FactionId,
+ seatedFactions: readonly FactionId[],
+): NexusCardMode {
+ requireCards(
+ faction(card) &&
+ faction(ownerFaction) &&
+ Array.isArray(seatedFactions) &&
+ seatedFactions.every(faction) &&
+ new Set(seatedFactions).size === seatedFactions.length &&
+ seatedFactions.includes(ownerFaction),
+ 'Nexus effects need a known card and its actual seated faction roster.',
+ );
+ return card === ownerFaction
+ ? 'cunning'
+ : seatedFactions.includes(card)
+ ? 'betrayal'
+ : 'secretAlly';
+}
+export function projectNexusCards(
+ state: NexusState,
+ viewer: string,
+ players: readonly NexusPlayer[],
+): NexusCardsView {
+ owner(state, viewer, players);
+ return {
+ card: state.hands[viewer],
+ deckCount: state.deck.length,
+ discardCount: state.discard.length,
+ held: Object.fromEntries(
+ players.map((player) => [player.id, state.hands[player.id] !== null]),
+ ),
+ };
+}
diff --git a/game/reference.ts b/game/reference.ts
index 1acdb6c2..797d3f9b 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -3,6 +3,7 @@ import { FACTION_RULES } from './faction-reference';
import { FACTIONS, PHASES } from './catalog';
import { RICHESE_CARD_DEFINITIONS } from './richese-cards';
import { HOMEWORLD_CARDS } from './homeworld-cards';
+import { NEXUS_CARD_REFERENCE } from './nexus-card-reference';
export const RULE_CHECKLIST_AREAS = [
'Implementation',
@@ -3333,6 +3334,32 @@ export const RULE_TOPICS: RuleTopic[] = [
},
],
},
+ {
+ id: 'nexus-cards',
+ title: 'Nexus Cards',
+ 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. Ownership and private draw choices are integrated; individual effects 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.',
+ 'Keep your card secret until it is used. You may hold at most one Nexus Card. It is separate from your Treachery hand and does not occupy a Treachery hand slot.',
+ 'Whenever you draw your own faction card, you may immediately discard it and draw again, in either Basic or Advanced play. A replacement is discarded before the new draw; when the deck is empty, shuffle its discard pile to refill it.',
+ 'Before drawing, choose whether to keep your own faction card, redraw it once, or redraw whenever it appears. Its effect is available in the reference before making this choice. The server applies that preference immediately; the table never pauses in a way that identifies a card you chose to keep.',
+ 'Entering any alliance discards your held Nexus Card, including alliances made through an Ambassador or Terror token. An offer that has not been accepted does not discard it.',
+ 'Use Cunning for your own faction, Betrayal when another player controls the printed faction, and Secret Ally when that faction is absent. Used cards go to the separate Nexus discard pile. Each printed effect has its own timing.',
+ 'The development table supports custody, closing choices, replacement, immediate redraw, alliance discard and private card inspection. Effect execution and full module games are unfinished, so public module starts remain disabled.',
+ ],
+ related: ['ecaz-modules', 'spice-blow', 'alliance-funding'],
+ checklist: [
+ { area: 'Implementation', status: 'Partial', detail: 'Physical ownership and whole-phase draw lifecycle; all individual card effects still require integration.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/engine.ts'] },
+ { area: 'Player controls', status: 'Partial', detail: 'Private draw, keep, replace and immediate own-faction redraw; readable card inspection. Effect play controls remain unfinished.', evidence: ['components/nexus-cards.tsx'] },
+ { area: 'AI', status: 'Partial', detail: 'All profiles draw when empty and keep their held card. Effect strategy and module strength calibration are unfinished.', evidence: ['game/nexus-card-options.ts'] },
+ { area: 'Documentation', status: 'Partial', detail: 'Common rules and twelve printed faces are recorded. Effect interaction guidance remains in development.', evidence: ['docs/NEXUS_CARD_RULES.md'] },
+ { area: 'Verification', status: 'Partial', detail: 'Focused custody and phase regressions; complete module games remain a release requirement.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts'] },
+ ],
+ },
...[
[
'ix-modules',
diff --git a/tests/fixture-nexus-cards.ts b/tests/fixture-nexus-cards.ts
new file mode 100644
index 00000000..2e0212c6
--- /dev/null
+++ b/tests/fixture-nexus-cards.ts
@@ -0,0 +1,188 @@
+import assert from 'node:assert/strict';
+import {
+ applyAction,
+ createGame,
+ initializeNexusGameForAudit,
+ joinGame,
+ newPlayer,
+ viewGame,
+ type Action,
+ type Game,
+} from '../game/engine';
+import { botActions } from '../game/bots';
+import { baseDeck, type SpiceCard } from '../game/cards';
+import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
+
+export const nexusReload = (g: Game): Game => JSON.parse(JSON.stringify(g));
+export const nexusPlayer = (g: Game, id: string) =>
+ g.players.find((p) => p.id === id)!;
+
+/** Final seat identities precede the genuine base setup. The only module seam
+ * enables the unfinished Nexus module through its explicit audit initializer. */
+export function nexusFixture(
+ options: {
+ seatIds?: [string, string, string];
+ advanced?: boolean;
+ hostFaction?: 'fremen' | 'moritani' | 'ecaz';
+ } = {},
+): Game {
+ const [f, a, h] = options.seatIds ?? ['f', 'a', 'h'];
+ let g = createGame(
+ 'NEXUSAUDIT',
+ newPlayer(
+ f,
+ options.hostFaction ?? 'fremen',
+ options.hostFaction ?? 'fremen',
+ ),
+ options.advanced ?? false,
+ [],
+ );
+ joinGame(g, newPlayer(a, 'Atreides', 'atreides'));
+ joinGame(g, newPlayer(h, 'Harkonnen', 'harkonnen'));
+ for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
+ g.nexusCards = { cards: null, phase: null };
+ g = initializeNexusGameForAudit(g);
+ for (let i = 0; g.status === 'setup' && i < 60; i++) {
+ let next: Game | undefined;
+ for (const p of g.players) {
+ const view = viewGame(g, p.id);
+ view.players.find((seat) => seat.id === p.id)!.bot = 'Easy';
+ const action = botActions(view)[0];
+ if (action) {
+ next = applyAction(g, p.id, action);
+ break;
+ }
+ }
+ assert.ok(next, `Setup stalled at ${g.setupStage}/${g.decision?.kind}`);
+ g = next;
+ }
+ assert.equal(g.status, 'playing');
+ assert.equal(g.homeworlds, undefined);
+ for (const p of g.players) g.deck.push(...p.hand.splice(0));
+ nexusInventory(g);
+ return g;
+}
+
+export function nexusReady(state: Game): Game {
+ let g = state;
+ for (const p of state.players)
+ if (!g.ready.includes(p.id)) g = applyAction(g, p.id, { type: 'ready' });
+ return g;
+}
+export function nexusAllow(state: Game): Game {
+ let g = state;
+ for (let i = 0; g.response && i < 20; i++) {
+ const id = g.players.find((p) => !g.response!.passed.includes(p.id))!.id;
+ g = applyAction(g, id, { type: 'passResponse' });
+ }
+ assert.equal(g.response, null);
+ return g;
+}
+/** Later turns stage only a settled Storm boundary after a completed phase.
+ * The tracker for the next Spice Blow is always created by actual Storm play. */
+export function enterNexusSpice(state: Game, nextTurn = false): Game {
+ let g = nexusReload(state);
+ if (nextTurn) {
+ assert.equal(g.nexusCards?.phase?.stage, 'complete');
+ Object.assign(g, {
+ turn: g.turn + 1,
+ phase: 0,
+ ready: [],
+ stormPending: null,
+ stormDialers: g.players.slice(0, 2).map((p) => p.id),
+ stormDials: {},
+ });
+ }
+ assert.equal(g.phase, 0);
+ for (const id of g.stormDialers)
+ g = applyAction(g, id, { type: 'stormDial', amount: g.turn === 1 ? 0 : 1 });
+ g = nexusAllow(nexusReady(g));
+ assert.equal(g.phase, 1);
+ assert.equal(g.nexusCards?.phase?.stage, 'spice');
+ return g;
+}
+/** Reorder existing physical spice cards without adding/removing their census. */
+export function orderNexusSpice(g: Game, kinds: ('land' | 'worm')[]): void {
+ const all = [...g.spiceDeck, ...g.spiceDiscard.flat()];
+ const front: SpiceCard[] = [];
+ for (const kind of kinds) {
+ const index = all.findIndex((card) =>
+ kind === 'land' ? 'territory' in card : 'worm' in card,
+ );
+ assert.ok(index >= 0);
+ front.push(all.splice(index, 1)[0]);
+ }
+ g.spiceDeck = [...front, ...all];
+ g.spiceDiscard = [[], []];
+}
+export function finishNexusSpice(state: Game): Game {
+ let g = state;
+ for (
+ let i = 0;
+ g.phase === 1 && g.nexusCards?.phase?.stage === 'spice' && i < 30;
+ i++
+ ) {
+ if (g.response) g = nexusAllow(g);
+ else if (g.decision?.kind === 'wormRide')
+ g = applyAction(g, g.decision.player, {
+ type: 'decision',
+ accept: false,
+ });
+ else {
+ assert.equal(g.decision, null);
+ g = nexusReady(g);
+ }
+ }
+ assert.notEqual(g.nexusCards?.phase?.stage, 'spice');
+ return g;
+}
+export function nexusTurnTwo(
+ options: {
+ seatIds?: [string, string, string];
+ advanced?: boolean;
+ hostFaction?: 'fremen' | 'moritani' | 'ecaz';
+ } = {},
+): Game {
+ const first = enterNexusSpice(nexusFixture(options));
+ orderNexusSpice(first, ['land', 'land']);
+ return enterNexusSpice(finishNexusSpice(first), true);
+}
+export function nexusInventory(g: Game): void {
+ assert.ok(g.nexusCards?.cards);
+ validateNexusCards(g.nexusCards.cards, g.players);
+ assert.deepEqual(
+ [
+ ...g.nexusCards.cards.deck,
+ ...g.nexusCards.cards.discard,
+ ...Object.values(g.nexusCards.cards.hands).filter(
+ (card) => card !== null,
+ ),
+ ].sort(),
+ [...NEXUS_FACTIONS].sort(),
+ );
+ assert.deepEqual(
+ [...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)]
+ .map((c) => c.id)
+ .sort(),
+ baseDeck()
+ .map((c) => c.id)
+ .sort(),
+ );
+ for (const p of g.players)
+ assert.equal(
+ p.reserves +
+ p.tanks +
+ Object.values(p.forces).reduce((sum, n) => sum + n, 0),
+ 20,
+ );
+}
+export function nexusReject(
+ g: Game,
+ id: string,
+ action: Action,
+ pattern: RegExp = /Nexus/,
+): void {
+ const before = nexusReload(g);
+ assert.throws(() => applyAction(g, id, action), pattern);
+ assert.deepEqual(g, before);
+}
diff --git a/tests/nexus-card-controls.test.ts b/tests/nexus-card-controls.test.ts
new file mode 100644
index 00000000..4e74ff04
--- /dev/null
+++ b/tests/nexus-card-controls.test.ts
@@ -0,0 +1,158 @@
+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 { FACTIONS, type FactionId } from '../game/catalog';
+import { createGame, joinGame, newPlayer, viewGame, type GameView } from '../game/engine';
+import { NEXUS_CARD_REFERENCE, NEXUS_PANEL_NAMES, nexusCardReference } from '../game/nexus-card-reference';
+
+// Match Vinext's actual next/image alias in this isolated Node test process;
+// render the production image implementation, not a component mock.
+const imageAlias = registerHooks({ resolve(specifier, context, nextResolve) {
+ return nextResolve(specifier === 'next/image' ? 'vinext/shims/image' : specifier, context);
+} });
+const { NexusCardFace, NexusCardGallery, NexusCards, nexusCardChoiceAction } = await import('../components/nexus-cards');
+imageAlias.deregister();
+
+/** Only public seats and the server's private owner projection feed the UI. */
+function fixture(card: FactionId | null = null): GameView {
+ const g = createGame('NEXUSCONTROLS', newPlayer('a', 'Atreides', 'atreides'));
+ joinGame(g, newPlayer('h', 'Harkonnen', 'harkonnen'));
+ joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
+ Object.assign(g, { status: 'playing', phase: 1, turn: 2, active: 'e' });
+ const view = viewGame(g, 'a');
+ view.nexusCards = { card, deckCount: card ? 9 : 10, discardCount: 0,
+ held: { a: !!card, h: true, e: true }, turn: 2,
+ choices: card ? ['keep', 'replace'] : ['keep', 'draw'], waiting: ['a', 'h'] };
+ return view;
+}
+function markup(game: GameView, busy = false): string {
+ return renderToStaticMarkup(createElement(NexusCards, { game, busy, act() {} }));
+}
+
+void test('the original inventory renders twelve identities, all36 readable panels and inspect controls', () => {
+ assert.equal(NEXUS_CARD_REFERENCE.length, 12);
+ assert.deepEqual(NEXUS_CARD_REFERENCE.map((card) => card.faction).sort(), FACTIONS.map((faction) => faction.id).sort());
+ const html = renderToStaticMarkup(createElement(NexusCardGallery));
+ for (const mode of ['betrayal', 'cunning', 'secretAlly'] as const)
+ assert.equal(html.split(`aria-label="${NEXUS_PANEL_NAMES[mode]}"`).length - 1, 12);
+ assert.equal((html.match(/aria-label="Inspect [^"]+ Nexus card"/g) ?? []).length, 12);
+ for (const card of NEXUS_CARD_REFERENCE) {
+ for (const mode of ['betrayal', 'cunning', 'secretAlly'] as const) {
+ assert.ok(card[mode].length > 30);
+ assert.ok(html.includes(card[mode]));
+ }
+ }
+});
+
+void test('reference preserves materially different physical effects and original numerical limits', () => {
+ assert.match(nexusCardReference('guild').betrayal, /whole shipment payment.*own payment.*Junction/);
+ assert.match(nexusCardReference('guild').cunning, /second shipment.*cannot move unless.*Hajr/);
+ assert.match(nexusCardReference('richese').cunning, /two No-Field.*one force.*reveal one/);
+ assert.match(nexusCardReference('tleilaxu').secretAlly, /leader.*free.*five.*one spice each/);
+ assert.match(nexusCardReference('emperor').secretAlly, /three extra forces free beyond/);
+ assert.match(nexusCardReference('ecaz').cunning, /capture.*Tanks.*Ghola.*Moritani.*end of the turn/);
+ assert.match(nexusCardReference('moritani').betrayal, /Return.*without revealing/);
+ assert.match(nexusCardReference('harkonnen').betrayal, /replacement during Mentat Pause/);
+ assert.match(nexusCardReference('choam').secretAlly, /not used in that battle/);
+ assert.match(nexusCardReference('ixians').betrayal, /cannot prevent both/);
+ assert.doesNotMatch(JSON.stringify(NEXUS_CARD_REFERENCE), /INTERNAL AUDIT|ALTERNATE SUPPLY CHAIN|entire Battle Plan/);
+});
+
+void test('draw, replacement and keep actions bind the exact offered turn and card without mutation', () => {
+ for (const card of [null, 'fremen'] as const) {
+ const view = fixture(card);
+ const before = structuredClone(view);
+ for (const choice of view.nexusCards!.choices)
+ assert.deepEqual(nexusCardChoiceAction(view, choice), { type: 'nexusCardChoice', turn: 2, card, choice, ownRedraws: 0 });
+ assert.equal(nexusCardChoiceAction(view, card ? 'draw' : 'replace'), null);
+ assert.deepEqual(view, before);
+ }
+});
+
+void test('private pre-draw policy applies in Basic and Advanced without a revealing post-draw prompt', () => {
+ for (const advanced of [false, true]) {
+ for (const card of [null, 'atreides', 'fremen'] as const) {
+ const view = fixture(card);
+ view.advanced = advanced;
+ const choice = card ? 'replace' : 'draw';
+ for (const ownRedraws of [0, 1, 2] as const)
+ assert.deepEqual(nexusCardChoiceAction(view, choice, ownRedraws), { type: 'nexusCardChoice', turn: 2, card, choice, ownRedraws });
+ assert.equal(nexusCardChoiceAction(view, choice, 3 as never), null);
+ assert.equal(nexusCardChoiceAction(view, 'keep', 2)!.ownRedraws, 0);
+ assert.match(markup(view), /aria-label="If I draw my faction"/);
+ assert.match(markup(view), /Basic or Advanced play/);
+ assert.match(markup(view), /Redraw whenever it appears/);
+ view.nexusCards!.choices = [];
+ view.nexusCards!.waiting = ['h'];
+ assert.doesNotMatch(markup(view), /If I draw my faction|You drew your own faction|Redraw my faction card/);
+ }
+ }
+});
+
+void test('settled, stale, nonplaying and nonowner opportunities cannot generate an action', () => {
+ for (const edit of [
+ (view: GameView) => { view.nexusCards!.waiting = ['h']; },
+ (view: GameView) => { view.nexusCards!.choices = []; },
+ (view: GameView) => { view.nexusCards!.turn = 1; },
+ (view: GameView) => { view.nexusCards!.turn = null; },
+ (view: GameView) => { view.phase = 2; },
+ (view: GameView) => { view.status = 'finished'; },
+ (view: GameView) => { view.me = 'observer'; },
+ ]) {
+ const view = fixture();
+ edit(view);
+ assert.equal(nexusCardChoiceAction(view, 'draw'), null);
+ assert.equal(nexusCardChoiceAction(view, 'keep'), null);
+ }
+});
+
+void test('the owned face highlights its mode from the public roster and all three requirements remain readable', () => {
+ for (const [card, mode] of [['atreides', 'Cunning'], ['harkonnen', 'Betrayal'], ['fremen', 'Secret Ally']] as const) {
+ const html = markup(fixture(card));
+ const privateFace = html.slice(html.indexOf('aria-label="Your private Nexus card"'), html.indexOf(']*>Your applicable mode`));
+ assert.equal((privateFace.match(/Your applicable mode/g) ?? []).length, 1);
+ assert.match(privateFace, /player only/);
+ assert.match(privateFace, /Another player controls/);
+ assert.match(privateFace, /is not in this game/);
+ }
+});
+
+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, /10 in deck · 0 discarded · 2 held/);
+ assert.match(html, /Waiting for Nexus choices: Harkonnen/);
+ assert.match(html, /You do not hold a Nexus card/);
+ assert.match(html, /effects are not playable yet/);
+ assert.match(html, /]*disabled=""[^>]*>Draw a Nexus card/);
+ assert.match(html, /]*disabled=""[^>]*>Skip this draw/);
+ assert.doesNotMatch(html, />Play |Activate effect|Use Betrayal/);
+ const enlarged = renderToStaticMarkup(createElement(NexusCardFace, { card: 'atreides', enlarged: true }));
+ assert.match(enlarged, /text-xl! leading-8!/);
+ assert.doesNotMatch(enlarged, / {
+ const view = fixture('fremen');
+ const expected = markup(view);
+ for (const player of view.players)
+ for (const field of ['hand', 'traitors', 'spice', 'prediction'])
+ Object.defineProperty(player, field, { get() { throw new Error(`Private ${field} inspected`); } });
+ for (const field of ['deck', 'discard', 'battle', 'hands', 'noField'])
+ Object.defineProperty(view, field, { get() { throw new Error(`Source ${field} inspected`); } });
+ Object.defineProperty(view.nexusCards!, 'hands', { get() { throw new Error('Opposing held identities inspected'); } });
+ assert.equal(markup(view), expected);
+ assert.ok(nexusCardChoiceAction(view, 'replace'));
+ assert.ok(nexusCardChoiceAction(view, 'keep'));
+});
+
+void test('disabled module reads no player or card source and renders no inventory', () => {
+ const view = fixture();
+ view.nexusCards = null;
+ Object.defineProperty(view, 'players', { get() { throw new Error('Unnecessary player access'); } });
+ assert.equal(markup(view), '');
+ assert.equal(nexusCardChoiceAction(view, 'draw'), null);
+});
diff --git a/tests/nexus-card-engine.test.ts b/tests/nexus-card-engine.test.ts
new file mode 100644
index 00000000..9ce2e9dc
--- /dev/null
+++ b/tests/nexus-card-engine.test.ts
@@ -0,0 +1,372 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ applyAction,
+ normalizeAutomaticGame,
+ viewGame,
+ type Action,
+ type Game,
+} from '../game/engine';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import type { FactionId } from '../game/catalog';
+import { placeAmbassador } from '../game/ecaz-ambassadors';
+import { placeTerror } from '../game/moritani-terror';
+import {
+ enterNexusSpice,
+ finishNexusSpice,
+ nexusFixture,
+ nexusAllow,
+ nexusInventory,
+ nexusPlayer,
+ nexusReady,
+ nexusReject,
+ nexusReload,
+ nexusTurnTwo,
+ orderNexusSpice,
+} from './fixture-nexus-cards';
+
+function alliance(g: Game, one = 'f', two = 'a'): Game {
+ g = applyAction(g, one, { type: 'alliance', target: two });
+ return applyAction(g, two, { type: 'alliance', target: one });
+}
+function negotiations(g: Game): Game {
+ g = nexusReady(g);
+ assert.ok(g.spiceWindow);
+ g = nexusReady(g);
+ assert.equal(g.nexus, true);
+ assert.equal(g.spiceWindow, null);
+ return g;
+}
+function choice(g: Game, id: string, value: string): Action {
+ return {
+ type: 'nexusCardChoice',
+ turn: g.turn,
+ card: g.nexusCards!.cards!.hands[id],
+ choice: value,
+ ownRedraws: 0,
+ };
+}
+function top(g: Game, ...cards: FactionId[]): void {
+ const deck = g.nexusCards!.cards!.deck;
+ assert.ok(cards.every((card) => deck.includes(card)));
+ g.nexusCards!.cards!.deck = [
+ ...cards,
+ ...deck.filter((card) => !cards.includes(card)),
+ ];
+}
+function closing(advanced = false): Game {
+ let g = nexusTurnTwo({ advanced });
+ orderNexusSpice(g, ['worm', 'land', 'land']);
+ g = alliance(negotiations(g));
+ return finishNexusSpice(g);
+}
+
+for (const advanced of [false, true])
+ void test(`${advanced ? 'Advanced A/B' : 'Basic'} real phase draws once only after all spice passes and settled alliances`, () => {
+ let g = nexusTurnTwo({ advanced });
+ assert.equal(g.nexusCards!.cards!.deck.length, 12);
+ orderNexusSpice(g, ['worm', 'land', 'land']);
+ g = alliance(negotiations(g));
+ assert.equal(g.nexusCards!.phase!.occurred, true);
+ assert.deepEqual(viewGame(g, 'h').nexusCards!.choices, []);
+ g = nexusReady(g);
+ if (advanced) {
+ assert.equal(g.spiceSequence!.pile, 1);
+ assert.ok(g.spiceWindow);
+ assert.equal(g.nexusCards!.cards!.deck.length, 12);
+ assert.deepEqual(viewGame(g, 'h').nexusCards!.choices, []);
+ }
+ g = finishNexusSpice(g);
+ assert.equal(g.phase, 1);
+ assert.equal(g.nexusCards!.phase!.stage, 'drawing');
+ assert.equal(viewGame(g, 'h').beforeSpiceDraw, false);
+ top(g, 'ecaz');
+ const draw = choice(g, 'h', 'draw');
+ g = applyAction(nexusReload(g), 'h', draw);
+ assert.equal(g.phase, 2);
+ assert.equal(g.nexusCards!.phase!.stage, 'complete');
+ assert.equal(g.nexusCards!.cards!.hands.h, 'ecaz');
+ assert.equal(g.nexusCards!.cards!.deck.length, 11);
+ nexusReject(g, 'h', draw);
+ assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), g);
+ nexusInventory(g);
+ });
+
+void test('first-turn skipped worm, no worm, and actual Nexus without any alliance do not grant a card', () => {
+ let first = enterNexusSpice(nexusFixture());
+ orderNexusSpice(first, ['worm', 'land']);
+ first = finishNexusSpice(first);
+ assert.equal(first.nexusCards!.phase!.occurred, false);
+ assert.equal(first.phase, 2);
+ let noWorm = enterNexusSpice(first, true);
+ orderNexusSpice(noWorm, ['land']);
+ noWorm = finishNexusSpice(noWorm);
+ assert.deepEqual(noWorm.nexusCards!.phase!.eligible, []);
+ let noAlliance = enterNexusSpice(noWorm, true);
+ orderNexusSpice(noAlliance, ['worm', 'land']);
+ noAlliance = finishNexusSpice(noAlliance);
+ assert.equal(noAlliance.nexusCards!.phase!.occurred, true);
+ assert.deepEqual(noAlliance.nexusCards!.phase!.eligible, []);
+ assert.equal(noAlliance.nexusCards!.cards!.deck.length, 12);
+ nexusInventory(noAlliance);
+});
+
+void test('a real before-blow Fremen summoned Nexus resumes both Advanced piles and offers one final draw', () => {
+ let g = nexusTurnTwo({ advanced: true });
+ orderNexusSpice(g, ['land', 'land']);
+ const index = g.deck.findIndex((card) => card.effect === 'karama');
+ assert.ok(index >= 0);
+ const card = g.deck.splice(index, 1)[0];
+ nexusPlayer(g, 'f').hand.push(card);
+ const beforeSpice = nexusReload(g).spiceDeck;
+ g = nexusAllow(
+ applyAction(g, 'f', {
+ type: 'card',
+ mode: 'special',
+ card: card.id,
+ territory: 'the_great_flat',
+ }),
+ );
+ assert.equal(g.summonedBeforeBlow, true);
+ assert.equal(g.nexusCards!.phase!.occurred, true);
+ assert.deepEqual(g.spiceDeck, beforeSpice);
+ assert.equal(g.discard.filter((item) => item.id === card.id).length, 1);
+ g = alliance(negotiations(nexusReload(g)));
+ g = finishNexusSpice(g);
+ assert.equal(
+ g.spiceDiscard[0].filter((item) => 'territory' in item).length,
+ 1,
+ );
+ assert.equal(
+ g.spiceDiscard[1].filter((item) => 'territory' in item).length,
+ 1,
+ );
+ assert.equal(g.nexusCards!.phase!.stage, 'drawing');
+ top(g, 'ecaz');
+ g = applyAction(nexusReload(g), 'h', choice(g, 'h', 'draw'));
+ assert.equal(g.phase, 2);
+ assert.equal(g.nexusCards!.cards!.deck.length, 11);
+ assert.equal(g.nexusCards!.cards!.hands.h, 'ecaz');
+ nexusInventory(g);
+});
+
+void test('precommitted own-faction policy completes privately and later replacement discards once', () => {
+ const initial = closing();
+ top(initial, 'harkonnen', 'ecaz', 'moritani');
+ const keptOwn = applyAction(initial, 'h', choice(initial, 'h', 'draw'));
+ assert.equal(keptOwn.phase, 2);
+ assert.equal(keptOwn.nexusCards!.cards!.hands.h, 'harkonnen');
+ assert.deepEqual(viewGame(keptOwn, 'a').nexusCards!.waiting, []);
+ let g = applyAction(nexusReload(initial), 'h', {
+ ...choice(initial, 'h', 'draw'),
+ ownRedraws: 1,
+ });
+ assert.equal(g.phase, 2);
+ assert.equal(g.nexusCards!.cards!.hands.h, 'ecaz');
+ assert.deepEqual(g.nexusCards!.cards!.discard, ['harkonnen']);
+ assert.deepEqual(viewGame(g, 'a').nexusCards!.waiting, []);
+ nexusReject(g, 'h', choice(initial, 'h', 'draw'));
+ const repeatedPolicy = applyAction(initial, 'h', {
+ ...choice(initial, 'h', 'draw'),
+ ownRedraws: 2,
+ });
+ assert.deepEqual(repeatedPolicy.nexusCards, g.nexusCards);
+ g = enterNexusSpice(g, true);
+ orderNexusSpice(g, ['worm', 'land']);
+ g = finishNexusSpice(g);
+ assert.deepEqual(viewGame(g, 'h').nexusCards!.choices, ['keep', 'replace']);
+ const replace = choice(g, 'h', 'replace');
+ g = applyAction(nexusReload(g), 'h', replace);
+ assert.equal(g.nexusCards!.cards!.hands.h, 'moritani');
+ assert.deepEqual(g.nexusCards!.cards!.discard, ['harkonnen', 'ecaz']);
+ nexusReject(g, 'h', replace);
+ nexusInventory(g);
+});
+
+void test('closing choices fence stale owners/cards/turns and every other action; all four bots emit a legal choice', () => {
+ const g = closing();
+ for (const action of [
+ { type: 'ready' },
+ { type: 'alliance', target: 'f' },
+ { type: 'card', card: 'invented' },
+ { type: 'ship', amount: 1 },
+ { type: 'decision', decline: true },
+ { ...choice(g, 'h', 'draw'), turn: g.turn - 1 },
+ { ...choice(g, 'h', 'draw'), card: 'atreides' },
+ { ...choice(g, 'h', 'draw'), injected: true },
+ ])
+ nexusReject(g, 'h', action);
+ nexusReject(g, 'f', choice(g, 'f', 'draw'));
+ for (const difficulty of DIFFICULTIES) {
+ const view = viewGame(g, 'h');
+ view.players.find((p) => p.id === 'h')!.bot = difficulty;
+ const action = botActions(view)[0];
+ assert.equal(action?.type, 'nexusCardChoice');
+ assert.doesNotThrow(() => applyAction(g, 'h', action!));
+ }
+ const kept = applyAction(g, 'h', choice(g, 'h', 'keep'));
+ assert.equal(kept.phase, 2);
+ assert.equal(kept.nexusCards!.cards!.deck.length, 12);
+});
+
+void test('private card projection exposes only own identity; a real reciprocal Nexus join forfeits held card exactly once', () => {
+ let g = closing();
+ top(g, 'ecaz');
+ g = applyAction(g, 'h', choice(g, 'h', 'draw'));
+ for (const id of ['f', 'a', 'h']) {
+ const projected = viewGame(nexusReload(g), id).nexusCards!;
+ assert.equal(projected.card, id === 'h' ? 'ecaz' : null);
+ assert.deepEqual(projected.held, { f: false, a: false, h: true });
+ assert.deepEqual(Object.keys(projected).sort(), [
+ 'card',
+ 'choices',
+ 'deckCount',
+ 'discardCount',
+ 'held',
+ 'turn',
+ 'waiting',
+ ]);
+ if (id !== 'h')
+ assert.equal(JSON.stringify(projected).includes('ecaz'), false);
+ }
+ assert.equal(JSON.stringify(g.log).includes('ecaz'), false);
+ g = enterNexusSpice(g, true);
+ orderNexusSpice(g, ['worm', 'land']);
+ g = negotiations(g);
+ g = applyAction(g, 'a', { type: 'alliance' });
+ g = applyAction(g, 'h', { type: 'alliance', target: 'a' });
+ assert.equal(g.nexusCards!.cards!.hands.h, 'ecaz', 'offer is not acceptance');
+ g = applyAction(nexusReload(g), 'a', { type: 'alliance', target: 'h' });
+ assert.equal(g.nexusCards!.cards!.hands.h, null);
+ assert.deepEqual(g.nexusCards!.cards!.discard, ['ecaz']);
+ assert.equal(nexusPlayer(g, 'h').ally, 'a');
+ nexusInventory(g);
+});
+
+void test('malformed inventory and closing state reject all views, normalization and action without repair', () => {
+ const g = closing();
+ const corruptions: ((g: Game) => void)[] = [
+ (x) => {
+ x.nexusCards!.cards!.deck.pop();
+ },
+ (x) => {
+ x.nexusCards!.cards!.hands.h = x.nexusCards!.cards!.deck[0];
+ },
+ (x) => {
+ x.nexusCards!.phase!.done = ['f'];
+ },
+ (x) => {
+ x.nexusCards!.phase!.occurred = false;
+ },
+ (x) => {
+ x.nexusCards!.phase!.turn--;
+ },
+ (x) => {
+ x.nexusCards!.phase = null;
+ },
+ ];
+ for (const corrupt of corruptions) {
+ const bad = nexusReload(g);
+ corrupt(bad);
+ const before = nexusReload(bad);
+ for (const p of bad.players)
+ assert.throws(() => viewGame(bad, p.id), /Nexus/);
+ assert.throws(() => normalizeAutomaticGame(bad), /Nexus/);
+ assert.throws(() => applyAction(bad, 'h', choice(g, 'h', 'draw')), /Nexus/);
+ assert.deepEqual(bad, before);
+ }
+});
+
+for (const hostFaction of ['moritani', 'ecaz'] as const)
+ void test(`${hostFaction} actual accepted entry alliance forfeits a genuinely drawn card, but declaration and refusal retain it`, () => {
+ // This explicit expansion roster enters the same genuine audit setup before
+ // any Nexus custody exists. Base Treachery inventory remains unchanged.
+ let g = nexusTurnTwo({ hostFaction });
+ orderNexusSpice(g, ['worm', 'land']);
+ g = alliance(negotiations(g), 'a', 'h');
+ g = finishNexusSpice(g);
+ top(g, 'richese');
+ g = applyAction(g, 'f', choice(g, 'f', 'draw'));
+ // Stage a later unused shipment, retaining the completed real phase receipt.
+ // The entrant first ends its old alliance in the next genuine Nexus.
+ g = enterNexusSpice(g, true);
+ orderNexusSpice(g, ['worm', 'land']);
+ g = negotiations(g);
+ g = applyAction(g, 'a', { type: 'alliance' });
+ g = finishNexusSpice(g);
+ Object.assign(g, {
+ phase: 5,
+ active: 'a',
+ order: ['a', 'f', 'h'],
+ movementRemaining: ['a', 'f', 'h'],
+ ready: [],
+ storm: 18,
+ });
+ for (const p of g.players) {
+ p.reserves += Object.values(p.forces).reduce((sum, n) => sum + n, 0);
+ p.forces = {};
+ Object.assign(p, { spice: 20, shipped: false, moved: 0 });
+ }
+ if (hostFaction === 'moritani') {
+ const token = g.moritaniTerror!.tokens.find((t) => t.kind === 'robbery')!;
+ g.moritaniTerror = placeTerror(
+ g.moritaniTerror!,
+ token.id,
+ 'arrakeen',
+ g.turn,
+ );
+ } else {
+ const token = g.ecazAmbassadors!.tokens.find((t) => t.effect === 'ecaz')!;
+ g.ecazAmbassadors = placeAmbassador(g.ecazAmbassadors!, token.id, {
+ turn: g.turn,
+ availableSpice: 20,
+ destination: {
+ id: 'arrakeen',
+ stronghold: true,
+ inStorm: false,
+ allowed: true,
+ },
+ }).state;
+ }
+ g = applyAction(g, 'a', {
+ type: 'ship',
+ amount: 2,
+ territory: 'arrakeen',
+ sector: 10,
+ });
+ const declare: Action =
+ hostFaction === 'moritani'
+ ? { type: 'decision', alliance: true }
+ : {
+ type: 'decision',
+ event: g.pendingAmbassador!.event,
+ trigger: true,
+ choice: 'alliance',
+ beneficiary: 'f',
+ };
+ g = applyAction(g, 'f', declare);
+ assert.equal(g.nexusCards!.cards!.hands.f, 'richese');
+ assert.equal(
+ g.response,
+ null,
+ 'empty Treachery hands need no Karama response',
+ );
+ const reply: Action =
+ hostFaction === 'moritani'
+ ? { type: 'decision', accept: true }
+ : { type: 'decision', event: g.pendingAmbassador!.event, accept: true };
+ const refused = applyAction(nexusReload(g), 'a', {
+ ...reply,
+ accept: false,
+ });
+ assert.equal(refused.nexusCards!.cards!.hands.f, 'richese');
+ const accepted = applyAction(nexusReload(g), 'a', reply);
+ assert.equal(accepted.nexusCards!.cards!.hands.f, null);
+ assert.deepEqual(accepted.nexusCards!.cards!.discard, ['richese']);
+ assert.equal(nexusPlayer(accepted, 'f').ally, 'a');
+ assert.equal(nexusPlayer(accepted, 'a').shipped, true);
+ assert.equal(accepted.pendingTerrorEntry ?? null, null);
+ assert.equal(accepted.pendingAmbassador ?? null, null);
+ nexusInventory(accepted);
+ });
diff --git a/tests/nexus-card-phase.test.ts b/tests/nexus-card-phase.test.ts
new file mode 100644
index 00000000..230c6e22
--- /dev/null
+++ b/tests/nexus-card-phase.test.ts
@@ -0,0 +1,238 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ createNexusCardPhase,
+ markNexusCardOccurred,
+ closeNexusCardPhase,
+ finishNexusCardChoice,
+ nexusCardChoices,
+ validateNexusCardPhase,
+ type NexusCardPhase,
+ type NexusCardChoice,
+} from '../game/nexus-card-phase';
+import {
+ createNexusCards,
+ drawNexusCard,
+ replaceNexusCard,
+ projectNexusCards,
+ validateNexusPlayers,
+ type NexusPlayer,
+ type NexusState,
+} from '../game/nexus-cards';
+
+const players: NexusPlayer[] = [
+ { id: 'a', faction: 'atreides', ally: null },
+ { id: 'g', faction: 'guild', ally: 'h' },
+ { id: 'h', faction: 'harkonnen', ally: 'g' },
+ { id: 'e', faction: 'emperor', ally: null },
+];
+const cards = () => createNexusCards(players, () => 0.999);
+const open = () =>
+ closeNexusCardPhase(markNexusCardOccurred(createNexusCardPhase(3)), players);
+const reload = (value: T): T => JSON.parse(JSON.stringify(value)) as T;
+
+void test('only an actual Nexus and a settled alliance open the closing opportunity', () => {
+ const initial = createNexusCardPhase(3);
+ assert.deepEqual(nexusCardChoices(initial, cards(), 'a'), []);
+ assert.equal(closeNexusCardPhase(initial, players).stage, 'complete');
+ const unallied = players.map((p) => ({ ...p, ally: null }));
+ assert.equal(
+ closeNexusCardPhase(markNexusCardOccurred(initial), unallied).stage,
+ 'complete',
+ );
+ const marked = markNexusCardOccurred(initial);
+ assert.equal(initial.occurred, false);
+ assert.deepEqual(markNexusCardOccurred(marked), marked);
+ const phase = closeNexusCardPhase(marked, players);
+ assert.deepEqual(phase.eligible, ['a', 'e']);
+ assert.equal(phase.stage, 'drawing');
+ assert.deepEqual(nexusCardChoices(phase, cards(), 'g'), []);
+ assert.throws(() => closeNexusCardPhase(phase, players));
+ assert.throws(() => markNexusCardOccurred(phase));
+});
+
+void test('each eligible owner may finish once in either order and the whole phase closes once', () => {
+ const initial = open();
+ const state = cards();
+ const first = finishNexusCardChoice(initial, players, state, 'e', 'keep');
+ assert.deepEqual(first.done, ['e']);
+ assert.equal(first.stage, 'drawing');
+ assert.deepEqual(nexusCardChoices(first, state, 'e'), []);
+ const drawn = drawNexusCard(state, 'a', players, () => 0);
+ const complete = finishNexusCardChoice(
+ reload(first),
+ players,
+ reload(drawn),
+ 'a',
+ 'draw',
+ );
+ assert.equal(complete.stage, 'complete');
+ assert.deepEqual(initial.done, []);
+ validateNexusCardPhase(reload(complete), players, drawn);
+ assert.throws(() =>
+ finishNexusCardChoice(complete, players, drawn, 'a', 'keep'),
+ );
+ assert.throws(() => closeNexusCardPhase(complete, players));
+ assert.throws(() => markNexusCardOccurred(complete));
+});
+
+void test('keeping empty or held hands is legal; replacement requires the physical transaction first', () => {
+ const initial = open();
+ const state = cards();
+ assert.deepEqual(nexusCardChoices(initial, state, 'a'), ['keep', 'draw']);
+ assert.throws(() =>
+ finishNexusCardChoice(initial, players, state, 'a', 'draw'),
+ );
+ const held = drawNexusCard(state, 'a', players, () => 0);
+ assert.deepEqual(nexusCardChoices(initial, held, 'a'), ['keep', 'replace']);
+ const replaced = replaceNexusCard(held, 'a', players, () => 0);
+ const done = finishNexusCardChoice(
+ initial,
+ players,
+ replaced,
+ 'a',
+ 'replace',
+ );
+ assert.deepEqual(done.done, ['a']);
+ assert.equal(held.hands.a, 'atreides');
+ assert.equal(replaced.hands.a, 'harkonnen');
+ assert.deepEqual(replaced.discard, ['atreides']);
+ assert.deepEqual(
+ finishNexusCardChoice(initial, players, held, 'a', 'keep').done,
+ ['a'],
+ );
+});
+
+void test('own-faction and other draws have identical public progress and observer projections', () => {
+ const phase = open();
+ const ownDeck = cards();
+ const otherDeck = reload(ownDeck);
+ [otherDeck.deck[0], otherDeck.deck[1]] = [
+ otherDeck.deck[1],
+ otherDeck.deck[0],
+ ];
+ const own = drawNexusCard(ownDeck, 'a', players, () => 0);
+ const other = drawNexusCard(otherDeck, 'a', players, () => 0);
+ assert.notEqual(own.hands.a, other.hands.a);
+ const ownDone = finishNexusCardChoice(phase, players, own, 'a', 'draw');
+ const otherDone = finishNexusCardChoice(phase, players, other, 'a', 'draw');
+ assert.deepEqual(ownDone, otherDone);
+ assert.deepEqual(
+ projectNexusCards(own, 'e', players),
+ projectNexusCards(other, 'e', players),
+ );
+ assert.deepEqual(nexusCardChoices(ownDone, own, 'a'), []);
+});
+
+void test('a recycled own card may repeat without creating a second phase opportunity', () => {
+ const phase = open();
+ const held = drawNexusCard(cards(), 'a', players, () => 0);
+ const exhausted: NexusState = { ...held, deck: [], discard: [...held.deck] };
+ let calls = 0;
+ const same = replaceNexusCard(exhausted, 'a', players, () =>
+ calls++ === 0 ? 0 : 0.999,
+ );
+ assert.equal(same.hands.a, 'atreides');
+ const different = replaceNexusCard(same, 'a', players, () => {
+ throw new Error('No shuffle needed');
+ });
+ assert.notEqual(different.hands.a, 'atreides');
+ const done = finishNexusCardChoice(phase, players, different, 'a', 'replace');
+ assert.deepEqual(done.done, ['a']);
+ assert.throws(() =>
+ finishNexusCardChoice(done, players, different, 'a', 'replace'),
+ );
+});
+
+void test('saved progress detects rewinds, altered recipients and unknown fields without mutation', () => {
+ const state = cards();
+ let complete = finishNexusCardChoice(open(), players, state, 'a', 'keep');
+ complete = finishNexusCardChoice(complete, players, state, 'e', 'keep');
+ const mutations: Array<(phase: NexusCardPhase) => void> = [
+ (p) => {
+ p.stage = 'drawing';
+ p.done = [];
+ },
+ (p) => {
+ p.turn++;
+ },
+ (p) => {
+ p.occurred = false;
+ },
+ (p) => {
+ p.eligible.reverse();
+ },
+ (p) => {
+ p.done.reverse();
+ },
+ (p) => {
+ p.eligible.push('outsider');
+ },
+ (p) => {
+ p.signature = '';
+ },
+ (p) => {
+ Object.assign(p, { redraw: 'a' });
+ },
+ ];
+ for (const mutate of mutations) {
+ const corrupt = reload(complete);
+ mutate(corrupt);
+ const before = JSON.stringify(corrupt);
+ assert.throws(() => validateNexusCardPhase(corrupt, players, state));
+ assert.throws(() => nexusCardChoices(corrupt, state, 'a'));
+ assert.throws(() =>
+ finishNexusCardChoice(corrupt, players, state, 'a', 'keep'),
+ );
+ assert.equal(JSON.stringify(corrupt), before);
+ }
+});
+
+void test('unknown choices, premature finishes and foreign owners reject unchanged', () => {
+ const phase = open();
+ const state = cards();
+ const before = JSON.stringify({ phase, state });
+ for (const choice of ['redraw', 'skip', '', null, 1])
+ assert.throws(() =>
+ finishNexusCardChoice(
+ phase,
+ players,
+ state,
+ 'a',
+ choice as NexusCardChoice,
+ ),
+ );
+ for (const owner of ['g', 'outsider'])
+ assert.throws(() =>
+ finishNexusCardChoice(phase, players, state, owner, 'keep'),
+ );
+ assert.throws(() =>
+ finishNexusCardChoice(createNexusCardPhase(3), players, state, 'a', 'keep'),
+ );
+ assert.equal(JSON.stringify({ phase, state }), before);
+});
+
+void test('one-way, missing and overlapping alliance partners cannot qualify a Nexus window', () => {
+ const state = cards();
+ for (const seats of [
+ players.map((p) => (p.id === 'h' ? { ...p, ally: null } : p)),
+ players.map((p) => (p.id === 'g' ? { ...p, ally: 'outsider' } : p)),
+ players.map((p) => (p.id === 'a' ? { ...p, ally: 'g' } : p)),
+ ]) {
+ const before = JSON.stringify(seats);
+ assert.throws(() => validateNexusPlayers(seats));
+ assert.throws(() =>
+ closeNexusCardPhase(
+ markNexusCardOccurred(createNexusCardPhase(3)),
+ seats,
+ ),
+ );
+ assert.throws(() => validateNexusCardPhase(open(), seats, state));
+ assert.equal(JSON.stringify(seats), before);
+ }
+ const changed = players.map((p) => ({ ...p, ally: null }));
+ assert.throws(() => validateNexusCardPhase(open(), changed, state));
+ let complete = finishNexusCardChoice(open(), players, state, 'a', 'keep');
+ complete = finishNexusCardChoice(complete, players, state, 'e', 'keep');
+ validateNexusCardPhase(complete, changed, state);
+});
diff --git a/tests/nexus-card-recovery.test.ts b/tests/nexus-card-recovery.test.ts
new file mode 100644
index 00000000..e72b5056
--- /dev/null
+++ b/tests/nexus-card-recovery.test.ts
@@ -0,0 +1,203 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createHash, webcrypto } from 'node:crypto';
+import { readFileSync, readdirSync } from 'node:fs';
+import { DatabaseSync } from 'node:sqlite';
+import { runInNewContext } from 'node:vm';
+import ts from 'typescript';
+import * as engine from '../game/engine';
+import * as bots from '../game/bots';
+import type * as Rooms from '../db/rooms';
+import { nexusTurnTwo, nexusReady, orderNexusSpice, finishNexusSpice, nexusInventory } from './fixture-nexus-cards';
+
+/** Execute the production room module and SQL, with a hook immediately before its CAS. */
+function unitStore(runBots = bots.runBots) {
+ const sqlite = new DatabaseSync(':memory:');
+ const hooks: { beforeWrite?: () => Promise } = {};
+ const writes: { expected: number; changes: number }[] = [];
+ for (const file of readdirSync(new URL('../drizzle/', import.meta.url))
+ .filter((f) => f.endsWith('.sql'))
+ .sort())
+ sqlite.exec(
+ readFileSync(new URL('../drizzle/' + file, import.meta.url), 'utf8'),
+ );
+ class Statement {
+ values: (string | number | null)[] = [];
+ constructor(readonly sql: string) {}
+ bind(...values: (string | number | null)[]) {
+ this.values = values;
+ return this;
+ }
+ async first() {
+ return sqlite.prepare(this.sql).get(...this.values) ?? null;
+ }
+ async run() {
+ const continuation = this.sql.startsWith('UPDATE rooms SET state');
+ if (continuation) await hooks.beforeWrite?.();
+ const changes = Number(
+ sqlite.prepare(this.sql).run(...this.values).changes,
+ );
+ if (continuation)
+ writes.push({ expected: Number(this.values[3]), changes });
+ return { meta: { changes } };
+ }
+ }
+ const database = {
+ prepare: (sql: string) => new Statement(sql),
+ batch: async (statements: Statement[]) => {
+ sqlite.exec('BEGIN');
+ try {
+ const results = [];
+ for (const statement of statements) results.push(await statement.run());
+ sqlite.exec('COMMIT');
+ return results;
+ } catch (error) {
+ sqlite.exec('ROLLBACK');
+ throw error;
+ }
+ },
+ };
+ function loadRooms() {
+ const exports = {};
+ runInNewContext(
+ ts.transpileModule(
+ readFileSync(new URL('../db/rooms.ts', import.meta.url), 'utf8'),
+ {
+ compilerOptions: {
+ module: ts.ModuleKind.CommonJS,
+ target: ts.ScriptTarget.ES2022,
+ },
+ },
+ ).outputText,
+ {
+ exports,
+ crypto: webcrypto,
+ TextEncoder,
+ structuredClone,
+ // Keep persisted JSON values in the test realm for strict structural assertions.
+ JSON,
+ require: (name: string) => {
+ if (name === 'cloudflare:workers') return { env: { DB: database } };
+ if (name === '@/game/engine') return engine;
+ if (name === '@/game/bots') return { ...bots, runBots };
+ throw new Error('Unexpected module ' + name);
+ },
+ },
+ );
+ return exports as typeof Rooms;
+ }
+ return { rooms: loadRooms(), restart: loadRooms, sqlite, hooks, writes };
+}
+
+
+const clock: Rooms.RoomsClock = { now: () => 10000, sleep: async () => {} };
+async function persisted() {
+ const store = unitStore();
+ const made = await store.rooms.createRoom('Nexus SQL QA', 'fremen', false, []);
+ const code = made.view.code;
+ const credentials = [made.token];
+ for (const faction of ['atreides', 'harkonnen'] as const)
+ credentials.push((await store.rooms.joinRoom(code, faction, faction)).token!);
+ const auths = await Promise.all(credentials.map(token => store.rooms.authenticate(code, token)));
+ const ids = auths.map(auth => auth.playerId) as [string,string,string];
+ let initial = nexusTurnTwo({seatIds:ids});
+ orderNexusSpice(initial, ['worm','land']);
+ initial = nexusReady(nexusReady(initial));
+ initial = engine.applyAction(initial, ids[0], {type:'alliance',target:ids[1]});
+ initial = engine.applyAction(initial, ids[1], {type:'alliance',target:ids[0]});
+ initial = finishNexusSpice(initial);
+ initial.code = code;
+ initial.version = (await store.rooms.readRoom(code)).version;
+ const cards = initial.nexusCards!.cards!;
+ cards.deck = ['harkonnen','ecaz',...cards.deck.filter(card => !['harkonnen','ecaz'].includes(card))];
+ const save = (state: engine.Game) => store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
+ .run(JSON.stringify(state),state.version,code);
+ save(initial);
+ const other = await store.rooms.createRoom('Untouched Nexus room','fremen',false,[]);
+ const otherBefore = await store.rooms.readRoom(other.view.code);
+ store.writes.length = 0;
+ return {...store,code,credentials,auths,ids,initial,save,otherCode:other.view.code,otherBefore};
+}
+type Fixture = Awaited>;
+function snapshot(f: Fixture) {
+ const row = f.sqlite.prepare('SELECT state,version FROM rooms WHERE code = ?').get(f.code)!;
+ return {version:row.version,hash:createHash('sha256').update(String(row.state)).digest('hex')};
+}
+function draw(f:Fixture, ownRedraws=0):engine.Action {
+ return {type:'nexusCardChoice',turn:f.initial.turn,card:null,choice:'draw',ownRedraws};
+}
+async function restored(f:Fixture,g:engine.Game) {
+ const before=snapshot(f), rooms=f.restart();
+ for (const [i,token] of f.credentials.entries()) {
+ const auth=await rooms.authenticate(f.code,token);
+ const view=await rooms.readSeatView(f.code,auth);
+ assert.deepEqual(view,engine.viewGame(g,f.ids[i]));
+ const offer=view.nexusCards!;
+ assert.equal(offer.card,g.nexusCards!.cards!.hands[f.ids[i]]);
+ assert.equal(Object.hasOwn(offer,'deck'),false);
+ assert.equal(Object.hasOwn(offer,'discard'),false);
+ assert.equal(Object.hasOwn(offer,'hands'),false);
+ assert.equal(JSON.stringify(offer).includes('signature'),false);
+ if(i!==2) assert.equal(offer.card,null);
+ }
+ assert.deepEqual(snapshot(f),before);
+ assert.deepEqual(await rooms.readRoom(f.otherCode),f.otherBefore);
+ nexusInventory(g);
+}
+void test('Nexus SQL concurrent keep-own and redraw-own policies commit one physical result and one phase advance',async()=>{
+ const f=await persisted();
+ try {
+ let arrived=0; let release!:()=>void;
+ const waiting=new Promise(resolve=>{release=resolve;});
+ f.hooks.beforeWrite=async()=>{if(++arrived===2)release();await waiting;};
+ const outcomes=await Promise.allSettled([
+ f.rooms.act(f.code,f.auths[2],f.initial.version,draw(f,0),clock),
+ f.restart().act(f.code,f.auths[2],f.initial.version,draw(f,2),clock),
+ ]);
+ delete f.hooks.beforeWrite;
+ assert.equal(outcomes.filter(result=>result.status==='fulfilled').length,1);
+ assert.deepEqual(f.writes.map(write=>write.changes).sort((a,b)=>a-b),[0,1]);
+ const g=await f.rooms.readRoom(f.code);
+ assert.equal(g.version,f.initial.version+1);
+ assert.equal(g.phase,2);
+ assert.ok(['harkonnen','ecaz'].includes(g.nexusCards!.cards!.hands[f.ids[2]]!));
+ assert.equal(g.nexusCards!.phase!.stage,'complete');
+ const before=snapshot(f);
+ await assert.rejects(f.rooms.act(f.code,f.auths[2],f.initial.version,draw(f),clock));
+ await assert.rejects(f.rooms.act(f.code,f.auths[2],g.version,draw(f),clock),/Nexus/);
+ assert.deepEqual(snapshot(f),before);
+ await restored(f,g);
+ } finally {f.sqlite.close();}
+});
+void test('Nexus SQL lost draw response restores the exact private card without reroll or additional writes',async()=>{
+ const f=await persisted();
+ try {
+ await f.rooms.act(f.code,f.auths[2],f.initial.version,draw(f,2),clock);
+ const g=await f.restart().readRoom(f.code);
+ assert.equal(g.nexusCards!.cards!.hands[f.ids[2]],'ecaz');
+ assert.deepEqual(g.nexusCards!.cards!.discard,['harkonnen']);
+ const before=snapshot(f);f.writes.length=0;
+ await restored(f,g);
+ await f.restart().continueRoomAutomatic(f.code,clock);
+ assert.deepEqual(snapshot(f),before);assert.equal(f.writes.length,0);
+ for(const entry of g.log.filter(entry=>entry.text.includes('Nexus Card')))
+ assert.doesNotMatch(entry.text,/Ecaz|Harkonnen.*card identity/);
+ } finally {f.sqlite.close();}
+});
+void test('Nexus SQL invalid inventory and edited closing progress reject before storage or private projection',async()=>{
+ const f=await persisted();
+ try {
+ for(const corrupt of [
+ (g:engine.Game)=>{g.nexusCards!.cards!.deck.pop();},
+ (g:engine.Game)=>{g.nexusCards!.phase!.done.push(f.ids[2]);},
+ (g:engine.Game)=>{g.nexusCards!.phase!.occurred=false;},
+ (g:engine.Game)=>{g.nexusCards!.phase=null;},
+ ]) {
+ const g=structuredClone(f.initial);corrupt(g);f.save(g);
+ const before=snapshot(f);f.writes.length=0;
+ await assert.rejects(f.rooms.readSeatView(f.code,f.auths[2]),/Nexus/);
+ await assert.rejects(f.rooms.act(f.code,f.auths[2],g.version,draw(f),clock),/Nexus/);
+ assert.deepEqual(snapshot(f),before);assert.equal(f.writes.length,0);
+ }
+ } finally {f.sqlite.close();}
+});
diff --git a/tests/nexus-cards.test.ts b/tests/nexus-cards.test.ts
new file mode 100644
index 00000000..a99e6c5f
--- /dev/null
+++ b/tests/nexus-cards.test.ts
@@ -0,0 +1,305 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ NEXUS_FACTIONS,
+ createNexusCards,
+ validateNexusCards,
+ drawNexusCard,
+ replaceNexusCard,
+ discardNexusCard,
+ nexusCardMode,
+ projectNexusCards,
+ type NexusState,
+ type NexusPlayer,
+} from '../game/nexus-cards';
+import type { FactionId } from '../game/catalog';
+
+const players: NexusPlayer[] = [
+ { id: 'a', faction: 'atreides', ally: null },
+ { id: 'g', faction: 'guild', ally: null },
+];
+const ordered = () => createNexusCards(players, () => 0.999);
+function census(state: NexusState) {
+ assert.deepEqual(
+ [
+ ...state.deck,
+ ...state.discard,
+ ...Object.values(state.hands).filter(
+ (card): card is FactionId => card !== null,
+ ),
+ ].sort(),
+ [...NEXUS_FACTIONS].sort(),
+ );
+ validateNexusCards(JSON.parse(JSON.stringify(state)) as NexusState, players);
+}
+
+void test('every setup contains all twelve physical faction cards independent of the two through six seated factions', () => {
+ assert.equal(NEXUS_FACTIONS.length, 12);
+ for (const count of [2, 3, 6]) {
+ const seats = NEXUS_FACTIONS.slice(0, count).map((faction, i) => ({
+ id: `seat-${i}`,
+ faction,
+ }));
+ let calls = 0;
+ const state = createNexusCards(seats, () => {
+ calls++;
+ return 0.999;
+ });
+ assert.equal(calls, 11);
+ assert.deepEqual(state.deck, NEXUS_FACTIONS);
+ assert.deepEqual(state.discard, []);
+ assert.deepEqual(
+ Object.values(state.hands),
+ Array.from({ length: count }, () => null),
+ );
+ validateNexusCards(state, seats);
+ }
+ assert.notDeepEqual(createNexusCards(players, () => 0).deck, ordered().deck);
+ assert.deepEqual(
+ createNexusCards(players, () => 0),
+ createNexusCards(players, () => 0),
+ );
+});
+
+void test('one secret card is drawn from the top without extra randomness; own-faction draw remains optional to replace', () => {
+ const initial = ordered();
+ const before = JSON.stringify(initial);
+ const state = drawNexusCard(initial, 'a', players, () => {
+ throw new Error('A nonempty deck is not shuffled');
+ });
+ assert.equal(state.hands.a, 'atreides');
+ assert.equal(
+ nexusCardMode(state.hands.a!, 'atreides', ['atreides', 'guild']),
+ 'cunning',
+ );
+ assert.equal(state.deck.length, 11);
+ assert.equal(state.discard.length, 0);
+ assert.equal(JSON.stringify(initial), before);
+ assert.throws(() => drawNexusCard(state, 'a', players, () => 0));
+ assert.throws(() => replaceNexusCard(initial, 'a', players, () => 0));
+ census(state);
+});
+
+void test('replacement discards before drawing and can draw the same physical card from a recycled discard pile', () => {
+ const initial = drawNexusCard(ordered(), 'a', players, () => 0);
+ const replaced = replaceNexusCard(initial, 'a', players, () => {
+ throw new Error('Still a nonempty deck');
+ });
+ assert.equal(replaced.hands.a, 'harkonnen');
+ assert.deepEqual(replaced.discard, ['atreides']);
+ census(replaced);
+ const exhausted = { ...initial, deck: [], discard: [...initial.deck] };
+ census(exhausted);
+ let calls = 0;
+ // The just-discarded own card is the final element; the first Fisher-Yates
+ // swap moves it to the top and remaining no-op swaps leave it there.
+ const redrawn = replaceNexusCard(exhausted, 'a', players, () =>
+ calls++ === 0 ? 0 : 0.999,
+ );
+ assert.equal(calls, 11);
+ assert.equal(redrawn.hands.a, 'atreides');
+ assert.equal(redrawn.discard.length, 0);
+ assert.equal(redrawn.deck.length, 11);
+ assert.deepEqual(exhausted.hands, initial.hands);
+ census(redrawn);
+});
+
+void test('using or forfeiting a held card routes it to discard once; allied players cannot draw or replace', () => {
+ const held = drawNexusCard(ordered(), 'a', players, () => 0);
+ const allied: NexusPlayer[] = [
+ { ...players[0], ally: 'g' },
+ { ...players[1], ally: 'a' },
+ ];
+ let calls = 0;
+ assert.throws(() =>
+ drawNexusCard(ordered(), 'g', allied, () => {
+ calls++;
+ return 0;
+ }),
+ );
+ assert.throws(() =>
+ replaceNexusCard(held, 'a', allied, () => {
+ calls++;
+ return 0;
+ }),
+ );
+ assert.equal(calls, 0);
+ const forfeited = discardNexusCard(held, 'a', allied);
+ assert.equal(forfeited.hands.a, null);
+ assert.deepEqual(forfeited.discard, ['atreides']);
+ assert.throws(() => discardNexusCard(forfeited, 'a', allied));
+ assert.deepEqual(discardNexusCard(held, 'a', players), forfeited);
+ census(forfeited);
+});
+
+void test('an empty deck recycles only discards, keeping another held identity outside the shuffle', () => {
+ const held = drawNexusCard(ordered(), 'g', players, () => 0);
+ const state = { ...held, deck: [], discard: [...held.deck] };
+ let calls = 0;
+ const drawn = drawNexusCard(state, 'a', players, () => {
+ calls++;
+ return 0.999;
+ });
+ assert.equal(calls, 10);
+ assert.equal(drawn.hands.g, 'atreides');
+ assert.equal(drawn.hands.a, 'harkonnen');
+ assert.equal(drawn.discard.length, 0);
+ assert.equal(drawn.deck.length, 10);
+ census(drawn);
+});
+
+void test('all physical cards use Cunning for their faction, Betrayal for another seated faction and Secret Ally otherwise', () => {
+ for (const card of NEXUS_FACTIONS) {
+ const other = NEXUS_FACTIONS.find((faction) => faction !== card)!;
+ assert.equal(nexusCardMode(card, card, [card, other]), 'cunning');
+ assert.equal(nexusCardMode(card, other, [card, other]), 'betrayal');
+ assert.equal(nexusCardMode(card, other, [other]), 'secretAlly');
+ }
+ for (const [card, own, seated] of [
+ ['unknown', 'atreides', ['atreides']],
+ ['guild', 'atreides', ['guild']],
+ ['guild', 'atreides', ['atreides', 'atreides']],
+ ['guild', 'unknown', ['unknown']],
+ ] as const)
+ assert.throws(() =>
+ nexusCardMode(
+ card as FactionId,
+ own as FactionId,
+ seated as readonly FactionId[],
+ ),
+ );
+});
+
+void test('malformed physical census, hand capacity, inherited dictionaries and changed seat keys reject unchanged', () => {
+ const mutations: ((s: NexusState) => void)[] = [
+ (s) => {
+ s.deck.pop();
+ },
+ (s) => {
+ s.discard.push(s.deck[0]);
+ },
+ (s) => {
+ s.hands.a = s.deck[0];
+ },
+ (s) => {
+ s.deck[0] = 'unknown' as FactionId;
+ },
+ (s) => {
+ delete s.hands.g;
+ },
+ (s) => {
+ s.hands.absent = null;
+ },
+ (s) => {
+ s.version = 2 as 1;
+ },
+ (s) => {
+ s.hands.a = [] as unknown as FactionId;
+ },
+ (s) => {
+ s.hands = Object.create({ a: null, g: null }) as NexusState['hands'];
+ },
+ (s) => {
+ Object.assign(s, { privateCard: 'guild' });
+ },
+ ];
+ for (const mutate of mutations) {
+ const state = ordered();
+ mutate(state);
+ const before = JSON.stringify(state);
+ let calls = 0;
+ assert.throws(() => validateNexusCards(state, players));
+ assert.throws(() =>
+ drawNexusCard(state, 'a', players, () => {
+ calls++;
+ return 0;
+ }),
+ );
+ assert.equal(calls, 0);
+ assert.equal(JSON.stringify(state), before);
+ }
+});
+
+void test('unsafe or duplicate player identities reject before randomness and cannot pollute hand dictionaries', () => {
+ for (const seats of [
+ [],
+ [players[0]],
+ [...players, players[0]],
+ [players[0], { ...players[1], faction: 'atreides' as const }],
+ [players[0], { ...players[1], ally: 'missing' }],
+ ...['__proto__', 'constructor', 'prototype', ''].map((id) => [
+ { ...players[0], id },
+ players[1],
+ ]),
+ ]) {
+ let calls = 0;
+ assert.throws(() =>
+ createNexusCards(seats, () => {
+ calls++;
+ return 0;
+ }),
+ );
+ assert.equal(calls, 0);
+ }
+ const state = ordered();
+ assert.throws(() => drawNexusCard(state, '__proto__', players, () => 0));
+ assert.equal(Object.getPrototypeOf(state.hands), Object.prototype);
+ assert.equal(Object.hasOwn(Object.prototype, 'atreides'), false);
+});
+
+void test('invalid random draws fail on initialization or recycling without altering a committed card or consuming custody', () => {
+ const held = drawNexusCard(ordered(), 'a', players, () => 0);
+ const exhausted = { ...held, deck: [], discard: [...held.deck] };
+ const before = JSON.stringify(exhausted);
+ for (const value of [-0.1, 1, NaN, Infinity, -Infinity]) {
+ assert.throws(() => createNexusCards(players, () => value), /random draws/);
+ assert.throws(
+ () => replaceNexusCard(exhausted, 'a', players, () => value),
+ /random draws/,
+ );
+ assert.equal(JSON.stringify(exhausted), before);
+ }
+});
+
+void test('private projection reveals only the caller’s card and public counts, and frozen inputs preserve independent JSON custody', () => {
+ let state = drawNexusCard(ordered(), 'a', players, () => 0);
+ state = drawNexusCard(state, 'g', players, () => 0);
+ const privateView = projectNexusCards(state, 'a', players);
+ assert.deepEqual(privateView, {
+ card: 'atreides',
+ deckCount: 10,
+ discardCount: 0,
+ held: { a: true, g: true },
+ });
+ const alternate = structuredClone(state);
+ [alternate.hands.g, alternate.deck[0]] = [
+ alternate.deck[0],
+ alternate.hands.g!,
+ ];
+ alternate.deck.reverse();
+ assert.deepEqual(projectNexusCards(alternate, 'a', players), privateView);
+ assert.notDeepEqual(
+ projectNexusCards(alternate, 'g', players),
+ projectNexusCards(state, 'g', players),
+ );
+ assert.throws(() => projectNexusCards(state, 'observer-not-seated', players));
+ for (const player of players)
+ for (const field of ['hand', 'spice', 'traitors', 'battlePlan'])
+ Object.defineProperty(player, field, {
+ configurable: true,
+ get() {
+ throw new Error(`Private ${field}`);
+ },
+ });
+ Object.freeze(state.deck);
+ Object.freeze(state.discard);
+ Object.freeze(state.hands);
+ Object.freeze(state);
+ const next = replaceNexusCard(state, 'a', players, () => 0);
+ census(next);
+ assert.notEqual(next.deck, state.deck);
+ assert.notEqual(next.discard, state.discard);
+ assert.notEqual(next.hands, state.hands);
+ privateView.held.g = false;
+ assert.equal(projectNexusCards(state, 'a', players).held.g, true);
+});