Compose classic Nexus with Tech and Advanced Stronghold Cards
This commit is contained in:
1 parent
b8585cdd92
commit
8ceee7afd0
17 files changed
+1250
-28
No files matched your search
@@ -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, []);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user