Implement Nexus card ownership, private draws and full card reference

This commit is contained in:
Quintine committed 2026-09-10 09:29:54 +10:00
1 parent 61689514a0
commit d2a1d63517
20 files changed
+2477 -5

No files matched your search

+2
View File
@@ -1,3 +1,4 @@
import { nexusCardBotActions } from './nexus-card-options';
import { tupileIntelligenceActions } from './tupile-intelligence-options';
import { biddingEndActions, choamMarketPolicy } from './bidding-end-options';
import { homeworldRevivalActionBlock, homeworldRevivalDeploymentActions } from './homeworld-revival-deployment-options';
@@ -3296,6 +3297,7 @@ function standaloneGholaAction(g: GameView, ordinary: Action[]): Action | null {
/** Obligations apply across policy branches, including choosing to move first. */
export function botActions(g: GameView): Action[] {
if (g.automaticContinuationPending) return [];
if (g.nexusCards?.waiting.length) return nexusCardBotActions(g);
const intelligence = tupileIntelligenceActions(g);
if (intelligence.length) return intelligence;
const actions = [...junctionTransportActions(g, rank(g)), ...policyActions(g)].flatMap((action) => {
+121 -3
View File
@@ -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<string, { recipient: string; amount: number }>;
};
export class RuleError extends Error {}
function nexusRule<T>(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<T>(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 &&
+18
View File
@@ -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] : [];
}
+197
View File
@@ -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<NexusCardPhase, 'signature'>): 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, 'signature'>): 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;
}
+96
View File
@@ -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<NexusCardMode, string> = {
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;
}
+280
View File
@@ -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<string, FactionId | null>;
};
export type NexusCardMode = 'cunning' | 'betrayal' | 'secretAlly';
export type NexusCardsView = {
card: FactionId | null;
deckCount: number;
discardCount: number;
held: Record<string, boolean>;
};
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<string, unknown> {
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<string>(),
factions = new Set<FactionId>();
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]),
),
};
}
+27
View File
@@ -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',