Compose classic Nexus with Tech and Advanced Stronghold Cards

This commit is contained in:
Quintine committed 2026-10-04 17:01:00 +11:00
1 parent b8585cdd92
commit 8ceee7afd0
17 files changed
+1250 -28

No files matched your search

+259
View File
@@ -0,0 +1,259 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game,
} from '../game/engine';
import { territory } from '../game/board';
import { type Card } from '../game/cards';
import { validateNexusCards, nexusCardMode } from '../game/nexus-cards';
import { ownedTech } from '../game/tech-tokens';
import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote';
import { nextStrongholdFactionsNativeStep, type StrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import { nexusInventory, orderNexusSpice } from './fixture-nexus-cards';
export type NexusModuleBattleOptions = {
initial?: Game;
family?: 'emperor' | 'retention';
tech?: boolean;
strongholds?: boolean;
advanced?: boolean;
};
export type NexusModuleBattleFixture = {
initial: Game;
afterSetup: Game;
beforeFirstMentat: Game;
firstMentatStep: StrongholdFactionsNativeStep;
afterFirstMentat: Game;
beforeBattle: Game;
battleStep: StrongholdFactionsNativeStep;
game: Game;
owner: string;
opponent: string;
observer: string;
location: string;
family: 'emperor' | 'retention';
played: Card;
unused: Card;
defense: Card;
planActions: StrongholdFactionsNativeStep[];
actions: StrongholdFactionsNativeStep[];
staging: string[];
};
export const nexusModuleBattlePlayer = (game: Game, actor: string) => {
const player = game.players.find(p => p.id === actor);
assert.ok(player, `Missing classic Nexus seat ${actor}`);
return player;
};
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
/** Extend the original native continuation only for actual Nexus aftermath.
* Unsealed private plans remain a human boundary, not bot-invented fixture state. */
export function nextNexusModuleBattleStep(game: Game): StrongholdFactionsNativeStep | null {
if (!game.phaseOpening && !game.response) {
const d = game.decision;
if (d?.kind === 'wormRide')
return { actor: d.player, action: { type: 'decision', accept: false } };
if (d?.kind === 'nexusFremenCunningOffer' || d?.kind === 'nexusFremenCunningRide')
return { actor: d.player, action: { type: 'decision', event: d.event, accept: false } };
if (d?.kind === 'moritaniRetention' && d.source === 'nexus')
return { actor: d.player, action: { type: 'decision', event: d.event, keep: null } };
if (d?.kind === 'nexusChoamInspection')
return { actor: d.player, action: { type: 'decision', event: d.event, inspect: false } };
if (d?.kind === 'techToken')
return { actor: d.player, action: { type: 'decision', token: d.choices[0] } };
}
return nextStrongholdFactionsNativeStep(game);
}
function entropy<T>(fn: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
assert.ok(array);
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try { return fn(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
function run(game: Game, step: StrongholdFactionsNativeStep, actions: StrongholdFactionsNativeStep[]): Game {
actions.push(structuredClone(step));
return entropy(() => applyAction(game, step.actor, step.action));
}
function advance(game: Game, until: (state: Game) => boolean, actions: StrongholdFactionsNativeStep[]): Game {
for (let n = 0; n < 1800; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextNexusModuleBattleStep(game);
assert.ok(step, `Human plan boundary before requested continuation at ${game.turn}/${game.phase}`);
// Real dials, not a staged Storm position; both printed sites survive.
if (step.action.type === 'stormDial' && game.turn === 1) step.action.amount = 0;
game = run(game, step, actions);
}
throw Error('Classic Nexus battle continuation did not reach its boundary');
}
function reserveBoard(game: Game): void {
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, count) => sum + count, 0);
player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, count) => sum + count, 0);
player.elites.forces = {};
}
}
}
function place(game: Game, actor: string, location: string, count: number): void {
const player = nexusModuleBattlePlayer(game, actor);
assert.ok(player.reserves - (player.elites?.reserves ?? 0) >= count);
player.reserves -= count;
player.forces[location] = count;
}
function hold(game: Game, actor: string, kind: Card['kind']): Card {
const index = game.deck.findIndex(card => card.kind === kind);
assert.ok(index >= 0, `Missing canonical ${kind}`);
const card = game.deck.splice(index, 1)[0];
nexusModuleBattlePlayer(game, actor).hand.push(card);
return card;
}
/** Genuine all-twelve setup, actual turn-two worm, reciprocal observer alliance
* and applyAction closing Nexus deals. Conserved physical deck order is explicit.
* Phases, wallets, first-Storm Tech and first END Mentat custody are never staged. */
export function createNexusModuleBattleFixture(options: NexusModuleBattleOptions = {}): NexusModuleBattleFixture {
const family = options.family ?? 'retention', tech = options.tech ?? true;
const strongholds = options.strongholds ?? true, advanced = options.advanced ?? true;
assert.ok(tech || strongholds);
if (strongholds || family === 'emperor') assert.equal(advanced, true);
const actions: StrongholdFactionsNativeStep[] = [], staging: string[] = [];
let game = options.initial ? structuredClone(options.initial)
: createGame('NEXUSMODULEBATTLE', newPlayer('emperor', 'Emperor', 'emperor'), advanced, []);
if (!options.initial) {
joinGame(game, newPlayer('guild', 'Guild', 'guild'));
joinGame(game, newPlayer('atreides', 'Atreides', 'atreides'));
joinGame(game, newPlayer('fremen', 'Fremen', 'fremen'));
}
assert.deepEqual(game.players.map(p => p.faction).sort(), ['atreides', 'emperor', 'fremen', 'guild']);
assert.deepEqual(game.expansions, []);
assert.equal(game.advanced, advanced);
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 && game.players.every(p => !p.hand.length));
if (game.status === 'lobby') {
// Offline audit configuration only; no public Nexus start/toggle is added.
if (!game.nexusCards) game.nexusCards = { cards: null, phase: null };
if (!!game.techTokens !== tech) game = run(game, { actor: game.host, action: { type: 'techTokens', enabled: tech } }, actions);
if (!!game.strongholdCards !== strongholds) game = run(game, { actor: game.host, action: { type: 'strongholdCards', enabled: strongholds } }, actions);
for (const player of game.players) if (!player.ready) game = run(game, { actor: player.id, action: { type: 'ready' } }, actions);
game = entropy(() => initializeNexusGameForAudit(game));
}
assert.equal(!!game.techTokens, tech);
assert.equal(!!game.strongholdCards, strongholds);
const initial = structuredClone(game);
game = advance(game, state => state.status === 'playing', actions);
const afterSetup = structuredClone(game);
const owner = game.players.find(p => p.faction === 'emperor')!.id;
const opponent = game.players.find(p => p.faction === 'guild')!.id;
const observer = game.players.find(p => p.faction === 'atreides')!.id;
const partner = game.players.find(p => p.faction === 'fremen')!.id;
const location = `arrakeen:${territory('arrakeen').sectors[0]}`;
validateNexusCards(game.nexusCards!.cards!, game.players);
game = advance(game, state => state.phase === 1 && clean(state), actions);
orderNexusSpice(game, ['land', 'land']);
game = advance(game, state => state.phase === 5 && clean(state), actions);
reserveBoard(game);
game = advance(game, state => state.phase === 8 && clean(state), actions);
place(game, owner, location, 1);
staging.push('Before real first END Mentat, conserved one ordinary Emperor counter controls Arrakeen; subtype reserves, real banks and first-Storm Tech retained.');
let beforeFirstMentat!: Game, firstMentatStep!: StrongholdFactionsNativeStep;
while (game.turn === 1) {
const step = nextNexusModuleBattleStep(game); assert.ok(step);
const before = structuredClone(game);
game = run(game, step, actions);
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = step; }
}
const afterFirstMentat = structuredClone(game);
if (strongholds) assert.equal(game.strongholdCards!.owners.arrakeen, owner);
game = advance(game, state => state.phase === 1 && clean(state), actions);
orderNexusSpice(game, ['worm', 'land', 'land']);
game = advance(game, state => state.nexus === true && !state.spiceWindow, actions);
game = run(game, { actor: observer, action: { type: 'alliance', target: partner } }, actions);
game = run(game, { actor: partner, action: { type: 'alliance', target: observer } }, actions);
game = advance(game, state => state.nexusCards?.phase?.stage === 'drawing', actions);
const deals = [
{ actor: owner, card: family === 'emperor' ? 'emperor' as const : 'choam' as const },
{ actor: opponent, card: 'moritani' as const },
];
for (const deal of deals) {
const cards = game.nexusCards!.cards!;
assert.ok(cards.deck.includes(deal.card));
cards.deck = [deal.card, ...cards.deck.filter(card => card !== deal.card)];
game = run(game, { actor: deal.actor, action: {
type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0,
} }, actions);
}
assert.equal(nexusCardMode(family === 'emperor' ? 'emperor' : 'choam', 'emperor', game.players.map(p => p.faction)), family === 'emperor' ? 'cunning' : 'secretAlly');
assert.equal(nexusCardMode('moritani', 'guild', game.players.map(p => p.faction)), 'secretAlly');
staging.push('Conserved all12 Nexus deck order; actual turn-two worm and Atreides/Fremen alliance open original applyAction closing deals for unallied Emperor/Guild.');
game = advance(game, state => state.phase === 5 && clean(state), actions);
reserveBoard(game);
// Return genuine setup hands before selecting canonical controlled battle cards.
for (const player of game.players) game.deck.push(...player.hand.splice(0));
place(game, owner, location, 7);
place(game, opponent, location, 6);
const played = hold(game, opponent, 'projectile');
const unused = hold(game, opponent, 'worthless');
const defense = hold(game, owner, 'shield');
staging.push('Turn-two movement: conserved seven ordinary Emperor and six Guild board counters; original projectile/worthless/shield cards relocated from physical deck, no bank/owner/phase writes.');
if (tech) assert.equal(ownedTech(game.techTokens, opponent).length, 1);
game = advance(game, state => state.phase === 6 && clean(state), actions);
const beforeBattle = structuredClone(game);
const battleStep: StrongholdFactionsNativeStep = { actor: game.active!, action: { type: 'chooseBattle', territory: 'arrakeen', target: game.active === owner ? opponent : owner } };
assert.ok([owner, opponent].includes(battleStep.actor));
game = run(game, battleStep, actions);
game = advance(game, state => clean(state) && !!state.battle && (!state.battle.preLeader || state.battle.preLeader.closed) && !state.battle.preparation, actions);
const best = nexusModuleBattlePlayer(game, owner).leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0];
const weakest = nexusModuleBattlePlayer(game, opponent).leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0];
assert.ok(best && weakest);
const planActions: StrongholdFactionsNativeStep[] = [
{ actor: owner, action: { type: 'battlePlan', leader: best.id, dial: 6, support: advanced ? 6 : 0, defense: defense.id } },
{ actor: opponent, action: { type: 'battlePlan', leader: weakest.id, dial: 0, support: 0, weapon: played.id } },
];
nexusInventory(game);
return { initial, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeBattle, battleStep, game, owner, opponent, observer, location, family, played, unused, defense, planActions, actions, staging };
}
export function revealNexusModuleBattle(fixture: NexusModuleBattleFixture, game = fixture.game, cunning = false): Game {
if (cunning) {
const offer = viewGame(game, fixture.owner).nexusSardaukar!.offer!;
assert.equal(offer.blocked, null);
game = applyAction(game, fixture.owner, { type: 'nexusSardaukar', event: offer.event });
game = advance(game, clean, []);
}
for (const step of fixture.planActions) {
const action: Action = { ...step.action };
if (cunning && step.actor === fixture.owner) action.support = 3;
game = applyAction(game, step.actor, action);
}
assert.ok(game.battle?.revealed);
return game;
}
/** Quote actual holders, committed plans and physical ordinary/elite pools. */
export function quoteNexusModuleBattle(game: Game) {
const battle = game.battle; assert.ok(battle?.revealed);
const side = (actor: string): ResolutionCombatant => {
const player = nexusModuleBattlePlayer(game, actor), view = viewGame(game, actor).battle!;
assert.ok(view.ownForces);
return { id: actor, faction: player.faction, spice: player.spice, hand: player.hand,
plan: battle.plans[actor], leader: player.leaders.find(l => l.id === battle.plans[actor].leader),
forces: view.ownForces, stronghold: view.strongholdEffects[actor] };
};
return quoteBattleResolution({ advanced: game.advanced, turn: game.turn, territory: battle.territory,
aggressor: viewGame(game, game.host).battle!.aggressor,
attacker: side(battle.attacker), defender: side(battle.defender),
voters: viewGame(game, game.host).battle!.traitorVoters.map(actor => ({ id: actor, beneficiary: actor,
called: battle.traitorCalls[actor] ?? false, traitors: nexusModuleBattlePlayer(game, actor).traitors })),
participants: game.players, physicalCards: [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)],
pendingAuditorPresent: !!game.pendingAuditor, pendingRetentionPresent: !!game.moritaniRetention,
nexusMoritani: true });
}
export function advanceNexusModuleBattle(game: Game, until: (state: Game) => boolean): Game {
return advance(game, until, []);
}
+264
View File
@@ -0,0 +1,264 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game,
} from '../game/engine';
import { territory } from '../game/board';
import type { FactionId } from '../game/catalog';
import { quoteNexusGuildSecretShipment } from '../game/nexus-guild-secret-ally';
import { quoteNexusRicheseShipment } from '../game/nexus-richese';
import { ownedTech, type TechId } from '../game/tech-tokens';
import { classicNexusModulesProfile } from '../game/nexus-module-profile';
import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
import { nexusInventory, orderNexusSpice } from './fixture-nexus-cards';
import { skillsTechPaymentsEmptyDestination } from './fixture-skills-tech-payments';
export type NexusModulePaymentKind = 'guild-shipment' | 'richese-shipment' | 'emperor-revival' | 'fremen-revival' | 'emperor-purchase';
export type NexusModulePaymentStep = { actor: string; action: Action };
export type NexusModulePaymentsOptions = {
initial?: Game;
kind?: NexusModulePaymentKind;
advanced?: boolean;
tech?: boolean;
strongholds?: boolean;
ownerFaction?: 'atreides' | 'harkonnen' | 'emperor' | 'fremen' | 'guild';
nativeGuild?: boolean;
tanks?: number;
eliteTanks?: number;
};
export type NexusModulePaymentsFixture = {
game: Game;
beforePayment: Game;
setup: Game;
actions: NexusModulePaymentStep[];
staging: string[];
actor: string;
kind: NexusModulePaymentKind;
paymentAction: Action;
amount: number;
cost: number;
token: TechId;
tokenOwner: string | null;
tokenCount: number;
destination?: string;
};
/** Use the existing original continuation policy, never a fixture phase jump. */
export function nextNexusModulePaymentsNativeStep(game: Game): NexusModulePaymentStep | null {
if (game.nexusCards?.phase?.stage === 'drawing' && !game.response && !game.decision) {
const phase = game.nexusCards.phase;
const actor = phase.eligible.find(id => !phase.done.includes(id));
if (actor) return { actor, action: { type: 'nexusCardChoice', turn: game.turn,
card: game.nexusCards.cards!.hands[actor], choice: 'keep', ownRedraws: 0 } };
}
return nextSpiceBankerIncomeNativeStep(game);
}
function step(game: Game, actions?: NexusModulePaymentStep[]): Game {
const next = nextNexusModulePaymentsNativeStep(game);
assert.ok(next, 'The original Nexus payment continuation needs an action.');
actions?.push(structuredClone(next));
return applyAction(game, next.actor, next.action);
}
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
export function advanceNexusModulePaymentsToPhase(state: Game, phase: number,
actions?: NexusModulePaymentStep[]): Game {
let game = state;
const turn = game.turn;
for (let n = 0; (game.phase < phase || !clean(game)) && n < 1200; n++) {
assert.equal(game.turn, turn);
game = step(game, actions);
}
assert.equal(game.turn, turn);
assert.ok(game.phase >= phase && clean(game));
return game;
}
function deterministic<T>(run: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
assert.ok(array);
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try { return run(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
function reserveBoard(game: Game, staging: string[]) {
for (const player of game.players) {
const count = Object.values(player.forces).reduce((a, b) => a + b, 0);
player.reserves += count;
player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((a, b) => a + b, 0);
player.elites.forces = {};
}
if (player.advisors) player.advisors = {};
if (count) staging.push(`Conserved ${player.id}: ${count} board counters to reserves.`);
}
}
/** Fresh classic lobby and original setup/Storm/first-Mentat ownership. No
* manufactured wallet, phase, Tech owner, Stronghold owner or earned receipt. */
export function initializeNexusModulePaymentsSetup(options: NexusModulePaymentsOptions = {}): Game {
const kind = options.kind ?? 'guild-shipment';
const ownerFaction = options.ownerFaction ?? (kind === 'fremen-revival' ? 'emperor' : 'atreides');
let game = options.initial ? structuredClone(options.initial)
: createGame('NEXUSMODULEPAYMENTS', newPlayer('payer', 'Payer', ownerFaction),
options.advanced ?? !!options.strongholds);
if (game.status === 'setup') {
assert.equal(game.turn, 1); assert.equal(game.phase, 0);
assert.ok(game.players.every(p => p.hand.length === 0));
assert.ok(classicNexusModulesProfile(game));
assert.equal(!!game.techTokens, options.tech !== false);
assert.equal(!!game.strongholdCards, !!options.strongholds);
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
assert.ok(game.players.some(p => p.faction === ownerFaction));
return game;
}
assert.equal(game.status, 'lobby', 'Use the fresh authenticated undealt lobby.');
if (!options.initial) {
const forbidden = kind === 'guild-shipment' ? 'guild' : kind === 'fremen-revival' ? 'fremen'
: kind.startsWith('emperor-') ? 'emperor' : 'richese';
const candidates: FactionId[] = options.nativeGuild ? ['guild', 'harkonnen', 'atreides', 'emperor']
: ['harkonnen', 'atreides', 'emperor', 'guild'];
for (const faction of candidates) if (game.players.length < 3 && faction !== forbidden && faction !== ownerFaction)
joinGame(game, newPlayer(faction, faction, faction));
}
assert.ok(game.players.some(p => p.faction === ownerFaction));
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
if (options.tech !== false && !game.techTokens)
game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
if (options.strongholds && !game.strongholdCards)
game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true });
if (!game.nexusCards) game.nexusCards = { cards: null, phase: null };
for (const player of game.players) if (!player.ready)
game = applyAction(game, player.id, { type: 'ready' });
return deterministic(() => initializeNexusGameForAudit(game));
}
/** Native second-turn Nexus supplies the singleton through an actual draw.
* Only conserved board/card ordering and reserve-to-Tanks casualties are staged.
* Stops before the real human payment/spend; initial preserves authenticated IDs. */
export function createNexusModulePaymentsFixture(options: NexusModulePaymentsOptions = {}): NexusModulePaymentsFixture {
return deterministic(() => {
const actions: NexusModulePaymentStep[] = [], staging: string[] = [];
const kind = options.kind ?? 'guild-shipment';
const ownerFaction = options.ownerFaction ?? (kind === 'fremen-revival' ? 'emperor' : 'atreides');
const setup = initializeNexusModulePaymentsSetup(options);
let game = setup;
for (let n = 0; game.status === 'setup' && n < 200; n++) game = step(game, actions);
assert.equal(game.status, 'playing');
const actor = game.players.find(p => p.faction === ownerFaction)!.id;
reserveBoard(game, staging);
if (options.strongholds) {
const player = game.players.find(p => p.id === actor)!;
player.reserves--;
player.forces['arrakeen:10'] = 1;
staging.push(`Conserved ${actor}: one reserve counter to Arrakeen before genuine first Mentat claim.`);
}
// A real full first turn precedes a real second-turn worm/Nexus. First-turn
// land-only ordering avoids ignored drawn worms, without adding spice cards.
let firstOrdered = false;
for (let n = 0; !(game.turn === 2 && game.phase === 1 && clean(game)) && n < 1400; n++) {
if (game.turn === 1 && game.phase === 1 && !firstOrdered) {
orderNexusSpice(game, ['land', 'land']); firstOrdered = true;
}
game = step(game, actions);
}
assert.equal(game.turn, 2);
assert.equal(game.phase, 1);
if (options.strongholds) assert.equal(game.strongholdCards!.owners.arrakeen, actor);
reserveBoard(game, staging);
orderNexusSpice(game, ['worm', 'land', 'land']);
let allied = false;
for (let n = 0; game.nexusCards?.phase?.stage !== 'drawing' && n < 200; n++) {
if (game.nexus && !game.spiceWindow && !game.spiceResolution && clean(game) && !allied) {
const others = game.players.filter(p => p.id !== actor);
assert.ok(others.length >= 2);
for (const [from, to] of [[others[0].id, others[1].id], [others[1].id, others[0].id]]) {
const action: Action = { type: 'alliance', target: to };
actions.push({ actor: from, action });
game = applyAction(game, from, action);
}
allied = true;
}
game = step(game, actions);
}
assert.ok(allied, 'The real Nexus negotiations must precede the singleton draw.');
assert.equal(game.nexusCards?.phase?.stage, 'drawing');
const card: FactionId = kind === 'guild-shipment' ? 'guild' : kind === 'richese-shipment' ? 'richese'
: kind.startsWith('emperor-') ? 'emperor' : 'fremen';
const cards = game.nexusCards!.cards!;
assert.ok(cards.deck.includes(card));
cards.deck = [card, ...cards.deck.filter(c => c !== card)];
const draw: NexusModulePaymentStep = { actor, action: { type: 'nexusCardChoice', turn: game.turn,
card: cards.hands[actor], choice: 'draw', ownRedraws: 0 } };
actions.push(draw);
game = applyAction(game, actor, draw.action);
assert.equal(game.nexusCards!.cards!.hands[actor], card);
while (game.nexusCards?.phase?.stage === 'drawing') game = step(game, actions);
const shipment = kind.endsWith('shipment');
const purchase = kind === 'emperor-purchase';
game = advanceNexusModulePaymentsToPhase(game, shipment ? 5 : purchase ? 3 : 4, actions);
let paymentAction: Action, cost = 0, destination: string | undefined;
const amount = shipment ? 5 : purchase ? 1 : 3;
if (purchase) {
for (let n = 0; game.decision?.kind !== 'auctionPayment' && n < 100; n++) {
if (game.auction?.active === actor && !game.auction.bid) {
const action: Action = { type: 'bid', amount: 2 };
actions.push({ actor, action });
game = applyAction(game, actor, action);
} else game = step(game, actions);
}
assert.equal(game.phase, 3);
assert.equal(game.decision?.kind, 'auctionPayment');
assert.equal(game.decision!.player, actor);
assert.equal(game.auction!.bidder, actor);
assert.equal(game.auction!.bid, 2);
assert.equal(game.auction!.allyPayment ?? 0, 0);
cost = game.auction!.bid;
paymentAction = { type: 'nexusEmperorPurchase', event: viewGame(game, actor).nexusEmperorSecretAlly!.event };
} else if (shipment) {
for (let n = 0; game.active !== actor && n < 30; n++) game = step(game, actions);
assert.equal(game.active, actor);
const target = skillsTechPaymentsEmptyDestination(game);
destination = `${target.territory}:${target.sector}`;
const player = game.players.find(p => p.id === actor)!;
cost = kind === 'guild-shipment' ? quoteNexusGuildSecretShipment(territory(target.territory).type, amount).cost
: quoteNexusRicheseShipment({ faction: player.faction, halfRate: player.faction === 'guild' },
territory(target.territory).type, amount).cost;
const view = viewGame(game, actor);
const event = kind === 'guild-shipment' ? view.nexusGuildSecretAlly!.event : view.nexusRichese!.event;
paymentAction = { type: 'ship', ...target, amount, nexus: event };
} else {
const player = game.players.find(p => p.id === actor)!;
const tanks = options.tanks ?? 6, elite = options.eliteTanks ?? 0;
assert.ok(tanks <= player.reserves && elite <= (player.elites?.reserves ?? 0));
player.reserves -= tanks; player.tanks += tanks;
if (player.elites) { player.elites.reserves -= elite; player.elites.tanks += elite; }
staging.push(`Conserved ${actor}: ${tanks} reserves to Tanks, including ${elite} actual elites.`);
const view = viewGame(game, actor);
const event = kind === 'emperor-revival' ? view.nexusEmperorSecretAlly!.event : view.nexusFremenRevival!.event;
paymentAction = { type: kind === 'emperor-revival' ? 'nexusEmperorRevive' : 'nexusFremenRevive',
event, elite: elite ? 1 : 0 };
}
nexusInventory(game);
const token: TechId = shipment ? 'heighliners' : purchase ? 'production' : 'axlotl';
const tokenOwner = game.techTokens?.[token].owner ?? null;
const tokenCount = tokenOwner ? ownedTech(game.techTokens, tokenOwner).length : 0;
return { game, beforePayment: structuredClone(game), setup, actions, staging, actor, kind,
paymentAction, amount, cost, token, tokenOwner, tokenCount, destination };
});
}
export function payNexusModulePaymentsFixture(fixture: NexusModulePaymentsFixture): Game {
let game = applyAction(fixture.game, fixture.actor, fixture.paymentAction);
for (let n = 0; (!clean(game) || game.pendingShipment || game.pendingRevival) && n < 200; n++)
game = step(game);
assert.ok(clean(game) && !game.pendingShipment && !game.pendingRevival);
return game;
}
+180
View File
@@ -0,0 +1,180 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { ownedTech } from '../game/tech-tokens';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { nexusInventory } from './fixture-nexus-cards';
import {
createNexusModuleBattleFixture, revealNexusModuleBattle, quoteNexusModuleBattle,
advanceNexusModuleBattle, nexusModuleBattlePlayer,
} from './fixture-nexus-module-battles';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
for (const modules of [
{ label: 'Basic Tech-only', advanced: false, tech: true, strongholds: false },
{ label: 'Advanced Tech + actual Arrakeen card', advanced: true, tech: true, strongholds: true },
]) void test(`${modules.label}: physical Moritani retention precedes winner Tech, then CHOAM inspects only the unused card`, () => {
const fixture = createNexusModuleBattleFixture({ ...modules, family: 'retention' });
const revealed = revealNexusModuleBattle(fixture);
const quote = quoteNexusModuleBattle(revealed);
assert.equal(quote.winner, fixture.owner);
assert.deepEqual(quote.destroyedArmies, [fixture.opponent]);
const payment = quote.payments.find(p => p.player === fixture.owner);
if (modules.advanced) {
assert.ok(payment);
assert.equal(payment.cost, 4);
assert.equal(payment.bankSupport, 2);
assert.equal(payment.allyPayment, 0);
} else assert.deepEqual(quote.payments, [], 'Basic force commitments consume no spice support.');
assert.equal(quote.retention?.source, 'nexus');
const token = ownedTech(revealed.techTokens, fixture.opponent)[0];
assert.ok(token);
const bank = nexusModuleBattlePlayer(revealed, fixture.owner).spice;
const guildTanks = nexusModuleBattlePlayer(revealed, fixture.opponent).tanks;
const emperorTanks = nexusModuleBattlePlayer(revealed, fixture.owner).tanks;
let game = advanceNexusModuleBattle(revealed, state => state.decision?.kind === 'moritaniRetention');
assert.equal(nexusModuleBattlePlayer(game, fixture.owner).spice, bank - (payment?.ownPayment ?? 0));
assert.equal(nexusModuleBattlePlayer(game, fixture.opponent).tanks, guildTanks + 6);
assert.equal(nexusModuleBattlePlayer(game, fixture.owner).tanks, emperorTanks + 6);
assert.equal(game.techTokens![token].owner, fixture.opponent);
assert.equal(game.lastBattleContext?.nexusChoamInspection?.stage, 'pending');
assert.equal(game.lastBattleContext?.nexusMoritaniRetention?.stage, 'offer');
assert.equal(viewGame(game, fixture.opponent).nexusMoritaniRetention?.canKeep, true);
const event = game.lastBattleContext!.event;
const before = reload(game);
assert.throws(() => applyAction(game, fixture.owner, { type: 'decision', event, keep: fixture.played.id }));
assert.throws(() => applyAction(game, fixture.opponent, { type: 'decision', event, keep: fixture.unused.id }));
assert.throws(() => applyAction(game, fixture.owner, { type: 'decision', event, inspect: true }));
assert.deepEqual(game, before);
const keep = { type: 'decision', event, keep: fixture.played.id };
game = applyAction(game, fixture.opponent, keep);
// Single original token transfer occurs inside the retention continuation.
assert.equal(game.techTokens![token].owner, fixture.owner);
assert.equal(game.decision?.kind, 'nexusChoamInspection');
assert.equal(game.lastBattleContext?.nexusMoritaniRetention?.kept, fixture.played.id);
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'moritani').length, 1);
assert.ok(nexusModuleBattlePlayer(game, fixture.opponent).hand.some(card => card.id === fixture.played.id));
assert.equal(game.discard.some(card => card.id === fixture.played.id), false);
assert.throws(() => applyAction(game, fixture.opponent, keep));
const inspect = { type: 'decision', event, inspect: true };
assert.throws(() => applyAction(game, fixture.owner, { ...inspect, card: fixture.played.id }));
game = applyAction(game, fixture.owner, inspect);
assert.equal(game.nexusChoamInsight?.card.id, fixture.unused.id);
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'choam').length, 1);
assert.ok(nexusModuleBattlePlayer(game, fixture.opponent).hand.some(card => card.id === fixture.played.id));
assert.ok(nexusModuleBattlePlayer(game, fixture.opponent).hand.some(card => card.id === fixture.unused.id));
assert.ok(nexusModuleBattlePlayer(game, fixture.owner).hand.some(card => card.id === fixture.defense.id));
assert.equal(game.lastBattleContext?.nexusChoamInspection?.stage, 'complete');
assert.equal(game.phase, 7);
assert.throws(() => applyAction(game, fixture.owner, inspect));
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
nexusInventory(game);
});
void test('Strongholds-only all-pass keeps Nexus cards, discards the original losing weapon once and preserves actual Arrakeen support', () => {
const fixture = createNexusModuleBattleFixture({ tech: false, strongholds: true });
const revealed = revealNexusModuleBattle(fixture);
const quote = quoteNexusModuleBattle(revealed);
const balance = nexusModuleBattlePlayer(revealed, fixture.owner).spice;
let game = advanceNexusModuleBattle(revealed, state => state.decision?.kind === 'moritaniRetention');
const event = game.lastBattleContext!.event;
game = applyAction(game, fixture.opponent, { type: 'decision', event, keep: null });
assert.equal(game.decision?.kind, 'nexusChoamInspection');
assert.equal(game.discard.filter(card => card.id === fixture.played.id).length, 1);
assert.deepEqual(nexusModuleBattlePlayer(game, fixture.opponent).hand.map(card => card.id), [fixture.unused.id]);
const collection = quoteSpiceCollection(game).receipts.find(p => p.player === fixture.owner)!;
game = applyAction(game, fixture.owner, { type: 'decision', event, inspect: false });
assert.equal(game.nexusCards!.cards!.hands[fixture.owner], 'choam');
assert.equal(game.nexusCards!.cards!.hands[fixture.opponent], 'moritani');
assert.equal(nexusModuleBattlePlayer(game, fixture.owner).spice,
balance - quote.payments.find(p => p.player === fixture.owner)!.ownPayment +
collection.strongholds + collection.collected);
assert.equal(nexusModuleBattlePlayer(game, fixture.owner).forces[fixture.location], 1);
assert.equal(game.strongholdCards!.owners.arrakeen, fixture.owner);
assert.equal(game.phase, 7);
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
nexusInventory(game);
});
void test('Tech-only Emperor passing Cunning retains its Nexus and pays ordinary support before losing ordinary counters', () => {
const fixture = createNexusModuleBattleFixture({ family: 'emperor', strongholds: false });
const revealed = revealNexusModuleBattle(fixture);
const quote = quoteNexusModuleBattle(revealed);
const payment = quote.payments.find(p => p.player === fixture.owner)!;
assert.equal(payment.ownPayment, 6);
assert.equal(payment.bankSupport, 0);
assert.deepEqual(quote.casualties!.options, [{ normal: 6, elite: 0, paidNormal: 6, paidElite: 0 }]);
const balance = nexusModuleBattlePlayer(revealed, fixture.owner).spice;
const elites = structuredClone(nexusModuleBattlePlayer(revealed, fixture.owner).elites);
const settled = advanceNexusModuleBattle(revealed, state => state.decision?.kind === 'nexusChoamInspection');
const collection = quoteSpiceCollection(settled).receipts.find(p => p.player === fixture.owner)!;
const done = advanceNexusModuleBattle(settled, state => state.phase === 7);
assert.equal(done.nexusCards!.cards!.hands[fixture.owner], 'emperor');
assert.equal(done.nexusCards!.cards!.hands[fixture.opponent], 'moritani');
assert.equal(nexusModuleBattlePlayer(done, fixture.owner).forces[fixture.location], 1);
assert.equal(nexusModuleBattlePlayer(done, fixture.owner).spice,
balance - 6 + collection.strongholds + collection.collected);
assert.deepEqual(nexusModuleBattlePlayer(done, fixture.owner).elites, elites);
assert.equal(done.discard.filter(card => card.id === fixture.played.id).length, 1);
const token = ownedTech(revealed.techTokens, fixture.opponent)[0];
assert.equal(done.techTokens![token].owner, fixture.owner);
assert.deepEqual(normalizeAutomaticGame(reload(done)), done);
nexusInventory(done);
});
void test('Emperor Cunning counts ordinary forces temporarily, pays actual-holder Arrakeen support and loses no starred counters', () => {
const fixture = createNexusModuleBattleFixture({ family: 'emperor' });
const originalElites = structuredClone(nexusModuleBattlePlayer(fixture.game, fixture.owner).elites);
const revealed = revealNexusModuleBattle(fixture, fixture.game, true);
const quote = quoteNexusModuleBattle(revealed);
assert.equal(quote.winner, fixture.owner);
assert.deepEqual(quote.casualties!.forces, viewGame(revealed, fixture.owner).battle!.ownForces);
assert.equal(quote.casualties!.forces.temporaryElite, 5);
assert.equal(quote.casualties!.forces.normal, 7);
assert.equal(quote.casualties!.forces.elite, 0);
assert.deepEqual(quote.casualties!.options[0], { normal: 3, elite: 0, paidNormal: 3, paidElite: 0 });
const payment = quote.payments.find(p => p.player === fixture.owner)!;
assert.equal(payment.cost, 1);
assert.equal(payment.bankSupport, 2);
assert.equal(payment.ownPayment, 1);
assert.equal(payment.allyPayment, 0);
const bank = nexusModuleBattlePlayer(revealed, fixture.owner).spice;
const tanks = nexusModuleBattlePlayer(revealed, fixture.owner).tanks;
let game = advanceNexusModuleBattle(revealed, state => state.decision?.kind === 'moritaniRetention');
const event = game.lastBattleContext!.event;
assert.equal(nexusModuleBattlePlayer(game, fixture.owner).tanks, tanks + 3);
assert.equal(nexusModuleBattlePlayer(game, fixture.owner).forces[fixture.location], 4);
assert.deepEqual(nexusModuleBattlePlayer(game, fixture.owner).elites, originalElites);
assert.equal(nexusModuleBattlePlayer(game, fixture.owner).spice, bank - 1);
assert.equal(game.nexusSardaukarHistory?.length, 1);
assert.equal(game.nexusSardaukarHistory![0].casualties?.outcome, 'complete');
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
game = applyAction(game, fixture.opponent, { type: 'decision', event, keep: null });
assert.equal(viewGame(game, fixture.owner).nexusChoamInspection?.canInspect, false);
game = applyAction(game, fixture.owner, { type: 'decision', event, inspect: false });
assert.equal(game.phase, 7);
assert.deepEqual(nexusModuleBattlePlayer(game, fixture.owner).elites, originalElites);
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
assert.throws(() => applyAction(game, fixture.owner, { type: 'nexusSardaukar', event: game.nexusSardaukarHistory![0].receipt.event }));
nexusInventory(game);
});
void test('minimal legal bot continuation retains the played card, completes single-owner Tech and inspects the unused card', () => {
const fixture = createNexusModuleBattleFixture();
let game = advanceNexusModuleBattle(revealNexusModuleBattle(fixture), state => state.decision?.kind === 'moritaniRetention');
for (const actor of [fixture.opponent, fixture.owner]) {
const view = viewGame(game, actor);
view.players.find(p => p.id === actor)!.bot = 'Easy';
const action = botActions(view)[0];
assert.ok(action);
if (actor === fixture.opponent) assert.equal(action.keep, fixture.played.id);
else assert.equal(action.inspect, true);
game = applyAction(game, actor, action);
}
assert.equal(game.techTokens![ownedTech(fixture.game.techTokens, fixture.opponent)[0]].owner, fixture.owner);
assert.equal(game.nexusChoamInsight?.card.id, fixture.unused.id);
assert.equal(game.phase, 7);
nexusInventory(game);
});
+300
View File
@@ -0,0 +1,300 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer, viewGame,
type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { forceRevivalQuote, forceRevivalRemaining } from '../game/revival';
import { createStrongholdCards } from '../game/stronghold-cards';
import { createLeaderSkills } from '../game/leader-skills';
import { createTechTokens } from '../game/tech-tokens';
import { nexusInventory } from './fixture-nexus-cards';
import { holdNexusRicheseCard } from './fixture-nexus-richese';
import {
advanceNexusModulePaymentsToPhase, createNexusModulePaymentsFixture,
nextNexusModulePaymentsNativeStep, payNexusModulePaymentsFixture,
type NexusModulePaymentsFixture,
} from './fixture-nexus-module-payments';
function wallets(game: Game): Record<string, number> {
return Object.fromEntries(game.players.map(player => [player.id, player.spice]));
}
function rejected(game: Game, actor: string, action: Parameters<typeof applyAction>[2]) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'Rejected declarations spend neither physical card, counter nor spice.');
}
/** Empty Battle can auto-skip into Collection. Its independently quoted native
* stronghold income is not technology and cannot be mistaken for a second pile. */
function collectOnce(fixture: NexusModulePaymentsFixture, paid: Game, amount: number): Game {
const expected = wallets(paid);
let ended = advanceNexusModulePaymentsToPhase(paid, paid.phase + 1);
if (ended.phase === 7) {
for (const receipt of quoteSpiceCollection(paid).receipts) expected[receipt.player] = receipt.balance;
}
if (fixture.tokenOwner) expected[fixture.tokenOwner] += amount;
assert.deepEqual(wallets(ended), expected);
assert.equal(ended.techTokens![fixture.token].spice, 0);
const collection = advanceNexusModulePaymentsToPhase(ended, 7);
const beforeMentat = wallets(collection);
ended = advanceNexusModulePaymentsToPhase(collection, 8);
assert.deepEqual(wallets(ended), beforeMentat, 'Mentat must not pay a collected Tech pile again.');
assert.equal(ended.techTokens![fixture.token].spice, 0);
const next = nextNexusModulePaymentsNativeStep(ended);
assert.ok(next);
const continued = applyAction(ended, next.actor, next.action);
assert.deepEqual(wallets(continued), beforeMentat);
nexusInventory(continued);
return continued;
}
// E1 printed p5: real non-Guild off-planet shipment, once for each token controlled,
// held on Heighliners until phase end. Borrowed tariff is not faction identity.
// Publisher-authored source: http://boardgame.bg/dune%20ixians%20and%20tleilaxu%20rulebook.pdf
for (const kind of ['guild-shipment', 'richese-shipment'] as const) {
for (const advanced of [false, true]) {
void test(`${kind} in ${advanced ? 'Advanced with claimed Strongholds' : 'Basic'} pays the original invoice, arrives physically and collects Heighliners only at phase end`, () => {
const f = createNexusModulePaymentsFixture({ kind, advanced, strongholds: advanced });
const before = f.beforePayment.players.find(p => p.id === f.actor)!;
assert.equal(f.cost, kind === 'guild-shipment' ? 3 : 1);
const paid = payNexusModulePaymentsFixture(f);
const after = paid.players.find(p => p.id === f.actor)!;
assert.equal(after.faction, 'atreides');
assert.equal(after.reserves, before.reserves - 5);
assert.equal(after.forces[f.destination!], 5);
assert.equal(after.shipped, true);
assert.equal(after.moved, before.moved);
const expected = wallets(f.beforePayment);
expected[f.actor] -= f.cost;
assert.deepEqual(wallets(paid), expected, 'The earned token pile cannot fund this invoice or a later shipment yet.');
assert.equal(paid.techTokens!.heighliners.spice, f.tokenCount);
assert.equal(paid.techTokens!.heighliners.triggeredTurn, paid.turn);
assert.equal(paid.nexusCards!.cards!.hands[f.actor], null);
assert.equal(paid.nexusCards!.cards!.discard.filter(c => c === (kind === 'guild-shipment' ? 'guild' : 'richese')).length, 1);
rejected(paid, f.actor, f.paymentAction);
nexusInventory(paid);
collectOnce(f, paid, f.tokenCount);
});
}
}
void test('a stopped Richese borrowed shipment spends its singleton but no invoice, force or Heighliners income', () => {
const f = createNexusModulePaymentsFixture({ kind: 'richese-shipment', advanced: true, nativeGuild: true });
const guild = f.game.players.find(p => p.faction === 'guild')!;
const karama = holdNexusRicheseCard(f.game, guild.id, 'karama');
const before = wallets(f.game);
const reserves = f.game.players.find(p => p.id === f.actor)!.reserves;
let game = applyAction(f.game, f.actor, f.paymentAction);
assert.equal(game.decision?.kind, 'guildShipment');
assert.equal(game.pendingShipment!.amount, 5);
assert.equal(game.pendingShipment!.cost, 1);
assert.deepEqual(wallets(game), before, 'Interception precedes actual payment and arrival.');
assert.equal(game.techTokens!.heighliners.spice, 0);
game = applyAction(game, guild.id, { type: 'card', mode: 'special', card: karama.id });
for (let n = 0; (game.response || game.decision) && n < 100; n++) {
const next = nextNexusModulePaymentsNativeStep(game);
assert.ok(next);
game = applyAction(game, next.actor, next.action);
}
assert.ok(!game.response && !game.decision);
assert.deepEqual(wallets(game), before);
assert.equal(game.players.find(p => p.id === f.actor)!.reserves, reserves);
assert.equal(game.players.find(p => p.id === f.actor)!.forces[f.destination!], undefined);
assert.equal(game.techTokens!.heighliners.spice, 0);
assert.equal(game.nexusCards!.cards!.discard.filter(c => c === 'richese').length, 1);
assert.ok(game.discard.some(c => c.id === karama.id));
rejected(game, f.actor, f.paymentAction);
nexusInventory(game);
collectOnce(f, game, 0);
});
void test('native Guild using Richese pricing stays native Guild and cannot activate Heighliners alone', () => {
const f = createNexusModulePaymentsFixture({ kind: 'richese-shipment', ownerFaction: 'guild' });
const paid = payNexusModulePaymentsFixture(f);
const actor = paid.players.find(p => p.id === f.actor)!;
assert.equal(actor.faction, 'guild');
assert.equal(actor.spice, f.beforePayment.players.find(p => p.id === f.actor)!.spice - 1);
assert.equal(actor.forces[f.destination!], 5);
assert.equal(paid.techTokens!.heighliners.spice, 0);
collectOnce(f, paid, 0);
});
void test('Fremen paid borrowed Guild tariff does not relocate southern reserves or change Heighliners source eligibility', () => {
const f = createNexusModulePaymentsFixture({ kind: 'guild-shipment', ownerFaction: 'fremen' });
const paid = payNexusModulePaymentsFixture(f);
const actor = paid.players.find(p => p.id === f.actor)!;
assert.equal(actor.faction, 'fremen');
assert.equal(f.cost, 3);
assert.equal(actor.spice, f.beforePayment.players.find(p => p.id === f.actor)!.spice - 3);
assert.equal(actor.forces[f.destination!], 5);
assert.equal(paid.techTokens!.heighliners.spice, 0, 'A tariff alone does not make the native on-planet reserve source off-planet.');
collectOnce(f, paid, 0);
});
// Printed Nexus Emperor: three additional free beyond limits. Printed Fremen:
// total free three, recorded ordinary-quota interpretation (NEXUS_FREMEN_RULES).
// E1 p5 classifies both actual phase-4 free batches as Axlotl activity, without
// conflating their different ordinary quota ledgers or granting native identity.
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
for (const strongholds of [false, true]) {
void test(`${kind} consumes the physical card and exact original ledgers${strongholds ? ' without a Stronghold revival bonus' : ''}, then collects Axlotl once`, () => {
const f = createNexusModulePaymentsFixture({ kind, advanced: strongholds, strongholds });
const before = f.beforePayment.players.find(p => p.id === f.actor)!;
let paid = payNexusModulePaymentsFixture(f);
let actor = paid.players.find(p => p.id === f.actor)!;
assert.equal(actor.faction, before.faction);
assert.equal(actor.reserves, before.reserves + 3);
assert.equal(actor.tanks, before.tanks - 3);
assert.deepEqual(wallets(paid), wallets(f.beforePayment), 'No grant, Tech pile or Stronghold card manufactures spendable spice during Revival.');
assert.equal(actor.revived, kind === 'fremen-revival' ? 3 : before.revived);
assert.equal(actor.freeForcesRevived ?? 0, kind === 'fremen-revival' ? 3 : (before.freeForcesRevived ?? 0));
assert.equal(paid.techTokens!.axlotl.spice, f.tokenCount);
assert.equal(paid.techTokens!.axlotl.triggeredTurn, paid.turn);
assert.equal(paid.nexusCards!.cards!.discard.filter(c => c === (kind === 'emperor-revival' ? 'emperor' : 'fremen')).length, 1);
rejected(paid, f.actor, f.paymentAction);
if (kind === 'fremen-revival') {
assert.equal(forceRevivalRemaining(paid, actor), 0);
rejected(paid, f.actor, { type: 'revive', amount: 1 });
} else {
assert.equal(forceRevivalRemaining(paid, actor), 3);
const quote = forceRevivalQuote(paid, actor, 3);
assert.equal(quote.cost, 2, 'Atreides keeps its ordinary two-free/one-paid invoice after the additional grant.');
const wallet = actor.spice;
paid = applyAction(paid, f.actor, { type: 'revive', amount: 3 });
actor = paid.players.find(p => p.id === f.actor)!;
assert.equal(actor.reserves, before.reserves + 6);
assert.equal(actor.tanks, before.tanks - 6);
assert.equal(actor.spice, wallet - quote.cost);
assert.equal(actor.revived, 3);
assert.equal(actor.freeForcesRevived, 2);
assert.equal(paid.techTokens!.axlotl.spice, f.tokenCount, 'Another genuine free batch cannot accrue the same token twice.');
}
nexusInventory(paid);
collectOnce(f, paid, f.tokenCount);
});
}
}
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
void test(`${kind} preserves actual Advanced elite identity and one-per-turn cap, including bot participation`, () => {
const f = createNexusModulePaymentsFixture({ kind, advanced: true, strongholds: true,
ownerFaction: kind === 'emperor-revival' ? 'fremen' : 'emperor', eliteTanks: 2 });
const view = viewGame(f.game, f.actor);
const offer = kind === 'emperor-revival' ? view.nexusEmperorSecretAlly!.revival : view.nexusFremenRevival!;
assert.deepEqual(offer.eliteOptions, [0, 1]);
rejected(f.game, f.actor, { ...f.paymentAction, elite: 2 });
view.players.find(p => p.id === f.actor)!.bot = 'Hard';
const bot = botActions(view).find(action => action.type === f.paymentAction.type);
assert.ok(bot, 'The actual bot must participate in the newly admitted module option.');
const paid = applyAction(f.game, f.actor, bot);
const actor = paid.players.find(p => p.id === f.actor)!;
const before = f.beforePayment.players.find(p => p.id === f.actor)!;
assert.equal(actor.elites!.reserves, before.elites!.reserves + 1);
assert.equal(actor.elites!.tanks, before.elites!.tanks - 1);
assert.equal(actor.elites!.revived, 1);
assert.equal(actor.elites!.reserves + actor.elites!.tanks
+ Object.values(actor.elites!.forces).reduce((sum, count) => sum + count, 0),
before.elites!.reserves + before.elites!.tanks
+ Object.values(before.elites!.forces).reduce((sum, count) => sum + count, 0));
rejected(paid, f.actor, { type: 'revive', amount: 1, elite: 1 });
nexusInventory(paid);
});
}
void test('Fremen late return and exact-three shortage remain unavailable; a real Truthtrance blocks both Nexus returns', () => {
const f = createNexusModulePaymentsFixture({ kind: 'fremen-revival' });
const late = applyAction(f.game, f.actor, { type: 'revive', amount: 1 });
assert.match(viewGame(late, f.actor).nexusFremenRevival!.blocked!, /before ordinary force revivals/);
rejected(late, f.actor, f.paymentAction);
for (const kind of ['fremen-revival', 'emperor-revival'] as const) {
const short = createNexusModulePaymentsFixture({ kind, tanks: 2 });
rejected(short.game, short.actor, short.paymentAction);
const quiet = createNexusModulePaymentsFixture({ kind });
const questioner = quiet.game.players.find(p => p.id !== quiet.actor)!;
const card = holdNexusRicheseCard(quiet.game, questioner.id, 'truthtrance');
const pending = applyAction(quiet.game, questioner.id, { type: 'card', card: card.id });
assert.ok(pending.truthtrance);
rejected(pending, quiet.actor, quiet.paymentAction);
}
});
void test('Strongholds alone grants no revival bonus or token income', () => {
const f = createNexusModulePaymentsFixture({ kind: 'fremen-revival', advanced: true, strongholds: true, tech: false });
const before = f.beforePayment.players.find(p => p.id === f.actor)!;
const paid = payNexusModulePaymentsFixture(f);
const actor = paid.players.find(p => p.id === f.actor)!;
assert.equal(actor.reserves, before.reserves + 3);
assert.equal(actor.revived, 3);
assert.equal(actor.freeForcesRevived, 3);
assert.deepEqual(wallets(paid), wallets(f.beforePayment));
assert.equal(forceRevivalRemaining(paid, actor), 0);
rejected(paid, f.actor, { type: 'revive', amount: 1 });
});
void test('new module admission rejects too few Tech seats, Basic Strongholds, unrelated modules and already-owned components', () => {
for (const boundary of ['two-tech-seats', 'basic-strongholds', 'skills', 'owned-tech', 'owned-stronghold'] as const) {
let lobby = createGame('NEXUSMODULEBOUNDARY', newPlayer('a', 'Atreides', 'atreides'), boundary !== 'basic-strongholds');
joinGame(lobby, newPlayer('h', 'Harkonnen', 'harkonnen'));
if (boundary !== 'two-tech-seats') joinGame(lobby, newPlayer('e', 'Emperor', 'emperor'));
lobby.nexusCards = { cards: null, phase: null };
if (boundary === 'owned-stronghold') lobby.strongholdCards = createStrongholdCards();
else if (boundary !== 'basic-strongholds') lobby = applyAction(lobby, lobby.host, { type: 'techTokens', enabled: true });
for (const player of lobby.players) lobby = applyAction(lobby, player.id, { type: 'ready' });
if (boundary === 'basic-strongholds') lobby.strongholdCards = createStrongholdCards();
if (boundary === 'skills') lobby.leaderSkills = createLeaderSkills(() => 0.5);
if (boundary === 'owned-tech') lobby.techTokens!.axlotl.owner = 'a';
if (boundary === 'owned-stronghold') lobby.strongholdCards!.owners.arrakeen = 'a';
const before = structuredClone(lobby);
assert.throws(() => initializeNexusGameForAudit(lobby));
assert.deepEqual(lobby, before);
}
// Selected native-expansion cards/factions are not another path to admission.
let lobby = createGame('NEXUSMODULEDECK', newPlayer('a', 'Atreides', 'atreides'), false, ['ix']);
joinGame(lobby, newPlayer('h', 'Harkonnen', 'harkonnen'));
joinGame(lobby, newPlayer('e', 'Emperor', 'emperor'));
lobby.nexusCards = { cards: null, phase: null };
lobby.techTokens = createTechTokens();
for (const player of lobby.players) lobby = applyAction(lobby, player.id, { type: 'ready' });
assert.throws(() => initializeNexusGameForAudit(lobby));
});
for (const strongholds of [false, true]) {
void test(`original Emperor bank-auction purchase${strongholds ? ' with genuinely claimed Advanced Strongholds' : ' with Tech'} keeps the winning wallet, delivers the lot once and activates no technology`, () => {
const f = createNexusModulePaymentsFixture({ kind: 'emperor-purchase',
advanced: strongholds, strongholds });
const before = f.beforePayment;
const auction = before.auction!;
const lot = auction.cards[auction.index];
const buyer = before.players.find(p => p.id === f.actor)!;
assert.equal(auction.bid, 2);
assert.ok(buyer.spice >= auction.bid, 'The real buyer must possess the original invoice amount.');
assert.equal(buyer.hand.some(card => card.id === lot.id), false);
const paid = applyAction(f.game, f.actor, f.paymentAction);
const after = paid.players.find(p => p.id === f.actor)!;
assert.deepEqual(wallets(paid), wallets(before), 'Emperor Secret Ally retains the actual personal winning payment.');
assert.equal(after.hand.filter(card => card.id === lot.id).length, 1);
assert.equal(after.faction, buyer.faction);
assert.equal(paid.nexusCards!.cards!.hands[f.actor], null);
assert.equal(paid.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
assert.equal(paid.nexusEmperorPurchaseHistory!.length, 1);
const receipt = paid.nexusEmperorPurchaseHistory![0];
assert.equal(receipt.card, lot.id);
assert.equal(receipt.price, 2);
assert.equal(receipt.beforeSpice, buyer.spice);
assert.equal(receipt.afterSpice, buyer.spice);
assert.deepEqual(paid.techTokens, before.techTokens,
'A bank purchase is neither a shipment, a free revival nor CHOAM Charity.');
assert.deepEqual(paid.strongholdCards, before.strongholdCards);
assert.ok(!paid.decision || paid.decision.kind !== 'auctionPayment',
'The original winner payment cannot remain open after card delivery.');
if (paid.auction) assert.equal(paid.auction.index, auction.index + 1);
rejected(paid, f.actor, f.paymentAction);
nexusInventory(paid);
const continued = advanceNexusModulePaymentsToPhase(paid, 4);
assert.equal(continued.players.find(p => p.id === f.actor)!.hand.filter(card => card.id === lot.id).length, 1);
assert.equal(continued.nexusEmperorPurchaseHistory!.length, 1);
assert.deepEqual(continued.techTokens, before.techTokens,
'Completing the real Bidding round cannot mint a delayed technology pile for that purchase.');
nexusInventory(continued);
});
}