Implement Harkonnen Nexus exchanges with durable traitor declarations
This commit is contained in:
1 parent
d820c8a414
commit
d5830d260c
20 files changed
+3158
-10
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import { nexusCardBotActions } from './nexus-card-options';
|
||||
import { nexusTraitorBotActions } from './nexus-traitor-options';
|
||||
import { tupileIntelligenceActions } from './tupile-intelligence-options';
|
||||
import { biddingEndActions, choamMarketPolicy } from './bidding-end-options';
|
||||
import { homeworldRevivalActionBlock, homeworldRevivalDeploymentActions } from './homeworld-revival-deployment-options';
|
||||
@@ -3314,8 +3315,14 @@ 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.nexusTraitors?.pending)
|
||||
return g.truthtrance
|
||||
? policyActions({ ...g, decision: null })
|
||||
: nexusTraitorBotActions(g);
|
||||
if (g.automaticContinuationPending) return [];
|
||||
if (g.nexusCards?.waiting.length) return nexusCardBotActions(g);
|
||||
const traitorExchange = nexusTraitorBotActions(g);
|
||||
if (traitorExchange.length) return traitorExchange;
|
||||
const nexus = g.nexusAtreides;
|
||||
if (
|
||||
g.status === 'playing' && g.phase === 6 && nexus && !nexus.blocked &&
|
||||
|
||||
+170
-2
@@ -1,4 +1,6 @@
|
||||
import { createNexusInspection, allowNexusInspection, answerNexusInspection, reopenNexusInspection, cancelNexusInspection, reopenNexusNative, answerNexusNative, validateNexusInspection, committedPlanElements, type NexusInspection, type BattleInspectionContext } from './battle-inspections';
|
||||
import { beginNexusTraitorExchange, finishNexusTraitorExchange, validateNexusTraitorExchange, validateNexusTraitorHistory, validateNexusTraitorSnapshot, type NexusTraitorExchange, type NexusTraitorSnapshot } from './nexus-traitor-exchange';
|
||||
import { createTraitorDeclaration, validateTraitorDeclarations, type TraitorDeclaration, type TraitorDeclarationContext } from './traitor-declarations';
|
||||
import { createNexusCards, validateNexusCards, drawNexusCard, replaceNexusCard, discardNexusCard, projectNexusCards, nexusCardMode, type NexusState } from './nexus-cards';
|
||||
import { createNexusCardPhase, markNexusCardOccurred, validateNexusCardPhase, closeNexusCardPhase, nexusCardChoices, finishNexusCardChoice, type NexusCardPhase, type NexusCardChoice } from './nexus-card-phase';
|
||||
import { createHomeworldOccupationHistory, observeHomeworldOccupation, validateHomeworldOccupationHistory, tupileOccupationStatus, type HomeworldOccupationHistory } from './homeworld-occupation-history';
|
||||
@@ -536,6 +538,8 @@ export type Battle = {
|
||||
nexusInspection?: NexusInspection;
|
||||
/** Independent presence marker: deleting a spent-card record is not a legacy save. */
|
||||
nexusInspectionUsed?: string;
|
||||
traitorDeclarationVersion?: 1;
|
||||
traitorDeclarations?: Record<string, TraitorDeclaration>;
|
||||
homeworldDefensePassed?: string[];
|
||||
event?: string;
|
||||
strongholdCopy?: StrongholdId;
|
||||
@@ -1253,6 +1257,9 @@ export type Game = {
|
||||
strongholdCards?: StrongholdState | null;
|
||||
/** Optional independent module; complete effect coverage is still release-gated. */
|
||||
nexusCards?: { cards: NexusState | null; phase: NexusCardPhase | null } | null;
|
||||
nexusTraitorExchanges?: NexusTraitorExchange[];
|
||||
nexusTraitorPending?: string | null;
|
||||
nexusTraitorParent?: { event: string; signature: string } | null;
|
||||
/** Null/absent disables the module; custody is installed at force placement. */
|
||||
homeworlds?: { custody: HomeworldCustody | null; historyVersion?: 1 } | null;
|
||||
homeworldOccupationHistory?: HomeworldOccupationHistory;
|
||||
@@ -1489,6 +1496,151 @@ function projectedNexusCards(g: Game, id: string) {
|
||||
choices: nexusCardChoices(nexus.phase, nexus.cards, id),
|
||||
waiting: nexus.phase?.stage === 'drawing' ? nexus.phase.eligible.filter(owner => !nexus.phase!.done.includes(owner)) : [] };
|
||||
}
|
||||
function nexusTraitorUniverse(g: Game) {
|
||||
// This describes the printed setup inventory; it never regenerates the deck
|
||||
// from current hands, captured leaders or Face Dancer custody.
|
||||
return traitorDeck(g.players.map(p => ({ leaders: [
|
||||
...leaders(p.faction), ...(g.advanced && p.faction === 'choam' ? [createAuditorLeader()] : []),
|
||||
] })), g.expansions.includes('ix'));
|
||||
}
|
||||
function nexusTraitorSnapshot(g: Game): NexusTraitorSnapshot {
|
||||
return { reserve: [...(g.traitorReserve ?? [])], players: g.players.map(p => ({
|
||||
id: p.id, faction: p.faction, traitors: [...p.traitors],
|
||||
...(p.faceDancers ? { faceDancers: structuredClone(p.faceDancers) } : {}),
|
||||
})) };
|
||||
}
|
||||
function pendingNexusTraitors(g: Game) {
|
||||
return g.nexusTraitorExchanges?.find(exchange => exchange.stage === 'return') ?? null;
|
||||
}
|
||||
function nexusTraitorParentSignature(g: Game, event: string) {
|
||||
const continuation = g.pendingTreacheryDiscard?.continuation;
|
||||
const context = continuation && 'resume' in continuation ? continuation.resume : g;
|
||||
const resume = { response: context.response ?? null, decision: context.decision ?? null,
|
||||
pendingKarama: 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] ?? null : null,
|
||||
phaseOpening: 'phaseOpening' in context ? context.phaseOpening as Game['phaseOpening'] ?? null : null };
|
||||
const battle = g.battle ? Object.fromEntries(Object.entries(g.battle).filter(([key]) => key !== 'truthPromises')) : null;
|
||||
return JSON.stringify({ event, turn: g.turn, phase: g.phase, status: g.status, active: g.active,
|
||||
order: g.order, ready: g.ready, battle, nullentropy: g.pendingNullentropy ?? null,
|
||||
controls: nullentropyParentSignature(g, resume, event) });
|
||||
}
|
||||
function traitorDeclarationContext(g: Game): TraitorDeclarationContext {
|
||||
const b = g.battle!;
|
||||
return { event: b.event!, attacker: b.attacker, defender: b.defender, plans: b.plans,
|
||||
players: g.players.map(({id,faction,ally,traitors}) => ({id,faction,ally,traitors})),
|
||||
heroLeaderIds: physicalTreacheryCards(g).filter(c => c.kind === 'hero').map(c => c.id),
|
||||
};
|
||||
}
|
||||
function traitorDeclarationIntegrity(g: Game) {
|
||||
const b = g.battle;
|
||||
if (!b) return;
|
||||
if (b.traitorDeclarationVersion === undefined && b.traitorDeclarations === undefined) return;
|
||||
nexusRule(() => validateTraitorDeclarations(traitorDeclarationContext(g), b.traitorCalls,
|
||||
b.traitorDeclarations, b.traitorDeclarationVersion));
|
||||
}
|
||||
function ensureTraitorDeclarations(g: Game) {
|
||||
const b = g.battle;
|
||||
if (!b?.revealed || b.traitorDeclarationVersion) return;
|
||||
requireRule(b.event, 'This battle predates the durable traitor declaration record.');
|
||||
b.traitorDeclarationVersion = 1;
|
||||
b.traitorDeclarations = {};
|
||||
for (const [voter, called] of Object.entries(b.traitorCalls))
|
||||
if (called) b.traitorDeclarations[voter] = nexusRule(() => createTraitorDeclaration(traitorDeclarationContext(g), voter));
|
||||
}
|
||||
function nexusTraitorIntegrity(g: Game) {
|
||||
const history = g.nexusTraitorExchanges;
|
||||
if (!history) {
|
||||
requireRule(!g.nexusTraitorPending && !g.nexusTraitorParent, 'The pending Nexus exchange has lost its physical draw record.');
|
||||
return;
|
||||
}
|
||||
requireRule(g.nexusCards?.cards && Array.isArray(history), 'Nexus exchanges require the original card module.');
|
||||
const universe = nexusTraitorUniverse(g);
|
||||
nexusRule(() => validateNexusTraitorSnapshot(nexusTraitorSnapshot(g), universe));
|
||||
const events = new Set<string>();
|
||||
for (const exchange of history) {
|
||||
requireRule(!events.has(exchange.event) && exchange.turn <= g.turn,
|
||||
'Nexus exchange history is duplicated or belongs to a future turn.');
|
||||
events.add(exchange.event);
|
||||
if (exchange.stage === 'complete') nexusRule(() => validateNexusTraitorHistory(universe, exchange));
|
||||
else requireRule(exchange.stage === 'return', 'Invalid Nexus exchange progress.');
|
||||
}
|
||||
const pending = history.filter(exchange => exchange.stage === 'return');
|
||||
requireRule(pending.length <= 1 && (pending[0]?.event ?? null) === (g.nexusTraitorPending ?? null),
|
||||
'The Nexus exchange pending marker contradicts its draw history.');
|
||||
if (pending.length) {
|
||||
const exchange = pending[0];
|
||||
requireRule(g.status === 'playing' && exchange === history.at(-1) && exchange.turn === g.turn && exchange.phase === g.phase &&
|
||||
g.nexusCards.cards.discard.includes('harkonnen'), 'The pending Nexus return has lost its turn, phase or spent card.');
|
||||
nexusRule(() => validateNexusTraitorExchange(nexusTraitorSnapshot(g), universe, exchange));
|
||||
requireRule(g.nexusTraitorParent?.event === exchange.event &&
|
||||
g.nexusTraitorParent.signature === nexusTraitorParentSignature(g, exchange.event),
|
||||
'The Nexus exchange has lost or changed its preceding game decision.');
|
||||
} else requireRule(!g.nexusTraitorParent, 'A completed Nexus return cannot retain a suspended decision.');
|
||||
traitorDeclarationIntegrity(g);
|
||||
}
|
||||
function nexusTraitorOffer(g: Game, id: string) {
|
||||
if (g.nexusCards?.cards?.hands[id] !== 'harkonnen') return null;
|
||||
const player = getPlayer(g, id), mode = nexusCardMode('harkonnen', player.faction, g.players.map(p => p.faction));
|
||||
let blocked: string | null = null;
|
||||
if (g.status !== 'playing') blocked = 'Nexus effects belong to the started game.';
|
||||
else if (player.ally) blocked = 'Allied players cannot retain or play a Nexus card.';
|
||||
else if (g.truthtrance) blocked = 'Finish the active Truthtrance first.';
|
||||
else if (g.nexusCards.phase?.stage === 'drawing') blocked = 'Finish the closing Nexus draws first.';
|
||||
else if (pendingNexusTraitors(g)) blocked = 'Finish the current Nexus return first.';
|
||||
else if (mode === 'betrayal') blocked = 'Betrayal reactions await the private response timing decision.';
|
||||
else if (mode === 'secretAlly' && g.phase !== 8) blocked = 'Use Secret Ally during Mentat Pause.';
|
||||
const draw = mode === 'secretAlly' ? 2 : 1;
|
||||
return { event: JSON.stringify(['nexusTraitors', g.turn, g.phase, g.nexusTraitorExchanges?.length ?? 0, id]), mode, draw, blocked };
|
||||
}
|
||||
function applyNexusTraitorSnapshot(g: Game, snapshot: NexusTraitorSnapshot) {
|
||||
g.traitorReserve = [...snapshot.reserve];
|
||||
for (const entry of snapshot.players) {
|
||||
const player = getPlayer(g, entry.id);
|
||||
player.traitors = [...entry.traitors];
|
||||
if (entry.faceDancers) player.faceDancers = structuredClone(entry.faceDancers);
|
||||
}
|
||||
}
|
||||
function playNexusTraitorDraw(g: Game, p: Player, action: Action) {
|
||||
const offer = nexusTraitorOffer(g, p.id);
|
||||
requireRule(offer && !offer.blocked && offer.mode !== 'betrayal' &&
|
||||
action.event === offer.event && action.mode === offer.mode && Object.keys(action).sort().join(',') === 'event,mode,type',
|
||||
offer?.blocked ?? 'This Nexus Traitor draw is stale or belongs to another player.');
|
||||
const result = nexusRule(() => beginNexusTraitorExchange(nexusTraitorSnapshot(g), nexusTraitorUniverse(g), {
|
||||
event: offer.event, owner: p.id, mode: offer.mode as 'cunning' | 'secretAlly', turn: g.turn, phase: g.phase,
|
||||
}));
|
||||
ensureTraitorDeclarations(g);
|
||||
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players));
|
||||
applyNexusTraitorSnapshot(g, result.state);
|
||||
(g.nexusTraitorExchanges ??= []).push(result.exchange);
|
||||
g.nexusTraitorPending = result.exchange.event;
|
||||
g.nexusTraitorParent = { event: result.exchange.event, signature: nexusTraitorParentSignature(g, result.exchange.event) };
|
||||
log(g, `${p.name} played the Harkonnen Nexus Card: ${offer.mode === 'cunning' ? 'Cunning' : 'Secret Ally'}. They drew ${offer.draw} private ${p.faction === 'tleilaxu' ? 'Face Dancer' : 'Traitor'} ${offer.draw === 1 ? 'Card' : 'Cards'} and must choose ${offer.draw} to return before play continues.`,
|
||||
{ faction: p.faction, name: 'Harkonnen Nexus' });
|
||||
}
|
||||
function finishNexusTraitorReturn(g: Game, p: Player, action: Action) {
|
||||
const pending = pendingNexusTraitors(g);
|
||||
requireRule(pending?.owner === p.id && action.event === pending.event &&
|
||||
Object.keys(action).sort().join(',') === 'cards,event,type' && Array.isArray(action.cards),
|
||||
'This Nexus return is stale or belongs to another player.');
|
||||
const result = nexusRule(() => finishNexusTraitorExchange(nexusTraitorSnapshot(g), nexusTraitorUniverse(g),
|
||||
pending, action.cards as string[], random));
|
||||
applyNexusTraitorSnapshot(g, result.state);
|
||||
g.nexusTraitorExchanges![g.nexusTraitorExchanges!.length - 1] = result.exchange;
|
||||
g.nexusTraitorPending = null;
|
||||
g.nexusTraitorParent = null;
|
||||
log(g, `${p.name} secretly returned ${result.exchange.returned.length} ${p.faction === 'tleilaxu' ? 'Face Dancer' : 'Traitor'} ${result.exchange.returned.length === 1 ? 'Card' : 'Cards'}. The Traitor Deck was shuffled; their other cards and any prior traitor declaration remain unchanged.`);
|
||||
}
|
||||
function projectedNexusTraitors(g: Game, id: string) {
|
||||
if (!g.nexusCards?.cards) return null;
|
||||
const pending = pendingNexusTraitors(g);
|
||||
const owner = pending ? getPlayer(g, pending.owner) : null;
|
||||
return { offer: nexusTraitorOffer(g, id), pending: pending ? {
|
||||
event: pending.event, owner: pending.owner, mode: pending.mode, count: pending.drawn.length,
|
||||
choices: pending.owner !== id ? [] : (owner!.faction === 'tleilaxu'
|
||||
? owner!.faceDancers!.filter(card => !card.revealed).map(card => ({id:card.leader, revealed:card.revealed, drawn:pending.drawn.includes(card.leader)}))
|
||||
: owner!.traitors.map(card => ({id:card, revealed:false, drawn:pending.drawn.includes(card)}))),
|
||||
} : null };
|
||||
}
|
||||
|
||||
function battleInspectionContext(g: Game): BattleInspectionContext {
|
||||
const b = g.battle!;
|
||||
return { event: b.event!, attacker: b.attacker, defender: b.defender,
|
||||
@@ -1536,6 +1688,7 @@ function nexusAtreidesOffer(g: Game, id: string) {
|
||||
blocked = 'Play this Nexus Card before the current battle plans are revealed.';
|
||||
else if (p.ally) blocked = 'Allied players cannot use a Nexus Card.';
|
||||
else if (b.nexusInspection || b.nexusInspectionUsed) blocked = 'This battle already used the Atreides Nexus Card.';
|
||||
else if (pendingNexusTraitors(g)) blocked = 'Finish the private Nexus card return first.';
|
||||
else if (g.truthtrance || g.decision || g.phaseOpening || g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy)
|
||||
blocked = 'Finish the current interaction before playing this Nexus Card.';
|
||||
else if (mode === 'betrayal') {
|
||||
@@ -11498,6 +11651,8 @@ function gholaOptions(g: Game, p: Player) {
|
||||
}
|
||||
function marketGholaIntegrity(g: Game) {
|
||||
nexusCardsIntegrity(g);
|
||||
nexusTraitorIntegrity(g);
|
||||
traitorDeclarationIntegrity(g);
|
||||
nexusInspectionIntegrity(g);
|
||||
homeworldHistoryIntegrity(g);
|
||||
grummanCollectionIntegrity(g);
|
||||
@@ -12484,7 +12639,7 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
|
||||
id,
|
||||
beneficiary: traitorBeneficiary(g, b, id)!,
|
||||
called: id === canceledVoter ? false : b.traitorCalls[id],
|
||||
traitors: getPlayer(g, id).traitors,
|
||||
traitors: b.traitorDeclarations?.[id] ? [b.traitorDeclarations[id].identity] : getPlayer(g, id).traitors,
|
||||
})),
|
||||
participants: g.players.map((p) => ({
|
||||
id: p.id,
|
||||
@@ -16508,6 +16663,7 @@ function finishActionContinuations(g: Game) {
|
||||
// discard. Retire that next physical batch before exposing optional choices.
|
||||
for (let i = 0; g.pendingTreacheryDiscard && i < 16; i++) finishTreacheryDiscard(g);
|
||||
requireRule(!g.pendingTreacheryDiscard, 'The automatic discard chain did not finish.');
|
||||
if (pendingNexusTraitors(g)) return;
|
||||
observeOccupation(g);
|
||||
resumeHomeworldRevivalReturn(g);
|
||||
resumeHomeworldVictoryReturn(g);
|
||||
@@ -16549,6 +16705,7 @@ function finishActionContinuations(g: Game) {
|
||||
settleAdvisors(g);
|
||||
}
|
||||
function settleAutomaticContinuations(g: Game) {
|
||||
if (pendingNexusTraitors(g)) return;
|
||||
if (g.pendingNullentropy) return;
|
||||
for (let iteration = 0; iteration < 128; iteration++) {
|
||||
if (g.truthtrance || g.phaseOpening || g.status === 'finished') return;
|
||||
@@ -16637,8 +16794,9 @@ function applyActionInner(
|
||||
stoneBurnerIntegrity(g);
|
||||
strongholdIntegrity(g);
|
||||
auditorIntegrity(g);
|
||||
if (g.pendingNullentropy) {
|
||||
if (g.pendingNullentropy && !['nexusTraitorDraw', 'nexusTraitorReturn'].includes(action?.type)) {
|
||||
if (action?.type === 'advanceBots') return g;
|
||||
requireRule(!pendingNexusTraitors(g), 'Finish the private Nexus card return before continuing the paid search.');
|
||||
requireRule(
|
||||
action?.type === 'decision',
|
||||
'Finish the paid Nullentropy Box search before another game action.',
|
||||
@@ -16693,6 +16851,11 @@ function applyActionInner(
|
||||
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.');
|
||||
if (t === 'nexusTraitorDraw') { playNexusTraitorDraw(g, p, action); return g; }
|
||||
if (t === 'nexusTraitorReturn') {
|
||||
requireRule(!g.truthtrance, 'Finish the active Truthtrance before returning Nexus cards.');
|
||||
finishNexusTraitorReturn(g, p, action); return g;
|
||||
}
|
||||
if (t === 'nexusAtreides') { playNexusAtreides(g, p, action); return g; }
|
||||
requireRule(
|
||||
!(
|
||||
@@ -16721,6 +16884,7 @@ function applyActionInner(
|
||||
throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
requireRule(!pendingNexusTraitors(g), 'Finish the private Nexus card return before continuing play.');
|
||||
if (t === 'card' && action.card === 'richese-juice-of-sapho') {
|
||||
playSapho(g, p, action);
|
||||
return g;
|
||||
@@ -19686,6 +19850,7 @@ function applyActionInner(
|
||||
'Traitor decision is not available.',
|
||||
);
|
||||
requireRule(typeof action.call === 'boolean', 'Choose reveal or decline.');
|
||||
if (g.nexusCards?.cards) ensureTraitorDeclarations(g);
|
||||
if (action.call) {
|
||||
const other =
|
||||
b.attacker === traitorBeneficiary(g, b, id) ? b.defender : b.attacker;
|
||||
@@ -19702,6 +19867,8 @@ function applyActionInner(
|
||||
'You do not hold that leader as a traitor.',
|
||||
);
|
||||
}
|
||||
if (action.call && b.traitorDeclarationVersion)
|
||||
b.traitorDeclarations![id] = nexusRule(() => createTraitorDeclaration(traitorDeclarationContext(g), id));
|
||||
b.traitorCalls[id] = action.call;
|
||||
if (
|
||||
action.call &&
|
||||
@@ -20151,6 +20318,7 @@ export function viewGame(state: Game, id: string) {
|
||||
techTokens: g.techTokens ?? null,
|
||||
strongholdCards: g.strongholdCards ?? null,
|
||||
nexusCards: projectedNexusCards(g, id),
|
||||
nexusTraitors: projectedNexusTraitors(g, id),
|
||||
nexusAtreides: nexusAtreidesOffer(g, id),
|
||||
homeworldRevivalDeployment: projectedHomeworldRevivalReturn(g, id),
|
||||
caladanReinforcement: projectedHomeworldVictoryReturn(g, id),
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
import { FACTIONS, type FactionId } from './catalog';
|
||||
|
||||
export type NexusExchangeFaceDancer = { leader: string; revealed: boolean };
|
||||
export type NexusExchangePlayer = {
|
||||
id: string;
|
||||
faction: FactionId;
|
||||
traitors: string[];
|
||||
faceDancers?: NexusExchangeFaceDancer[];
|
||||
};
|
||||
export type NexusTraitorSnapshot = {
|
||||
reserve: string[];
|
||||
players: NexusExchangePlayer[];
|
||||
};
|
||||
export type NexusTraitorExchange = {
|
||||
version: 1;
|
||||
event: string;
|
||||
owner: string;
|
||||
mode: 'cunning' | 'secretAlly';
|
||||
turn: number;
|
||||
phase: number;
|
||||
stage: 'return' | 'complete';
|
||||
drawn: string[];
|
||||
before: string[] | NexusExchangeFaceDancer[];
|
||||
returned: string[];
|
||||
source: {
|
||||
ownerFaction: FactionId;
|
||||
reserve: string[];
|
||||
others: NexusExchangePlayer[];
|
||||
};
|
||||
settledReserve?: string[];
|
||||
signature: string;
|
||||
};
|
||||
export type NexusTraitorExchangeResult = {
|
||||
state: NexusTraitorSnapshot;
|
||||
exchange: NexusTraitorExchange;
|
||||
};
|
||||
function requireExchange(
|
||||
condition: unknown,
|
||||
message: string,
|
||||
): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
function plain(value: unknown): value is Record<string, unknown> {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
[Object.prototype, null].includes(Object.getPrototypeOf(value))
|
||||
);
|
||||
}
|
||||
function exact(value: unknown, keys: string[]): boolean {
|
||||
return (
|
||||
plain(value) &&
|
||||
Object.keys(value).sort().join(',') === [...keys].sort().join(',')
|
||||
);
|
||||
}
|
||||
function identifier(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.trim().length > 0 &&
|
||||
!['__proto__', 'constructor', 'prototype'].includes(value)
|
||||
);
|
||||
}
|
||||
function ids(value: unknown): value is string[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.every(identifier) &&
|
||||
new Set(value).size === value.length
|
||||
);
|
||||
}
|
||||
function equal(one: unknown, two: unknown): boolean {
|
||||
return JSON.stringify(one) === JSON.stringify(two);
|
||||
}
|
||||
function playerTuple(player: NexusExchangePlayer): unknown[] {
|
||||
return [
|
||||
player.id,
|
||||
player.faction,
|
||||
player.traitors,
|
||||
player.faceDancers?.map((card) => [card.leader, card.revealed]) ?? null,
|
||||
];
|
||||
}
|
||||
function validatePlayer(player: NexusExchangePlayer): void {
|
||||
requireExchange(
|
||||
exact(player, [
|
||||
'id',
|
||||
'faction',
|
||||
'traitors',
|
||||
...(Object.hasOwn(player, 'faceDancers') ? ['faceDancers'] : []),
|
||||
]) &&
|
||||
identifier(player.id) &&
|
||||
FACTIONS.some((f) => f.id === player.faction) &&
|
||||
ids(player.traitors),
|
||||
'Invalid traitor exchange seat custody.',
|
||||
);
|
||||
if (player.faction === 'tleilaxu') {
|
||||
requireExchange(
|
||||
player.traitors.length === 0 &&
|
||||
Array.isArray(player.faceDancers) &&
|
||||
player.faceDancers.every(
|
||||
(card) =>
|
||||
exact(card, ['leader', 'revealed']) &&
|
||||
identifier(card.leader) &&
|
||||
typeof card.revealed === 'boolean',
|
||||
) &&
|
||||
new Set(player.faceDancers.map((card) => card.leader)).size ===
|
||||
player.faceDancers.length,
|
||||
'Tleilaxu must preserve physical Face Dancer identities and statuses.',
|
||||
);
|
||||
} else
|
||||
requireExchange(
|
||||
!Object.hasOwn(player, 'faceDancers'),
|
||||
'Only Tleilaxu may hold Face Dancers.',
|
||||
);
|
||||
}
|
||||
export function validateNexusTraitorSnapshot(
|
||||
state: NexusTraitorSnapshot,
|
||||
universe: readonly string[],
|
||||
): void {
|
||||
requireExchange(
|
||||
ids(universe) &&
|
||||
universe.length > 0 &&
|
||||
exact(state, ['reserve', 'players']) &&
|
||||
ids(state.reserve) &&
|
||||
Array.isArray(state.players) &&
|
||||
state.players.length >= 2 &&
|
||||
state.players.length <= 6,
|
||||
'Invalid traitor exchange physical inventory.',
|
||||
);
|
||||
for (const player of state.players) validatePlayer(player);
|
||||
requireExchange(
|
||||
new Set(state.players.map((p) => p.id)).size === state.players.length &&
|
||||
new Set(state.players.map((p) => p.faction)).size ===
|
||||
state.players.length,
|
||||
'Traitor exchange seats must have distinct identities and factions.',
|
||||
);
|
||||
const physical = [
|
||||
...state.reserve,
|
||||
...state.players.flatMap((p) => [
|
||||
...p.traitors,
|
||||
...(p.faceDancers ?? []).map((c) => c.leader),
|
||||
]),
|
||||
];
|
||||
requireExchange(
|
||||
physical.length === universe.length &&
|
||||
new Set(physical).size === physical.length &&
|
||||
physical.every((card) => universe.includes(card)),
|
||||
'The complete physical traitor census must remain intact.',
|
||||
);
|
||||
}
|
||||
function count(mode: NexusTraitorExchange['mode']): number {
|
||||
return mode === 'cunning' ? 1 : 2;
|
||||
}
|
||||
function hand(
|
||||
player: NexusExchangePlayer,
|
||||
): string[] | NexusExchangeFaceDancer[] {
|
||||
return player.faction === 'tleilaxu' ? player.faceDancers! : player.traitors;
|
||||
}
|
||||
function returnable(value: string[] | NexusExchangeFaceDancer[]): string[] {
|
||||
return value
|
||||
.filter((card) => typeof card === 'string' || !card.revealed)
|
||||
.map((card) => (typeof card === 'string' ? card : card.leader));
|
||||
}
|
||||
function setHand(
|
||||
player: NexusExchangePlayer,
|
||||
value: (string | NexusExchangeFaceDancer)[],
|
||||
): void {
|
||||
if (player.faction === 'tleilaxu')
|
||||
player.faceDancers = structuredClone(value) as NexusExchangeFaceDancer[];
|
||||
else player.traitors = [...value] as string[];
|
||||
}
|
||||
function augmented(
|
||||
exchange: NexusTraitorExchange,
|
||||
player: NexusExchangePlayer,
|
||||
): string[] | NexusExchangeFaceDancer[] {
|
||||
return player.faction === 'tleilaxu'
|
||||
? [
|
||||
...(structuredClone(exchange.before) as NexusExchangeFaceDancer[]),
|
||||
...exchange.drawn.map((leader) => ({ leader, revealed: false })),
|
||||
]
|
||||
: [...(exchange.before as string[]), ...exchange.drawn];
|
||||
}
|
||||
function signature(exchange: Omit<NexusTraitorExchange, 'signature'>): string {
|
||||
return JSON.stringify([
|
||||
'nexusTraitorExchange',
|
||||
exchange.version,
|
||||
exchange.event,
|
||||
exchange.owner,
|
||||
exchange.mode,
|
||||
exchange.turn,
|
||||
exchange.phase,
|
||||
exchange.stage,
|
||||
exchange.drawn,
|
||||
exchange.before.map((card) =>
|
||||
typeof card === 'string' ? card : [card.leader, card.revealed],
|
||||
),
|
||||
exchange.returned,
|
||||
exchange.source.ownerFaction,
|
||||
exchange.source.reserve,
|
||||
exchange.source.others.map(playerTuple),
|
||||
exchange.settledReserve ?? null,
|
||||
]);
|
||||
}
|
||||
function validateMode(
|
||||
state: NexusTraitorSnapshot,
|
||||
owner: string,
|
||||
mode: NexusTraitorExchange['mode'],
|
||||
): NexusExchangePlayer {
|
||||
const player = state.players.find((p) => p.id === owner);
|
||||
requireExchange(
|
||||
player && ['cunning', 'secretAlly'].includes(mode),
|
||||
'Choose a supported Harkonnen Nexus exchange.',
|
||||
);
|
||||
requireExchange(
|
||||
mode === 'cunning'
|
||||
? player.faction === 'harkonnen'
|
||||
: !state.players.some((p) => p.faction === 'harkonnen'),
|
||||
'The Harkonnen Nexus exchange must match the seated factions.',
|
||||
);
|
||||
return player;
|
||||
}
|
||||
export function validateNexusTraitorExchange(
|
||||
state: NexusTraitorSnapshot,
|
||||
universe: readonly string[],
|
||||
exchange: NexusTraitorExchange,
|
||||
): void {
|
||||
validateNexusTraitorSnapshot(state, universe);
|
||||
requireExchange(
|
||||
exact(exchange, [
|
||||
'version',
|
||||
'event',
|
||||
'owner',
|
||||
'mode',
|
||||
'turn',
|
||||
'phase',
|
||||
'stage',
|
||||
'drawn',
|
||||
'before',
|
||||
'returned',
|
||||
'source',
|
||||
'signature',
|
||||
...(Object.hasOwn(exchange, 'settledReserve') ? ['settledReserve'] : []),
|
||||
]) &&
|
||||
exchange.version === 1 &&
|
||||
identifier(exchange.event) &&
|
||||
identifier(exchange.owner) &&
|
||||
Number.isSafeInteger(exchange.turn) &&
|
||||
exchange.turn > 0 &&
|
||||
Number.isSafeInteger(exchange.phase) &&
|
||||
exchange.phase >= 0 &&
|
||||
exchange.phase <= 8 &&
|
||||
['return', 'complete'].includes(exchange.stage) &&
|
||||
ids(exchange.drawn) &&
|
||||
ids(exchange.returned) &&
|
||||
Array.isArray(exchange.before) &&
|
||||
exact(exchange.source, ['ownerFaction', 'reserve', 'others']) &&
|
||||
ids(exchange.source.reserve) &&
|
||||
Array.isArray(exchange.source.others),
|
||||
'Invalid saved Nexus traitor exchange.',
|
||||
);
|
||||
const player = validateMode(state, exchange.owner, exchange.mode);
|
||||
requireExchange(
|
||||
exchange.drawn.length === count(exchange.mode) &&
|
||||
equal(
|
||||
exchange.drawn,
|
||||
exchange.source.reserve.slice(0, count(exchange.mode)),
|
||||
),
|
||||
'A Nexus exchange must draw its full original group before returning cards.',
|
||||
);
|
||||
requireExchange(
|
||||
exchange.source.ownerFaction === player.faction,
|
||||
'The exchange owner faction must match its original custody.',
|
||||
);
|
||||
const originalOwner = structuredClone(player);
|
||||
setHand(originalOwner, exchange.before);
|
||||
validatePlayer(originalOwner);
|
||||
const original: NexusTraitorSnapshot = {
|
||||
reserve: exchange.source.reserve,
|
||||
players: state.players.map((p) =>
|
||||
p.id === exchange.owner
|
||||
? originalOwner
|
||||
: exchange.source.others.find((other) => other.id === p.id)!,
|
||||
),
|
||||
};
|
||||
requireExchange(
|
||||
exchange.source.others.length === state.players.length - 1 &&
|
||||
equal(
|
||||
exchange.source.others.map(playerTuple),
|
||||
state.players.filter((p) => p.id !== exchange.owner).map(playerTuple),
|
||||
),
|
||||
'A Nexus exchange cannot alter another player’s held cards.',
|
||||
);
|
||||
validateNexusTraitorSnapshot(original, universe);
|
||||
const pool = augmented(exchange, player);
|
||||
requireExchange(
|
||||
exchange.returned.every((card) => returnable(pool).includes(card)),
|
||||
'Return only cards from the augmented owner hand.',
|
||||
);
|
||||
if (exchange.stage === 'return')
|
||||
requireExchange(
|
||||
exchange.returned.length === 0 &&
|
||||
!Object.hasOwn(exchange, 'settledReserve') &&
|
||||
equal(hand(player), pool) &&
|
||||
equal(
|
||||
state.reserve,
|
||||
exchange.source.reserve.slice(count(exchange.mode)),
|
||||
),
|
||||
'The pending exchange must preserve every drawn and originally held card.',
|
||||
);
|
||||
else {
|
||||
const retained = pool.filter(
|
||||
(card) =>
|
||||
!exchange.returned.includes(
|
||||
typeof card === 'string' ? card : card.leader,
|
||||
),
|
||||
);
|
||||
requireExchange(
|
||||
exchange.returned.length === count(exchange.mode) &&
|
||||
ids(exchange.settledReserve) &&
|
||||
equal(hand(player), retained) &&
|
||||
equal(state.reserve, exchange.settledReserve) &&
|
||||
equal(
|
||||
[...exchange.settledReserve].sort(),
|
||||
[
|
||||
...exchange.source.reserve.slice(count(exchange.mode)),
|
||||
...exchange.returned,
|
||||
].sort(),
|
||||
),
|
||||
'The completed exchange must retain its exact selection and shuffled reserve.',
|
||||
);
|
||||
}
|
||||
requireExchange(
|
||||
exchange.signature === signature(exchange),
|
||||
'The Nexus traitor exchange has lost its original history.',
|
||||
);
|
||||
}
|
||||
export function beginNexusTraitorExchange(
|
||||
state: NexusTraitorSnapshot,
|
||||
universe: readonly string[],
|
||||
input: Pick<
|
||||
NexusTraitorExchange,
|
||||
'event' | 'owner' | 'mode' | 'turn' | 'phase'
|
||||
>,
|
||||
): NexusTraitorExchangeResult {
|
||||
validateNexusTraitorSnapshot(state, universe);
|
||||
const player = validateMode(state, input.owner, input.mode);
|
||||
requireExchange(
|
||||
state.reserve.length >= count(input.mode),
|
||||
'The Traitor Deck must contain the full Nexus draw before any return.',
|
||||
);
|
||||
const exchange: NexusTraitorExchange = {
|
||||
version: 1,
|
||||
...input,
|
||||
stage: 'return',
|
||||
drawn: state.reserve.slice(0, count(input.mode)),
|
||||
before: structuredClone(hand(player)),
|
||||
returned: [],
|
||||
source: {
|
||||
ownerFaction: player.faction,
|
||||
reserve: [...state.reserve],
|
||||
others: structuredClone(
|
||||
state.players.filter((p) => p.id !== input.owner),
|
||||
),
|
||||
},
|
||||
signature: '',
|
||||
};
|
||||
const next = structuredClone(state);
|
||||
next.reserve = next.reserve.slice(count(input.mode));
|
||||
setHand(
|
||||
next.players.find((p) => p.id === input.owner)!,
|
||||
augmented(exchange, player),
|
||||
);
|
||||
exchange.signature = signature(exchange);
|
||||
validateNexusTraitorExchange(next, universe, exchange);
|
||||
return { state: next, exchange };
|
||||
}
|
||||
export function finishNexusTraitorExchange(
|
||||
state: NexusTraitorSnapshot,
|
||||
universe: readonly string[],
|
||||
exchange: NexusTraitorExchange,
|
||||
selected: readonly string[],
|
||||
rng: () => number,
|
||||
): NexusTraitorExchangeResult {
|
||||
validateNexusTraitorExchange(state, universe, exchange);
|
||||
requireExchange(
|
||||
exchange.stage === 'return',
|
||||
'This Nexus exchange is already complete.',
|
||||
);
|
||||
const player = state.players.find((p) => p.id === exchange.owner)!;
|
||||
requireExchange(
|
||||
ids(selected) &&
|
||||
selected.length === count(exchange.mode) &&
|
||||
selected.every((card) => returnable(hand(player)).includes(card)),
|
||||
'Return the exact number of distinct cards from your augmented hand.',
|
||||
);
|
||||
const next = structuredClone(state);
|
||||
const retained = hand(player).filter(
|
||||
(card) => !selected.includes(typeof card === 'string' ? card : card.leader),
|
||||
);
|
||||
setHand(
|
||||
next.players.find((p) => p.id === exchange.owner)!,
|
||||
retained,
|
||||
);
|
||||
next.reserve.push(...selected);
|
||||
for (let i = next.reserve.length - 1; i > 0; i--) {
|
||||
const random = rng();
|
||||
requireExchange(
|
||||
typeof random === 'number' &&
|
||||
Number.isFinite(random) &&
|
||||
random >= 0 &&
|
||||
random < 1,
|
||||
'Invalid Nexus exchange shuffle randomness.',
|
||||
);
|
||||
const j = Math.floor(random * (i + 1));
|
||||
[next.reserve[i], next.reserve[j]] = [next.reserve[j], next.reserve[i]];
|
||||
}
|
||||
const complete: NexusTraitorExchange = {
|
||||
...structuredClone(exchange),
|
||||
stage: 'complete',
|
||||
returned: [...selected],
|
||||
settledReserve: [...next.reserve],
|
||||
signature: '',
|
||||
};
|
||||
complete.signature = signature(complete);
|
||||
validateNexusTraitorExchange(next, universe, complete);
|
||||
return { state: next, exchange: complete };
|
||||
}
|
||||
|
||||
/** Completed receipts prove the original transition without freezing later
|
||||
* global custody. Validate the live census separately after subsequent play. */
|
||||
export function validateNexusTraitorHistory(
|
||||
universe: readonly string[],
|
||||
exchange: NexusTraitorExchange,
|
||||
): void {
|
||||
requireExchange(
|
||||
exchange?.stage === 'complete' &&
|
||||
exchange.source &&
|
||||
Array.isArray(exchange.before) &&
|
||||
Array.isArray(exchange.returned),
|
||||
'Only a completed Nexus exchange has an independent history.',
|
||||
);
|
||||
const owner: NexusExchangePlayer = {
|
||||
id: exchange.owner,
|
||||
faction: exchange.source.ownerFaction,
|
||||
traitors: [],
|
||||
};
|
||||
setHand(owner, exchange.before);
|
||||
const retained = augmented(exchange, owner).filter(
|
||||
(card) =>
|
||||
!exchange.returned.includes(
|
||||
typeof card === 'string' ? card : card.leader,
|
||||
),
|
||||
);
|
||||
setHand(owner, retained);
|
||||
validateNexusTraitorExchange(
|
||||
{
|
||||
reserve: exchange.settledReserve!,
|
||||
players: [owner, ...structuredClone(exchange.source.others)],
|
||||
},
|
||||
universe,
|
||||
exchange,
|
||||
);
|
||||
}
|
||||
|
||||
export function nexusTraitorReturnChoices(
|
||||
state: NexusTraitorSnapshot,
|
||||
universe: readonly string[],
|
||||
exchange: NexusTraitorExchange,
|
||||
): string[] {
|
||||
validateNexusTraitorExchange(state, universe, exchange);
|
||||
return exchange.stage === 'return'
|
||||
? returnable(hand(state.players.find((p) => p.id === exchange.owner)!))
|
||||
: [];
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { Action, GameView } from './engine';
|
||||
import { nexusCardMode } from './nexus-cards';
|
||||
import { CHEAP_HERO_TRAITOR, matchingTraitor } from './traitors';
|
||||
|
||||
function quiet(game: GameView): boolean {
|
||||
return (
|
||||
game.status === 'playing' &&
|
||||
!game.automaticContinuationPending &&
|
||||
!game.truthtrance &&
|
||||
!game.response &&
|
||||
!game.decision &&
|
||||
!game.phaseOpening &&
|
||||
!game.nexusCards?.waiting.length
|
||||
);
|
||||
}
|
||||
|
||||
/** The private server offer, not another player's hand, authorizes a draw. */
|
||||
export function nexusTraitorDrawAction(
|
||||
game: GameView,
|
||||
event: string,
|
||||
mode: 'cunning' | 'secretAlly' | 'betrayal',
|
||||
): Action | null {
|
||||
const offer = game.nexusTraitors?.offer;
|
||||
const owner = game.players.find((player) => player.id === game.me);
|
||||
if (
|
||||
game.status !== 'playing' ||
|
||||
game.truthtrance ||
|
||||
game.nexusCards?.waiting.length ||
|
||||
game.nexusTraitors?.pending ||
|
||||
!owner ||
|
||||
!event ||
|
||||
!offer ||
|
||||
offer.event !== event ||
|
||||
offer.mode !== mode ||
|
||||
offer.blocked ||
|
||||
mode === 'betrayal' ||
|
||||
game.nexusCards?.card !== 'harkonnen' ||
|
||||
owner.ally ||
|
||||
nexusCardMode(
|
||||
'harkonnen',
|
||||
owner.faction,
|
||||
game.players.map((player) => player.faction),
|
||||
) !== mode ||
|
||||
offer.draw !== (mode === 'cunning' ? 1 : 2) ||
|
||||
(mode === 'secretAlly' && game.phase !== 8)
|
||||
)
|
||||
return null;
|
||||
return { type: 'nexusTraitorDraw', event, mode };
|
||||
}
|
||||
|
||||
/** Return only the explicitly projected physical choices, never a reconstructed
|
||||
* traitor/Face Dancer hand. A returned new draw is as legal as an old identity. */
|
||||
export function nexusTraitorReturnAction(
|
||||
game: GameView,
|
||||
event: string,
|
||||
cards: readonly string[],
|
||||
): Action | null {
|
||||
const pending = game.nexusTraitors?.pending;
|
||||
if (
|
||||
game.status !== 'playing' ||
|
||||
game.truthtrance ||
|
||||
!pending ||
|
||||
pending.owner !== game.me ||
|
||||
!event ||
|
||||
pending.event !== event ||
|
||||
!game.players.some((player) => player.id === game.me) ||
|
||||
!['cunning', 'secretAlly'].includes(pending.mode) ||
|
||||
pending.count !== (pending.mode === 'cunning' ? 1 : 2) ||
|
||||
(pending.mode === 'secretAlly' && game.phase !== 8) ||
|
||||
cards.length !== pending.count ||
|
||||
new Set(cards).size !== cards.length ||
|
||||
!cards.every((id) => pending.choices.some((choice) => choice.id === id))
|
||||
)
|
||||
return null;
|
||||
return { type: 'nexusTraitorReturn', event, cards: [...cards] };
|
||||
}
|
||||
|
||||
function returnPreference(game: GameView, id: string): number {
|
||||
const owner = game.players.find((player) => player.id === game.me)!;
|
||||
const battle = game.battle;
|
||||
if (
|
||||
battle?.revealed &&
|
||||
[battle.attacker, battle.defender].includes(owner.id)
|
||||
) {
|
||||
const target =
|
||||
battle.attacker === owner.id ? battle.defender : battle.attacker;
|
||||
const leader = battle.plans[target]?.leader;
|
||||
if (
|
||||
leader &&
|
||||
matchingTraitor(
|
||||
[id],
|
||||
leader,
|
||||
battle.cards.find((card) => card.id === leader),
|
||||
)
|
||||
)
|
||||
return 1000;
|
||||
}
|
||||
if (id === CHEAP_HERO_TRAITOR) return 0;
|
||||
const identity = game.allLeaders.find((leader) => leader.id === id);
|
||||
const publicLeader = game.players
|
||||
.flatMap((player) => player.leaders)
|
||||
.find((leader) => leader.id === id);
|
||||
return (
|
||||
(identity?.faction === owner.faction ? -100 : 0) +
|
||||
(publicLeader?.dead ? -20 : 0) +
|
||||
(identity?.strength ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
/** Shared legal policy only; ranking uses public leaders and this viewer's
|
||||
* authorized choices. It makes no claim of calibrated faction strategy. */
|
||||
export function nexusTraitorBotActions(game: GameView): Action[] {
|
||||
const pending = game.nexusTraitors?.pending;
|
||||
if (pending) {
|
||||
if (
|
||||
pending.owner !== game.me ||
|
||||
game.truthtrance ||
|
||||
game.status !== 'playing'
|
||||
)
|
||||
return [];
|
||||
const cards = [...pending.choices]
|
||||
.sort(
|
||||
(one, two) =>
|
||||
returnPreference(game, one.id) - returnPreference(game, two.id),
|
||||
)
|
||||
.slice(0, pending.count)
|
||||
.map((choice) => choice.id);
|
||||
const action = nexusTraitorReturnAction(game, pending.event, cards);
|
||||
return action ? [action] : [];
|
||||
}
|
||||
const offer = game.nexusTraitors?.offer;
|
||||
if (!offer || !quiet(game)) return [];
|
||||
if (offer.mode === 'cunning') {
|
||||
const battle = game.battle;
|
||||
const beforeCall =
|
||||
game.phase === 6 &&
|
||||
battle?.revealed &&
|
||||
[battle.attacker, battle.defender].includes(game.me) &&
|
||||
battle.traitorVoters.includes(game.me) &&
|
||||
!battle.traitorSubmitted.includes(game.me);
|
||||
if (!beforeCall && game.phase !== 8) return [];
|
||||
}
|
||||
const action = nexusTraitorDrawAction(game, offer.event, offer.mode);
|
||||
return action ? [action] : [];
|
||||
}
|
||||
+9
-6
@@ -3340,7 +3340,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
searchText: NEXUS_CARD_REFERENCE.map(card => `${card.faction} ${card.betrayal} ${card.cunning} ${card.secretAlly}`).join(' '),
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Partial',
|
||||
summary: 'A separate twelve-card deck rewards remaining unallied. Private draws and Atreides inspection controls are integrated. Other effects and complete module play remain in development.',
|
||||
summary: 'A separate twelve-card deck rewards remaining unallied. Private draws, Atreides inspections and Harkonnen traitor exchanges are integrated. Other effects and complete module play remain in development.',
|
||||
steps: [
|
||||
'This optional module can be combined with Basic or Advanced rules independently of the factions and other expansion modules selected. Include one Nexus Card for each of the twelve factions.',
|
||||
'At the end of the entire Spice Blow and Nexus phase, if a Nexus occurred and at least one alliance exists, each unallied player may draw one card or discard their held card and draw a replacement. You may decline. The two Advanced spice piles do not grant two closing draws.',
|
||||
@@ -3353,15 +3353,18 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'With Atreides absent, Secret Ally inspects one opposing element in your battle. A sealed plan supplies only that selected answer automatically. Otherwise the opponent chooses an answer that permits a legal plan. Other players receive no private answer.',
|
||||
'The No-Field restriction names Atreides, so Cunning cannot inspect that dial. A non-Atreides Secret Ally holder may request it without learning the token denomination or other plan elements.',
|
||||
'Residual Poison cannot follow a committed leader. If it makes a single nonleader inspection impossible, the same field is answered again and the earlier observation remains private history. Residual Poison after two completed Cunning inspections awaits a ruling on incompatible surviving commitments.',
|
||||
'Harkonnen Cunning draws one Traitor Card before you choose one held card to return. It can pause another action, including a response or decision. Finish the private return before that action continues; active Truthtrance takes priority. Returning an identity already declared as a traitor does not cancel that declaration.',
|
||||
'With Harkonnen absent, Secret Ally draws two Traitor Cards during Mentat Pause, then you choose exactly two distinct held identities to return. Newly drawn cards may be returned. Tleilaxu receives Face Dancers and can return newly drawn or existing unrevealed dancers; previously revealed dancers stay protected.',
|
||||
'The Harkonnen exchange spends the Nexus Card when you draw and shuffles the returned cards into the Traitor Deck after your selection. Your original hand size is restored. Other players see that the exchange is pending, without learning your drawn or returned identities.',
|
||||
'Betrayal reactions await a private response timing decision. Other factions’ Nexus effects and complete module games are unfinished, so public module starts remain disabled.',
|
||||
],
|
||||
related: ['ecaz-modules', 'spice-blow', 'alliance-funding'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle plus Atreides Cunning and Secret Ally inspections, distinct commitments and cancellation. Betrayal and remaining card families are unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Private card lifecycle, Atreides element selection, owned answers, private history and all four plan locks. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All profiles handle lifecycle, offered Atreides inspections and both binding answers using only authorized views. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and Atreides inspection interactions are recorded with explicit unresolved boundaries.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Custody, phase, inspection, controls, bot and concurrent recovery regressions; complete module games remain a release requirement.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen draw-first exchanges and preserved traitor declarations. Betrayal and remaining card families are unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, Atreides answers/history/plan locks, and exact Harkonnen returns with Traitor or Face Dancer inspection. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All profiles handle lifecycle, Atreides commitments and Harkonnen exchanges using authorized choices and public leaders. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces, Atreides inspections and Harkonnen exchanges are recorded with explicit unresolved boundaries.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_HARKONNEN_RUNTIME.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Custody, phase, inspection, exchange controls, bot and concurrent recovery regressions; final Harkonnen acceptance and complete module games remain separate requirements.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-traitor-controls.test.ts', 'tests/nexus-traitor-bots.test.ts', 'tests/nexus-traitor-recovery.test.ts'] },
|
||||
],
|
||||
},
|
||||
...[
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { FACTIONS, type FactionId } from './catalog';
|
||||
import { CHEAP_HERO_TRAITOR } from './traitors';
|
||||
|
||||
export type TraitorDeclarationContext = {
|
||||
event: string;
|
||||
attacker: string;
|
||||
defender: string;
|
||||
plans: Record<string, { leader: string | null; kwisatz?: boolean }>;
|
||||
players: readonly {
|
||||
id: string;
|
||||
faction: FactionId;
|
||||
ally?: string | null;
|
||||
traitors: readonly string[];
|
||||
}[];
|
||||
heroLeaderIds: readonly string[];
|
||||
};
|
||||
|
||||
export type TraitorDeclaration = {
|
||||
event: string;
|
||||
voter: string;
|
||||
beneficiary: string;
|
||||
target: string;
|
||||
leader: string;
|
||||
identity: string;
|
||||
signature: string;
|
||||
};
|
||||
|
||||
const has = (object: object, key: string) =>
|
||||
Object.prototype.hasOwnProperty.call(object, key);
|
||||
function requireDeclaration(value: unknown, message: string): asserts value {
|
||||
if (!value) throw new Error(message);
|
||||
}
|
||||
function validId(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
function validateContext(context: TraitorDeclarationContext): void {
|
||||
requireDeclaration(
|
||||
context &&
|
||||
validId(context.event) &&
|
||||
validId(context.attacker) &&
|
||||
validId(context.defender) &&
|
||||
context.attacker !== context.defender &&
|
||||
Array.isArray(context.players) &&
|
||||
context.players.length >= 2 &&
|
||||
context.players.length <= 6 &&
|
||||
context.plans &&
|
||||
Array.isArray(context.heroLeaderIds) &&
|
||||
context.heroLeaderIds.every(validId) &&
|
||||
new Set(context.heroLeaderIds).size === context.heroLeaderIds.length,
|
||||
'Invalid traitor declaration battle context.',
|
||||
);
|
||||
const ids = new Set<string>();
|
||||
const factions = new Set<FactionId>();
|
||||
for (const player of context.players) {
|
||||
requireDeclaration(
|
||||
player &&
|
||||
validId(player.id) &&
|
||||
!ids.has(player.id) &&
|
||||
FACTIONS.some((faction) => faction.id === player.faction) &&
|
||||
!factions.has(player.faction),
|
||||
'Invalid traitor declaration roster.',
|
||||
);
|
||||
ids.add(player.id);
|
||||
factions.add(player.faction);
|
||||
}
|
||||
for (const id of [context.attacker, context.defender]) {
|
||||
const plan = has(context.plans, id) ? context.plans[id] : undefined;
|
||||
requireDeclaration(
|
||||
ids.has(id) &&
|
||||
plan &&
|
||||
(plan.leader === null || validId(plan.leader)) &&
|
||||
(plan.kwisatz === undefined || typeof plan.kwisatz === 'boolean'),
|
||||
'A traitor declaration requires both sealed battle plans.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function declaredElements(context: TraitorDeclarationContext, voter: string) {
|
||||
const owner = context.players.find((player) => player.id === voter);
|
||||
requireDeclaration(owner, 'The traitor declaration voter is not seated.');
|
||||
const combatants = [context.attacker, context.defender];
|
||||
const beneficiary = combatants.includes(voter) ? voter : owner.ally;
|
||||
requireDeclaration(
|
||||
beneficiary &&
|
||||
combatants.includes(beneficiary) &&
|
||||
(beneficiary === voter || owner.faction === 'harkonnen'),
|
||||
'Only a combatant or its Harkonnen ally can declare this traitor.',
|
||||
);
|
||||
const target =
|
||||
beneficiary === context.attacker ? context.defender : context.attacker;
|
||||
const plan = context.plans[target];
|
||||
requireDeclaration(
|
||||
validId(plan.leader) && !plan.kwisatz,
|
||||
'A traitor declaration requires an unprotected opposing leader.',
|
||||
);
|
||||
return {
|
||||
beneficiary,
|
||||
target,
|
||||
leader: plan.leader,
|
||||
identity: context.heroLeaderIds.includes(plan.leader)
|
||||
? CHEAP_HERO_TRAITOR
|
||||
: plan.leader,
|
||||
};
|
||||
}
|
||||
|
||||
/** Canonical corruption evidence, not authentication against rewritten saves. */
|
||||
function signature(
|
||||
context: TraitorDeclarationContext,
|
||||
receipt: Omit<TraitorDeclaration, 'signature'>,
|
||||
): string {
|
||||
return JSON.stringify([
|
||||
1,
|
||||
receipt.event,
|
||||
receipt.voter,
|
||||
receipt.beneficiary,
|
||||
receipt.target,
|
||||
receipt.leader,
|
||||
receipt.identity,
|
||||
context.players
|
||||
.map(({ id, faction }) => [id, faction])
|
||||
.sort(([a], [b]) => a.localeCompare(b)),
|
||||
]);
|
||||
}
|
||||
|
||||
export function createTraitorDeclaration(
|
||||
context: TraitorDeclarationContext,
|
||||
voter: string,
|
||||
): TraitorDeclaration {
|
||||
validateContext(context);
|
||||
const elements = declaredElements(context, voter);
|
||||
const held = context.players.find((player) => player.id === voter)!.traitors;
|
||||
requireDeclaration(
|
||||
Array.isArray(held) && held.includes(elements.identity),
|
||||
'The declaring player must hold the matching physical traitor identity.',
|
||||
);
|
||||
const receipt = { event: context.event, voter, ...elements, signature: '' };
|
||||
receipt.signature = signature(context, receipt);
|
||||
return receipt;
|
||||
}
|
||||
|
||||
/** Later hand exchanges do not retract an already committed declaration. */
|
||||
export function validateTraitorDeclaration(
|
||||
context: TraitorDeclarationContext,
|
||||
receipt: TraitorDeclaration,
|
||||
): void {
|
||||
validateContext(context);
|
||||
requireDeclaration(
|
||||
receipt &&
|
||||
Object.keys(receipt).sort().join(',') ===
|
||||
'beneficiary,event,identity,leader,signature,target,voter' &&
|
||||
receipt.event === context.event &&
|
||||
validId(receipt.voter),
|
||||
'Invalid traitor declaration receipt.',
|
||||
);
|
||||
const expected = declaredElements(context, receipt.voter);
|
||||
requireDeclaration(
|
||||
receipt.beneficiary === expected.beneficiary &&
|
||||
receipt.target === expected.target &&
|
||||
receipt.leader === expected.leader &&
|
||||
receipt.identity === expected.identity &&
|
||||
receipt.signature === signature(context, receipt),
|
||||
'The traitor declaration lost its original battle or identity binding.',
|
||||
);
|
||||
}
|
||||
|
||||
/** Missing version AND map are the explicit legacy compatibility boundary. */
|
||||
export function validateTraitorDeclarations(
|
||||
context: TraitorDeclarationContext,
|
||||
calls: Readonly<Record<string, boolean | undefined>>,
|
||||
receipts: Readonly<Record<string, TraitorDeclaration>> | undefined,
|
||||
version?: 1,
|
||||
): void {
|
||||
if (version === undefined && receipts === undefined) return;
|
||||
requireDeclaration(
|
||||
version === 1 &&
|
||||
receipts &&
|
||||
typeof receipts === 'object' &&
|
||||
!Array.isArray(receipts) &&
|
||||
calls &&
|
||||
typeof calls === 'object' &&
|
||||
!Array.isArray(calls),
|
||||
'The traitor declaration version or receipt map is missing.',
|
||||
);
|
||||
validateContext(context);
|
||||
for (const [voter, called] of Object.entries(calls)) {
|
||||
requireDeclaration(
|
||||
context.players.some((player) => player.id === voter) &&
|
||||
(called === undefined || typeof called === 'boolean'),
|
||||
'Invalid traitor declaration call.',
|
||||
);
|
||||
if (called === true)
|
||||
requireDeclaration(
|
||||
has(receipts, voter),
|
||||
'A called traitor has no declaration receipt.',
|
||||
);
|
||||
}
|
||||
for (const [voter, receipt] of Object.entries(receipts)) {
|
||||
requireDeclaration(
|
||||
has(calls, voter) &&
|
||||
calls[voter] !== undefined &&
|
||||
receipt?.voter === voter,
|
||||
'A traitor declaration receipt has no matching declared call.',
|
||||
);
|
||||
validateTraitorDeclaration(context, receipt);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user