Compose paired Nexus with original Tech and Stronghold modules
This commit is contained in:
1 parent
8f2dd3b1a4
commit
9f254a6912
14 files changed
+1256
-20
No files matched your search
@@ -0,0 +1,220 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializePairedNexusGameForAudit, joinGame, newPlayer,
|
||||
viewGame, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { territory } from '../game/board';
|
||||
import type { Card } from '../game/cards';
|
||||
import { pairedNexusModulesProfile } from '../game/nexus-module-profile';
|
||||
import { validateNexusCards } from '../game/nexus-cards';
|
||||
import { nextNativeTechFactionsStep } from './fixture-native-tech-factions';
|
||||
import { orderNexusSpice } from './fixture-nexus-cards';
|
||||
import type { StrongholdFactionsNativeStep as Step } from './fixture-stronghold-factions';
|
||||
|
||||
export type PairedChoamNexusModulesOptions = {
|
||||
initial?: Game;
|
||||
advanced?: boolean;
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
reserveCap?: number;
|
||||
};
|
||||
export type PairedChoamNexusModulesFixture = {
|
||||
initial: Game;
|
||||
afterSetup: Game;
|
||||
afterFirstStorm: Game;
|
||||
firstShipment: { before: Game; step: Step; after: Game };
|
||||
beforeFirstMentat: Game;
|
||||
firstMentatStep: Step;
|
||||
afterFirstMentat: Game;
|
||||
movementStart: Game;
|
||||
game: Game;
|
||||
richese: string;
|
||||
choam: string;
|
||||
guild: string;
|
||||
observer: string;
|
||||
location: string;
|
||||
actions: Step[];
|
||||
staging: string[];
|
||||
};
|
||||
export const pairedChoamPlayer = (game: Game, actor: string) => {
|
||||
const player = game.players.find(p => p.id === actor);
|
||||
assert.ok(player, `Missing original paired E2 actor ${actor}`);
|
||||
return player;
|
||||
};
|
||||
export const pairedChoamClean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
|
||||
export function nextPairedChoamStep(game: Game): Step | 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 === 'nexusChoamInspection')
|
||||
return { actor: d.player, action: { type: 'decision', event: d.event, inspect: false } };
|
||||
}
|
||||
return nextNativeTechFactionsStep(game);
|
||||
}
|
||||
function run(game: Game, step: Step, actions: Step[]): Game {
|
||||
actions.push(structuredClone(step));
|
||||
return applyAction(game, step.actor, step.action);
|
||||
}
|
||||
export function advancePairedChoam(game: Game, until: (game: Game) => boolean, actions: Step[] = []): Game {
|
||||
for (let n = 0; n < 1800; n++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextPairedChoamStep(game);
|
||||
assert.ok(step, `Original paired E2 needs a human battle plan at ${game.turn}/${game.phase}`);
|
||||
if (step.action.type === 'stormDial') step.action.amount = game.turn === 1 ? 0 : 1;
|
||||
game = run(game, step, actions);
|
||||
}
|
||||
throw Error('Original paired E2 continuation did not reach its requested boundary');
|
||||
}
|
||||
function setupEntropy<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');
|
||||
}
|
||||
}
|
||||
/** Continue the supplied authenticated, undealt original setup without changing
|
||||
* actors, first deal or deck. Entropy is scoped only to a newly created lobby. */
|
||||
export function initializePairedChoamModules(options: PairedChoamNexusModulesOptions = {}): Game {
|
||||
const tech = options.tech ?? true, strongholds = options.strongholds ?? true;
|
||||
const advanced = options.advanced ?? options.initial?.advanced ?? true;
|
||||
assert.ok(tech || strongholds);
|
||||
if (strongholds) assert.equal(advanced, true);
|
||||
let game = options.initial ? structuredClone(options.initial)
|
||||
: createGame('PAIREDCHOAMNEXUSMODULES', newPlayer('richese', 'Richese', 'richese'), advanced, ['choam']);
|
||||
if (!options.initial) {
|
||||
joinGame(game, newPlayer('choam', 'CHOAM', 'choam'));
|
||||
joinGame(game, newPlayer('guild', 'Guild', 'guild'));
|
||||
joinGame(game, newPlayer('atreides', 'Atreides', 'atreides'));
|
||||
}
|
||||
assert.deepEqual(game.players.map(p => p.faction).sort(), ['atreides', 'choam', 'guild', 'richese']);
|
||||
assert.deepEqual(game.expansions, ['choam']);
|
||||
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), 'Use the original authenticated lobby or turn-one undealt setup');
|
||||
if (game.status === 'lobby') {
|
||||
if (!game.nexusCards) game.nexusCards = { cards: null, phase: null };
|
||||
if (!!game.techTokens !== tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: tech });
|
||||
if (!!game.strongholdCards !== strongholds) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: strongholds });
|
||||
for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' });
|
||||
const initialize = () => initializePairedNexusGameForAudit(game);
|
||||
game = options.initial ? initialize() : setupEntropy(initialize);
|
||||
}
|
||||
assert.ok(pairedNexusModulesProfile(game));
|
||||
assert.equal(!!game.techTokens, tech);
|
||||
assert.equal(!!game.strongholdCards, strongholds);
|
||||
return game;
|
||||
}
|
||||
export function reservePairedChoamBoard(game: Game): void {
|
||||
for (const p of game.players) {
|
||||
p.reserves += Object.values(p.forces).reduce((a, b) => a + b, 0);
|
||||
p.forces = {};
|
||||
if (p.elites) {
|
||||
p.elites.reserves += Object.values(p.elites.forces).reduce((a, b) => a + b, 0);
|
||||
p.elites.forces = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
export function placePairedChoam(game: Game, actor: string, key: string, count: number): void {
|
||||
const p = pairedChoamPlayer(game, actor);
|
||||
assert.ok(count >= 0 && p.reserves - (p.elites?.reserves ?? 0) >= count);
|
||||
p.reserves -= count;
|
||||
if (count) p.forces[key] = (p.forces[key] ?? 0) + count;
|
||||
}
|
||||
export function holdPairedChoamCard(game: Game, actor: string, matches: (card: Card) => boolean): Card {
|
||||
const p = pairedChoamPlayer(game, actor), held = p.hand.find(matches);
|
||||
if (held) return held;
|
||||
const at = game.deck.findIndex(matches);
|
||||
assert.ok(at >= 0, 'Required original physical card is unavailable');
|
||||
assert.ok(p.hand.length < (p.faction === 'choam' ? 5 : 4));
|
||||
const card = game.deck.splice(at, 1)[0];
|
||||
p.hand.push(card);
|
||||
return card;
|
||||
}
|
||||
/** Real native zero-marker shipment establishes previous-token history. Actual
|
||||
* first Storm, END Mentat, turn-two worm, reciprocal classic alliance and closing
|
||||
* Nexus deals precede the controlled movement program. No bank or custody grants. */
|
||||
export function createPairedChoamNexusModulesFixture(options: PairedChoamNexusModulesOptions = {}): PairedChoamNexusModulesFixture {
|
||||
const actions: Step[] = [], staging: string[] = [];
|
||||
let game = initializePairedChoamModules(options);
|
||||
const initial = structuredClone(game);
|
||||
game = advancePairedChoam(game, g => g.status === 'playing', actions);
|
||||
const afterSetup = structuredClone(game);
|
||||
const richese = game.players.find(p => p.faction === 'richese')!.id;
|
||||
const choam = game.players.find(p => p.faction === 'choam')!.id;
|
||||
const guild = game.players.find(p => p.faction === 'guild')!.id;
|
||||
const observer = game.players.find(p => p.faction === 'atreides')!.id;
|
||||
const location = `habbanya_ridge_sietch:${territory('habbanya_ridge_sietch').sectors[0]}`;
|
||||
game = advancePairedChoam(game, g => g.phase === 1 && pairedChoamClean(g), actions);
|
||||
const afterFirstStorm = structuredClone(game);
|
||||
orderNexusSpice(game, ['land', 'land']);
|
||||
game = advancePairedChoam(game, g => g.phase === 5 && g.active === richese && pairedChoamClean(g), actions);
|
||||
reservePairedChoamBoard(game);
|
||||
const before = structuredClone(game), p = pairedChoamPlayer(game, richese);
|
||||
const step: Step = { actor: richese, action: { type: 'ship', noField: p.noField!.tokens.find(t => t.value === 0)!.id, event: p.noFieldEvent, territory: 'habbanya_ridge_sietch', sector: territory('habbanya_ridge_sietch').sectors[0] } };
|
||||
game = run(game, step, actions);
|
||||
game = advancePairedChoam(game, pairedChoamClean, actions);
|
||||
const firstShipment = { before, step, after: structuredClone(game) };
|
||||
game = run(game, { actor: richese, action: { type: 'revealNoField', event: pairedChoamPlayer(game, richese).noFieldEvent, token: pairedChoamPlayer(game, richese).noField!.deployed!.tokenId } }, actions);
|
||||
game = advancePairedChoam(game, g => g.phase === 8 && pairedChoamClean(g), actions);
|
||||
reservePairedChoamBoard(game);
|
||||
placePairedChoam(game, richese, location, 1);
|
||||
placePairedChoam(game, choam, `arrakeen:${territory('arrakeen').sectors[0]}`, 1);
|
||||
staging.push('Before genuine first END Mentat: conserve one Richese Habbanya and one CHOAM Arrakeen counter; preserve actual zero-marker history, wallets and first-Storm Tech.');
|
||||
let beforeFirstMentat!: Game, firstMentatStep!: Step;
|
||||
while (game.turn === 1) {
|
||||
const next = nextPairedChoamStep(game); assert.ok(next);
|
||||
const previous = structuredClone(game);
|
||||
game = run(game, next, actions);
|
||||
if (game.turn === 2) { beforeFirstMentat = previous; firstMentatStep = next; }
|
||||
}
|
||||
const afterFirstMentat = structuredClone(game);
|
||||
game = advancePairedChoam(game, g => g.phase === 1 && pairedChoamClean(g), actions);
|
||||
orderNexusSpice(game, ['worm', 'land', 'land']);
|
||||
game = advancePairedChoam(game, g => g.nexus === true && !g.spiceWindow, actions);
|
||||
game = run(game, { actor: guild, action: { type: 'alliance', target: observer } }, actions);
|
||||
game = run(game, { actor: observer, action: { type: 'alliance', target: guild } }, actions);
|
||||
game = advancePairedChoam(game, g => g.nexusCards?.phase?.stage === 'drawing', actions);
|
||||
for (const [actor, card] of [[richese, 'richese'], [choam, 'choam']] as const) {
|
||||
const cards = game.nexusCards!.cards!;
|
||||
assert.ok(cards.deck.includes(card));
|
||||
cards.deck = [card, ...cards.deck.filter(c => c !== card)];
|
||||
game = run(game, { actor, action: { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 } }, actions);
|
||||
}
|
||||
staging.push('Conserve all12 deck order; turn-two worm and reciprocal Guild/Atreides alliance cause actual closing Nexus Cunning deals to the two unallied native E2 seats.');
|
||||
game = advancePairedChoam(game, g => g.phase === 5 && pairedChoamClean(g), actions);
|
||||
reservePairedChoamBoard(game);
|
||||
// Preserve first-deal snapshots; relocate original physical hands only here.
|
||||
for (const p of game.players) game.deck.push(...p.hand.splice(0));
|
||||
const movementStart = structuredClone(game);
|
||||
game = advancePairedChoam(game, g => g.phase === 5 && g.active === richese && pairedChoamClean(g), actions);
|
||||
if (options.reserveCap !== undefined) {
|
||||
assert.ok(Number.isSafeInteger(options.reserveCap) && options.reserveCap >= 0 && options.reserveCap <= 20);
|
||||
placePairedChoam(game, richese, 'polar_sink:0', 20 - options.reserveCap);
|
||||
staging.push(`Conserve ${20 - options.reserveCap} Richese counters at Polar Sink, leaving ${options.reserveCap} physical reserves for capped materialization.`);
|
||||
}
|
||||
validateNexusCards(game.nexusCards!.cards!, game.players);
|
||||
return { initial, afterSetup, afterFirstStorm, firstShipment, beforeFirstMentat, firstMentatStep, afterFirstMentat, movementStart, game, richese, choam, guild, observer, location, actions, staging };
|
||||
}
|
||||
export function pairedRicheseCunningRequest(f: PairedChoamNexusModulesFixture, game = f.game): Action {
|
||||
const p = pairedChoamPlayer(game, f.richese);
|
||||
return { type: 'ship', noField: p.noField!.tokens.find(t => t.value === 3)!.id,
|
||||
revealedToken: p.noField!.tokens.find(t => t.value === 5)!.id,
|
||||
nexus: viewGame(game, f.richese).nexusRicheseCunning!.event, event: p.noFieldEvent,
|
||||
territory: 'habbanya_ridge_sietch', sector: territory('habbanya_ridge_sietch').sectors[0], allyPayment: 0 };
|
||||
}
|
||||
export function allowPairedChoam(game: Game): Game {
|
||||
return advancePairedChoam(game, pairedChoamClean);
|
||||
}
|
||||
export function revealPairedRichese(game: Game, actor: string): Game {
|
||||
const p = pairedChoamPlayer(game, actor);
|
||||
assert.ok(p.noField!.deployed);
|
||||
return applyAction(game, actor, { type: 'revealNoField', event: p.noFieldEvent, token: p.noField!.deployed.tokenId });
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, initializePairedNexusGameForAudit, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import { MOBILE_LOCATION, MOBILE_STRONGHOLD, territory } from '../game/board';
|
||||
import type { Card } from '../game/cards';
|
||||
import { pairedNexusModulesProfile } from '../game/nexus-module-profile';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
|
||||
export type PairedIxNexusModulesStep = { actor: string; action: Action };
|
||||
export type PairedIxNexusModulesOptions = {
|
||||
initial?: Game;
|
||||
advanced?: boolean;
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
program?: 'suboids' | 'faceDance';
|
||||
};
|
||||
export type PairedIxNexusModulesFixture = {
|
||||
initial: Game;
|
||||
afterSetup: Game;
|
||||
afterFirstStorm: Game;
|
||||
beforeFirstMentat: Game;
|
||||
afterFirstMentat: Game;
|
||||
beforeNexusDraw: Game;
|
||||
afterNexusDraw: Game;
|
||||
beforeBattle: Game;
|
||||
game: Game;
|
||||
ixians: string;
|
||||
tleilaxu: string;
|
||||
opponent: string;
|
||||
partners: [string, string];
|
||||
kind: 'arrakeen' | 'sietch_tabr';
|
||||
location: string;
|
||||
replacementSource: string;
|
||||
battleStep: PairedIxNexusModulesStep;
|
||||
planActions: PairedIxNexusModulesStep[];
|
||||
actions: PairedIxNexusModulesStep[];
|
||||
staging: string[];
|
||||
};
|
||||
export function pairedIxNexusModulesPlayer(game: Game, actor: string) {
|
||||
const p = game.players.find(player => player.id === actor);
|
||||
assert.ok(p, `Missing original paired E1 actor ${actor}`);
|
||||
return p;
|
||||
}
|
||||
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
|
||||
|
||||
/** Only real native windows; unsealed plans require explicit human submission. */
|
||||
export function nextPairedIxNexusModulesStep(game: Game): PairedIxNexusModulesStep | null {
|
||||
if (clean(game) && game.nexusCards?.phase?.stage === 'drawing') {
|
||||
const actor = game.nexusCards.phase.eligible.find(id => !game.nexusCards!.phase!.done.includes(id));
|
||||
assert.ok(actor);
|
||||
return { actor, action: { type: 'nexusCardChoice', turn: game.turn, card: game.nexusCards.cards!.hands[actor] ?? null, choice: 'keep', ownRedraws: 0 } };
|
||||
}
|
||||
if (!game.phaseOpening && !game.response && game.decision?.kind === 'techToken')
|
||||
return { actor: game.decision.player, action: { type: 'decision', token: game.decision.choices[0] } };
|
||||
if (!game.phaseOpening && !game.response && game.decision?.kind === 'faceDance')
|
||||
return { actor: game.decision.player, action: { type: 'decision', reveal: false } };
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
function run(game: Game, step: PairedIxNexusModulesStep, actions: PairedIxNexusModulesStep[]): Game {
|
||||
actions.push(structuredClone(step));
|
||||
return applyAction(game, step.actor, step.action);
|
||||
}
|
||||
export function advancePairedIxNexusModules(state: Game, until: (game: Game) => boolean, actions: PairedIxNexusModulesStep[] = []): Game {
|
||||
let game = structuredClone(state);
|
||||
for (let n = 0; n < 2400; n++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextPairedIxNexusModulesStep(game);
|
||||
assert.ok(step, 'The paired native program requires an explicit human plan or Face Dance');
|
||||
game = run(game, step, actions);
|
||||
}
|
||||
throw Error('Paired E1 module continuation did not reach its native boundary');
|
||||
}
|
||||
function setupEntropy<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 reserveBoard(game: Game) {
|
||||
for (const p of game.players) {
|
||||
p.reserves += Object.values(p.forces).reduce((sum, count) => sum + count, 0);
|
||||
p.forces = {};
|
||||
if (p.elites) {
|
||||
p.elites.reserves += Object.values(p.elites.forces).reduce((sum, count) => sum + count, 0);
|
||||
p.elites.forces = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
function place(game: Game, actor: string, location: string, total: number, elite = 0) {
|
||||
const p = pairedIxNexusModulesPlayer(game, actor);
|
||||
assert.ok(p.reserves >= total && (p.elites?.reserves ?? 0) >= elite && total >= elite);
|
||||
p.reserves -= total;
|
||||
p.forces[location] = (p.forces[location] ?? 0) + total;
|
||||
if (elite) {
|
||||
p.elites!.reserves -= elite;
|
||||
p.elites!.forces[location] = (p.elites!.forces[location] ?? 0) + elite;
|
||||
}
|
||||
}
|
||||
function heldCard(game: Game, actor: string, kind: Card['kind'], staging: string[]): string {
|
||||
const p = pairedIxNexusModulesPlayer(game, actor), held = p.hand.find(c => c.kind === kind);
|
||||
if (held) return held.id;
|
||||
const at = game.deck.findIndex(c => c.kind === kind);
|
||||
assert.ok(at >= 0 && p.hand.length < 4);
|
||||
const card = game.deck.splice(at, 1)[0];
|
||||
p.hand.push(card);
|
||||
staging.push(`Conserved ${card.id}: remaining original deck → ${actor} hand, after genuine first deal.`);
|
||||
return card.id;
|
||||
}
|
||||
/** Fresh CLI lobby or its original initialized undealt setup; preserve identities,
|
||||
* native first deal and wallets. Local entropy selects original shuffles only;
|
||||
* supplied snapshots use their saved native setup and real continuing randomness. */
|
||||
export function createPairedIxNexusModulesFixture(options: PairedIxNexusModulesOptions = {}): PairedIxNexusModulesFixture {
|
||||
const advanced = options.advanced ?? options.initial?.advanced ?? true;
|
||||
const tech = options.tech ?? (options.initial ? !!options.initial.techTokens : true);
|
||||
const strongholds = options.strongholds ?? (options.initial ? !!options.initial.strongholdCards : advanced);
|
||||
const program = options.program ?? 'suboids';
|
||||
assert.ok(tech || strongholds);
|
||||
assert.ok(!strongholds || advanced);
|
||||
const actions: PairedIxNexusModulesStep[] = [], staging: string[] = [];
|
||||
let game = options.initial ? structuredClone(options.initial)
|
||||
: createGame('PAIREDIXNEXUSMODULES', newPlayer('ixians', 'Ixians', 'ixians'), advanced, ['ix']);
|
||||
if (!options.initial) for (const faction of ['tleilaxu', 'guild', 'emperor'] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
assert.equal(game.advanced, advanced);
|
||||
assert.deepEqual(game.expansions, ['ix']);
|
||||
assert.deepEqual(game.players.map(p => p.faction).sort(), ['emperor', 'guild', 'ixians', 'tleilaxu']);
|
||||
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') {
|
||||
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);
|
||||
// Nexus has no public optional-rules action; admit only its unused CLI component.
|
||||
game.nexusCards ??= { cards: null, phase: null };
|
||||
for (const p of game.players) if (!p.ready) game = run(game, { actor: p.id, action: { type: 'ready' } }, actions);
|
||||
game = options.initial ? initializePairedNexusGameForAudit(game) : setupEntropy(() => initializePairedNexusGameForAudit(game));
|
||||
}
|
||||
assert.ok(pairedNexusModulesProfile(game));
|
||||
assert.equal(!!game.techTokens, tech);
|
||||
assert.equal(!!game.strongholdCards, strongholds);
|
||||
const initial = structuredClone(game);
|
||||
const completeSetup = () => advancePairedIxNexusModules(game, state => state.status === 'playing', actions);
|
||||
game = options.initial ? completeSetup() : setupEntropy(completeSetup);
|
||||
const afterSetup = structuredClone(game);
|
||||
const ixians = game.players.find(p => p.faction === 'ixians')!.id;
|
||||
const tleilaxu = game.players.find(p => p.faction === 'tleilaxu')!.id;
|
||||
const classics = game.players.filter(p => p.faction === 'guild' || p.faction === 'emperor').map(p => p.id);
|
||||
const partners: [string, string] = [classics[0], classics[1]];
|
||||
// Conserved land ordering avoids a turn-one worm that cannot supply Nexus cards.
|
||||
for (let n = 0; n < 2; n++) {
|
||||
const at = game.spiceDeck.findIndex((card, index) => index >= n && 'territory' in card);
|
||||
assert.ok(at >= n);
|
||||
game.spiceDeck.splice(n, 0, game.spiceDeck.splice(at, 1)[0]);
|
||||
}
|
||||
game = advancePairedIxNexusModules(game, state => state.phase === 1 && clean(state), actions);
|
||||
const afterFirstStorm = structuredClone(game);
|
||||
const opponent = tech ? classics.find(id => ownedTech(game.techTokens, id).length)! : classics[0];
|
||||
assert.ok(opponent);
|
||||
const kind = program === 'faceDance' ? 'sietch_tabr' : 'arrakeen';
|
||||
const location = `${kind}:${territory(kind).sectors[0]}`;
|
||||
game = advancePairedIxNexusModules(game, state => state.phase === 8 && clean(state), actions);
|
||||
reserveBoard(game);
|
||||
place(game, ixians, location, 1);
|
||||
place(game, ixians, MOBILE_LOCATION, 6, 3);
|
||||
staging.push('Before genuine first END Mentat: conserved Ix counters control the printed site and HMS only; no third Stronghold, wallets or ownership assignment.');
|
||||
const beforeFirstMentat = structuredClone(game);
|
||||
game = advancePairedIxNexusModules(game, state => state.turn === 2, actions);
|
||||
const afterFirstMentat = structuredClone(game);
|
||||
game = advancePairedIxNexusModules(game, state => state.phase === 1 && clean(state), actions);
|
||||
const worm = game.spiceDeck.findIndex(c => 'worm' in c && !c.greatMaker && !c.suppressed);
|
||||
assert.ok(worm >= 0);
|
||||
game.spiceDeck.splice(0, 0, game.spiceDeck.splice(worm, 1)[0]);
|
||||
staging.push('After real second Storm: conserved original worm moved to the next Spice Blow; native worm resolution opens the alliance Nexus.');
|
||||
game = advancePairedIxNexusModules(game, state => !!state.nexus && !state.spiceWindow && !state.spiceResolution && clean(state), actions);
|
||||
game = run(game, { actor: partners[0], action: { type: 'alliance', target: partners[1] } }, actions);
|
||||
game = run(game, { actor: partners[1], action: { type: 'alliance', target: partners[0] } }, actions);
|
||||
game = advancePairedIxNexusModules(game, state => state.nexusCards?.phase?.stage === 'drawing', actions);
|
||||
const cards = game.nexusCards!.cards!;
|
||||
cards.deck = ['ixians', 'tleilaxu', ...cards.deck.filter(face => face !== 'ixians' && face !== 'tleilaxu')];
|
||||
staging.push('Order two conserved undealt Nexus singletons only; both native hands come from real closing Nexus draw actions.');
|
||||
const beforeNexusDraw = structuredClone(game);
|
||||
for (const actor of [ixians, tleilaxu]) game = run(game, { actor, action: { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 } }, actions);
|
||||
game = advancePairedIxNexusModules(game, state => state.nexusCards?.phase?.stage !== 'drawing', actions);
|
||||
const afterNexusDraw = structuredClone(game);
|
||||
game = advancePairedIxNexusModules(game, state => state.phase === 5 && clean(state), actions);
|
||||
reserveBoard(game);
|
||||
place(game, ixians, location, 6, 3);
|
||||
if (program === 'suboids') {
|
||||
place(game, ixians, MOBILE_LOCATION, 4, 2);
|
||||
place(game, opponent, MOBILE_LOCATION, 2);
|
||||
}
|
||||
place(game, opponent, location, 6);
|
||||
const replacementSource = `arrakeen:${territory('arrakeen').sectors[0]}`;
|
||||
if (program === 'faceDance') place(game, tleilaxu, replacementSource, 3);
|
||||
staging.push('Before actual movement completion: conserved subtype forces create native battles, plus original reserve/board Face Dance sources. No phases, plans, payments or revealed flags are staged.');
|
||||
const ix = pairedIxNexusModulesPlayer(game, ixians);
|
||||
const leader = program === 'faceDance'
|
||||
? ix.leaders.filter(l => !l.dead && pairedIxNexusModulesPlayer(game, tleilaxu).faceDancers!.some(c => c.leader === l.id && !c.revealed)).sort((a, b) => b.strength - a.strength)[0]
|
||||
: ix.leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0];
|
||||
assert.ok(leader, 'The original Face Dancer stock must include a living Ix leader for this human-ready source case');
|
||||
const enemy = pairedIxNexusModulesPlayer(game, opponent).leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0];
|
||||
assert.ok(enemy);
|
||||
const weapon = program === 'faceDance' ? heldCard(game, ixians, 'projectile', staging) : null;
|
||||
const defense = program === 'faceDance' ? heldCard(game, ixians, 'shield', staging) : null;
|
||||
game = advancePairedIxNexusModules(game, state => state.phase === 6 && clean(state), actions);
|
||||
const beforeBattle = structuredClone(game), actor = game.active!;
|
||||
assert.ok(actor === ixians || actor === opponent);
|
||||
const battleStep: PairedIxNexusModulesStep = { actor, action: { type: 'chooseBattle', territory: kind, target: actor === ixians ? opponent : ixians } };
|
||||
game = run(game, battleStep, actions);
|
||||
game = advancePairedIxNexusModules(game, state => !!state.battle && !state.battle.revealed && !nextPairedIxNexusModulesStep(state), actions);
|
||||
const planActions: PairedIxNexusModulesStep[] = [
|
||||
{ actor: ixians, action: { type: 'battlePlan', dial: program === 'faceDance' ? 2 : advanced ? 4 : 6, support: advanced ? 1 : 0, leader: leader.id, weapon, defense } },
|
||||
{ actor: opponent, action: { type: 'battlePlan', dial: 1, support: advanced ? 1 : 0, leader: enemy.id, weapon: null, defense: null } },
|
||||
];
|
||||
return { initial, afterSetup, afterFirstStorm, beforeFirstMentat, afterFirstMentat, beforeNexusDraw, afterNexusDraw, beforeBattle, game, ixians, tleilaxu, opponent, partners, kind, location, replacementSource, battleStep, planActions, actions, staging };
|
||||
}
|
||||
export function revealPairedIxNexusModulesBattle(state: Game, plans: PairedIxNexusModulesStep[]): Game {
|
||||
let game = structuredClone(state);
|
||||
for (const step of plans) {
|
||||
game = applyAction(game, step.actor, step.action);
|
||||
game = advancePairedIxNexusModules(game, g => !g.response && g.decision?.kind !== 'fullPlanOffer');
|
||||
}
|
||||
assert.ok(game.battle?.revealed);
|
||||
return game;
|
||||
}
|
||||
export function finishPairedIxNexusModulesBattle(state: Game): Game {
|
||||
return advancePairedIxNexusModules(state, g => !g.battle && !g.decision && !g.response && !g.pendingTreacheryDiscard);
|
||||
}
|
||||
export function finishPairedIxNexusModulesTurn(state: Game): Game {
|
||||
return advancePairedIxNexusModules(state, g => g.turn > state.turn);
|
||||
}
|
||||
export function choosePairedIxMobileBattle(state: Game, ixians: string, opponent: string): Game {
|
||||
let game = advancePairedIxNexusModules(state, g => g.phase === 6 && clean(g) && !!g.active);
|
||||
const actor = game.active!;
|
||||
assert.ok(actor === ixians || actor === opponent);
|
||||
game = applyAction(game, actor, { type: 'chooseBattle', territory: MOBILE_STRONGHOLD, target: actor === ixians ? opponent : ixians });
|
||||
game = advancePairedIxNexusModules(game, g => g.decision?.kind === 'strongholdCopy' || !!g.battle && !g.battle.revealed && !nextPairedIxNexusModulesStep(g));
|
||||
if (game.decision?.kind === 'strongholdCopy') {
|
||||
assert.equal(game.decision.player, ixians);
|
||||
game = applyAction(game, ixians, { type: 'decision', event: game.decision.event, stronghold: 'arrakeen' });
|
||||
game = advancePairedIxNexusModules(game, g => !!g.battle && !g.battle.revealed && !nextPairedIxNexusModulesStep(g));
|
||||
}
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Next native turn, conserved Suboids alone make expiry observable by a real
|
||||
* fractional dial; no phase, Nexus receipt or budget is assigned. */
|
||||
export function preparePairedIxExpiryBattle(state: Game, ixians: string, tleilaxu: string): Game {
|
||||
let game = advancePairedIxNexusModules(state, g => g.phase === 5 && clean(g));
|
||||
reserveBoard(game);
|
||||
const location = `arrakeen:${territory('arrakeen').sectors[0]}`;
|
||||
place(game, ixians, location, 3);
|
||||
place(game, tleilaxu, location, 2);
|
||||
game = advancePairedIxNexusModules(game, g => g.phase === 6 && clean(g));
|
||||
const actor = game.active!;
|
||||
assert.ok(actor === ixians || actor === tleilaxu);
|
||||
game = applyAction(game, actor, { type: 'chooseBattle', territory: 'arrakeen', target: actor === ixians ? tleilaxu : ixians });
|
||||
return advancePairedIxNexusModules(game, g => !!g.battle && !g.battle.revealed && !nextPairedIxNexusModulesStep(g));
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { gameDistance, splitLocation, TERRITORIES, territory } from '../game/board';
|
||||
import { presenceAt } from '../game/force-presence';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import { quotePhaseResources } from '../game/phase-resource-quote';
|
||||
import { quoteSpiceCollection } from '../game/board-resolution-quote';
|
||||
import { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions';
|
||||
import { orderNexusSpice } from './fixture-nexus-cards';
|
||||
import {
|
||||
advancePairedChoam, allowPairedChoam, createPairedChoamNexusModulesFixture,
|
||||
holdPairedChoamCard, initializePairedChoamModules, pairedChoamClean,
|
||||
pairedChoamPlayer as player, pairedRicheseCunningRequest, placePairedChoam,
|
||||
revealPairedRichese, type PairedChoamNexusModulesFixture,
|
||||
} from './fixture-paired-choam-nexus-modules';
|
||||
|
||||
function reject(game: Game, actor: string, action: Action): void {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before, 'An illegal native choice cannot consume original money, tokens, cards or counters');
|
||||
}
|
||||
function physicalCards(game: Game): string[] {
|
||||
return [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
|
||||
...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []),
|
||||
...(game.auction?.cards.slice(game.auction.index) ?? []), ...game.players.flatMap(p => p.hand)].map(c => c.id).sort();
|
||||
}
|
||||
function conserved(game: Game, original: Game): void {
|
||||
assert.deepEqual(physicalCards(game), physicalCards(original), 'Original E2 deck and native cache have one physical custody location per card');
|
||||
for (const p of game.players) {
|
||||
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), 20, p.faction);
|
||||
assert.deepEqual(p.noField?.tokens, player(original, p.id).noField?.tokens, 'Physical marker identities and printed values never change');
|
||||
}
|
||||
}
|
||||
function assertPair(f: PairedChoamNexusModulesFixture, after: Game, faceup: number): void {
|
||||
const before = player(f.game, f.richese), p = player(after, f.richese);
|
||||
const request = pairedRicheseCunningRequest(f);
|
||||
assert.equal(p.spice, before.spice - 1, 'The original one-marker Stronghold tariff is paid, not the eight-force tariff');
|
||||
assert.equal(player(after, f.guild).spice, player(f.game, f.guild).spice + 1, 'The actual native Guild receives the one-spice invoice');
|
||||
assert.equal(p.reserves, before.reserves - faceup);
|
||||
assert.equal(p.forces[f.location] ?? 0, faceup);
|
||||
assert.equal(p.noField!.deployed!.tokenId, request.noField);
|
||||
assert.equal(p.noField!.lastShipped, request.noField);
|
||||
assert.equal(presenceAt(p, 'habbanya_ridge_sietch'), faceup + 1);
|
||||
assert.equal(after.nexusCards!.cards!.hands[f.richese], null);
|
||||
assert.equal(after.nexusRicheseCunningLast!.stage, 'shipped');
|
||||
conserved(after, f.afterSetup);
|
||||
}
|
||||
|
||||
for (const modules of [
|
||||
{ advanced: false, tech: true, strongholds: false },
|
||||
{ advanced: true, tech: true, strongholds: false },
|
||||
{ advanced: true, tech: false, strongholds: true },
|
||||
{ advanced: true, tech: true, strongholds: true },
|
||||
]) void test(`paired E2 ${modules.advanced ? 'Advanced' : 'Basic'} Tech=${modules.tech} SH=${modules.strongholds}: real two-token shipment, one tariff and once-only phase-end Heighliners`, () => {
|
||||
const f = createPairedChoamNexusModulesFixture(modules), request = pairedRicheseCunningRequest(f);
|
||||
assert.equal(player(f.firstShipment.after, f.richese).spice, player(f.firstShipment.before, f.richese).spice - 1);
|
||||
assert.equal(player(f.firstShipment.after, f.richese).reserves, player(f.firstShipment.before, f.richese).reserves);
|
||||
const previous = player(f.game, f.richese).noField!.tokens.find(t => t.value === 0)!;
|
||||
assert.equal(player(f.game, f.richese).noField!.lastShipped, previous.id, 'Genuine first-turn shipment, not a staged last-used stamp');
|
||||
reject(f.game, f.richese, { ...request, noField: previous.id });
|
||||
reject(f.game, f.richese, { ...request, revealedToken: previous.id });
|
||||
reject(f.game, f.richese, { ...request, revealedToken: request.noField });
|
||||
const declared = applyAction(f.game, f.richese, request);
|
||||
const shipped = allowPairedChoam(declared);
|
||||
assertPair(f, shipped, 5);
|
||||
if (modules.strongholds) {
|
||||
assert.equal(f.afterFirstMentat.strongholdCards!.owners.habbanya_ridge_sietch, f.richese);
|
||||
assert.equal(shipped.strongholdCards!.owners.habbanya_ridge_sietch, f.richese, 'The card is retained even after controlled board relocation');
|
||||
assert.equal(f.afterFirstMentat.strongholdCards!.owners.arrakeen, f.choam);
|
||||
}
|
||||
if (modules.tech) {
|
||||
const token = shipped.techTokens!.heighliners;
|
||||
assert.ok(token.owner);
|
||||
const amount = ownedTech(f.game.techTokens, token.owner).length;
|
||||
assert.equal(token.triggeredTurn, shipped.turn);
|
||||
assert.equal(token.spice, amount);
|
||||
assert.equal(player(shipped, token.owner).spice, player(f.game, token.owner).spice + (token.owner === f.richese ? -1 : token.owner === f.guild ? 1 : 0), 'Industry accrues without immediate wallet credit');
|
||||
reject(shipped, f.richese, { type: 'ship', amount: 1, territory: 'arrakeen', sector: territory('arrakeen').sectors[0] });
|
||||
let another = shipped;
|
||||
if (shipped.movementRemaining?.includes(f.choam)) {
|
||||
const atChoam = advancePairedChoam(shipped, g => g.phase === 5 && g.active === f.choam && pairedChoamClean(g));
|
||||
another = allowPairedChoam(applyAction(atChoam, f.choam, { type: 'ship', amount: 1, territory: 'arrakeen', sector: territory('arrakeen').sectors[0] }));
|
||||
assert.equal(player(another, f.choam).spice, player(atChoam, f.choam).spice - 1);
|
||||
assert.equal(player(another, f.guild).spice, player(atChoam, f.guild).spice + 1);
|
||||
}
|
||||
assert.equal(another.techTokens!.heighliners.spice, amount, 'Neither a second paid shipment nor a rejected repeated shipment accrues industry again');
|
||||
const credit = quotePhaseResources(another).credits.find(c => c.kind === 'tech' && c.token === 'heighliners');
|
||||
assert.ok(credit);
|
||||
assert.equal(credit.player, token.owner);
|
||||
assert.equal(credit.amount, amount);
|
||||
const collection = quoteSpiceCollection(another).receipts.find(r => r.player === token.owner);
|
||||
const ended = advancePairedChoam(another, g => g.phase >= 6 && pairedChoamClean(g));
|
||||
assert.equal(player(ended, token.owner).spice, credit.balance +
|
||||
(ended.phase === 7 ? (collection?.collected ?? 0) + (collection?.strongholds ?? 0) : 0));
|
||||
assert.equal(ended.techTokens!.heighliners.spice, 0);
|
||||
assert.equal(ended.techTokens!.heighliners.triggeredTurn, ended.turn);
|
||||
assertPair(f, shipped, 5);
|
||||
conserved(ended, f.afterSetup);
|
||||
}
|
||||
});
|
||||
|
||||
void test('paired E2 materializes only available physical reserves, and voluntary revelation retains previous-marker restriction on the next native shipment', () => {
|
||||
const f = createPairedChoamNexusModulesFixture({ advanced: false, strongholds: false, reserveCap: 2 });
|
||||
const shipped = allowPairedChoam(applyAction(f.game, f.richese, pairedRicheseCunningRequest(f)));
|
||||
assertPair(f, shipped, 2);
|
||||
const revealed = revealPairedRichese(shipped, f.richese);
|
||||
assert.equal(player(revealed, f.richese).noField!.deployed, null);
|
||||
assert.equal(player(revealed, f.richese).forces[f.location], 2, 'Concealed three cannot manufacture counters when reserves are empty');
|
||||
assert.equal(player(revealed, f.richese).reserves, 0);
|
||||
const previous = player(revealed, f.richese).noField!.lastShipped!;
|
||||
let next = advancePairedChoam(revealed, g => g.turn === 3 && g.phase === 1 && pairedChoamClean(g));
|
||||
// Deck ordering remains physical; prevent a fresh worm from changing this native lifecycle program.
|
||||
orderNexusSpice(next, ['land', 'land']);
|
||||
next = advancePairedChoam(next, g => g.turn === 3 && g.phase === 5 && g.active === f.richese && pairedChoamClean(g));
|
||||
reject(next, f.richese, { type: 'ship', noField: previous, event: player(next, f.richese).noFieldEvent, territory: 'polar_sink', sector: 0 });
|
||||
const zero = player(next, f.richese).noField!.tokens.find(t => t.value === 0)!;
|
||||
const accepted = allowPairedChoam(applyAction(next, f.richese, { type: 'ship', noField: zero.id, event: player(next, f.richese).noFieldEvent, territory: 'polar_sink', sector: 0 }));
|
||||
assert.equal(player(accepted, f.richese).noField!.lastShipped, zero.id);
|
||||
assert.equal(player(accepted, f.richese).noField!.deployed!.tokenId, zero.id);
|
||||
assert.equal(player(accepted, f.richese).spice, player(next, f.richese).spice - 2, 'Polar Sink uses the actual ordinary one-marker non-Stronghold price');
|
||||
conserved(accepted, f.afterSetup);
|
||||
});
|
||||
|
||||
void test('paired E2 Karama-stopped pair spends Nexus but neither invoice, counters, markers nor Heighliners accrual', () => {
|
||||
const f = createPairedChoamNexusModulesFixture();
|
||||
const karama = holdPairedChoamCard(f.game, f.guild, c => c.kind === 'special' && c.effect === 'karama');
|
||||
const before = structuredClone(f.game), request = pairedRicheseCunningRequest(f);
|
||||
const pending = applyAction(f.game, f.richese, request);
|
||||
assert.equal(pending.response?.kind, 'richeseNoField');
|
||||
assert.equal(pending.pendingShipment?.cost, 1);
|
||||
assert.equal(pending.pendingShipment?.amount, 1);
|
||||
assert.equal(pending.nexusCards!.cards!.hands[f.richese], null);
|
||||
const stopped = allowPairedChoam(applyAction(pending, f.guild, { type: 'card', mode: 'cancel', card: karama.id }));
|
||||
assert.equal(stopped.nexusRicheseCunningLast!.stage, 'stopped');
|
||||
for (const p of before.players) assert.equal(player(stopped, p.id).spice, p.spice, 'No paid leg exists for the stopped shipment');
|
||||
assert.deepEqual(player(stopped, f.richese).forces, player(before, f.richese).forces);
|
||||
assert.equal(player(stopped, f.richese).reserves, player(before, f.richese).reserves);
|
||||
assert.deepEqual(player(stopped, f.richese).noField, player(before, f.richese).noField);
|
||||
assert.deepEqual(stopped.techTokens, before.techTokens);
|
||||
assert.equal(player(stopped, f.richese).shipped, false);
|
||||
reject(stopped, f.richese, request);
|
||||
const ordinary = allowPairedChoam(applyAction(stopped, f.richese, { type: 'ship', amount: 1, territory: 'habbanya_ridge_sietch', sector: territory('habbanya_ridge_sietch').sectors[0] }));
|
||||
assert.equal(player(ordinary, f.richese).spice, player(stopped, f.richese).spice - 1);
|
||||
assert.equal(player(ordinary, f.guild).spice, player(stopped, f.guild).spice + 1);
|
||||
assert.equal(ordinary.techTokens!.heighliners.triggeredTurn, ordinary.turn);
|
||||
conserved(ordinary, f.afterSetup);
|
||||
});
|
||||
|
||||
void test('paired CHOAM own Cunning consumes one original physical fuel card through the real printed Kulon response and enables its extra territory', () => {
|
||||
const f = createPairedChoamNexusModulesFixture();
|
||||
let game = advancePairedChoam(f.movementStart, g => g.phase === 5 && g.active === f.choam && pairedChoamClean(g));
|
||||
const card = holdPairedChoamCard(game, f.choam, c => c.kind === 'projectile');
|
||||
holdPairedChoamCard(game, f.guild, c => c.kind === 'special' && c.effect === 'karama');
|
||||
const offer = viewGame(game, f.choam).choamWorthless!.plays.find(p => p.source === 'nexus' && p.card.id === card.id && p.effect === 'kulon');
|
||||
assert.ok(offer && offer.source === 'nexus' && !offer.blocked);
|
||||
const request: Action = { type: 'card', mode: 'choam', card: card.id, effect: 'kulon', nexus: offer.event };
|
||||
const before = structuredClone(game);
|
||||
game = applyAction(game, f.choam, request);
|
||||
assert.equal(game.response?.kind, 'choamWorthless');
|
||||
assert.equal(game.nexusCards!.cards!.hands[f.choam], null);
|
||||
assert.ok(player(game, f.choam).hand.some(c => c.id === card.id), 'Fuel remains physical until the original response resolves');
|
||||
game = allowPairedChoam(game);
|
||||
assert.equal(game.nexusChoamLast!.stage, 'complete');
|
||||
assert.equal(game.discard.filter(c => c.id === card.id).length, 1);
|
||||
assert.equal(game.choamMovement!.bonus, 1);
|
||||
assert.equal(viewGame(game, f.choam).choamMovementBonus, 1);
|
||||
for (const p of before.players) assert.equal(player(game, p.id).spice, p.spice);
|
||||
reject(game, f.choam, request);
|
||||
const keys = TERRITORIES.flatMap(t => t.sectors.map(s => `${t.id}:${s}`));
|
||||
let destination: string | undefined;
|
||||
const source = keys.find(from => {
|
||||
const origin = splitLocation(from);
|
||||
if (origin.sector === game.storm || territory(origin.territory).type === 'stronghold' || origin.territory === 'polar_sink') return false;
|
||||
destination = keys.find(to => splitLocation(to).sector !== game.storm &&
|
||||
gameDistance(game, from, to, k => splitLocation(k).sector === game.storm) === 2);
|
||||
return !!destination;
|
||||
});
|
||||
assert.ok(source && destination, 'An actual clear two-territory route is available for Kulon');
|
||||
placePairedChoam(game, f.choam, source, 1);
|
||||
const move: Action = { type: 'move', from: source, amount: 1, ...splitLocation(destination) };
|
||||
placePairedChoam(before, f.choam, source, 1);
|
||||
reject(before, f.choam, move);
|
||||
const moved = allowPairedChoam(applyAction(game, f.choam, move));
|
||||
assert.equal(player(moved, f.choam).forces[destination], 1);
|
||||
assert.equal(player(moved, f.choam).forces[source] ?? 0, 0);
|
||||
assert.equal(player(moved, f.choam).moved, 1);
|
||||
conserved(moved, f.afterSetup);
|
||||
conserved(game, f.afterSetup);
|
||||
});
|
||||
|
||||
void test('paired Advanced Habbanya retained effect wins an ordinary tied battle after native voluntary marker reveal, without entering mixed No-Field plans', () => {
|
||||
const f = createPairedChoamNexusModulesFixture();
|
||||
let game = allowPairedChoam(applyAction(f.game, f.richese, pairedRicheseCunningRequest(f)));
|
||||
game = revealPairedRichese(game, f.richese);
|
||||
assert.equal(player(game, f.richese).noField!.deployed, null);
|
||||
assert.equal(player(game, f.richese).forces[f.location], 8);
|
||||
placePairedChoam(game, f.guild, f.location, 6);
|
||||
game = advancePairedChoam(game, g => g.phase === 6 && pairedChoamClean(g));
|
||||
assert.ok(game.active === f.richese || game.active === f.guild);
|
||||
game = applyAction(game, game.active!, { type: 'chooseBattle', territory: 'habbanya_ridge_sietch', target: game.active === f.richese ? f.guild : f.richese });
|
||||
game = advancePairedChoam(game, g => pairedChoamClean(g) && !!g.battle && (!g.battle.preLeader || g.battle.preLeader.closed) && !g.battle.preparation);
|
||||
const richeseLeader = player(game, f.richese).leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0];
|
||||
const guildLeader = player(game, f.guild).leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0];
|
||||
assert.ok(richeseLeader && guildLeader);
|
||||
const richeseDial = Math.max(0, guildLeader.strength - richeseLeader.strength) + 1;
|
||||
const guildDial = Math.max(0, richeseLeader.strength - guildLeader.strength) + 1;
|
||||
assert.ok(richeseDial <= 8 && guildDial <= 6);
|
||||
for (const [actor, leader, dial] of [[f.richese, richeseLeader.id, richeseDial], [f.guild, guildLeader.id, guildDial]] as const) {
|
||||
game = applyAction(game, actor, { type: 'battlePlan', leader, dial, support: dial, weapon: null, defense: null });
|
||||
game = allowPairedChoam(game);
|
||||
}
|
||||
assert.ok(game.battle?.revealed);
|
||||
const revealed = structuredClone(game), quote = quoteStrongholdFactionsBattle(game);
|
||||
assert.equal(richeseDial + richeseLeader.strength, guildDial + guildLeader.strength);
|
||||
assert.equal(viewGame(revealed, f.richese).battle!.strongholdEffects[f.richese], 'habbanya_ridge_sietch', 'Tie benefit is attributed to the actual retained card holder and committed plan');
|
||||
assert.equal(quote.winner, f.richese);
|
||||
const payment = quote.payments.find(p => p.player === f.richese)!;
|
||||
assert.equal(payment.ownPayment, richeseDial);
|
||||
assert.equal(payment.bankSupport, 0, 'Habbanya grants tie priority, not Arrakeen support');
|
||||
game = advancePairedChoam(game, g => !g.battle && !g.response && !g.decision && !g.pendingTreacheryDiscard);
|
||||
assert.equal(game.lastBattleContext!.winner, f.richese);
|
||||
assert.equal(player(game, f.richese).tanks, richeseDial);
|
||||
assert.equal(player(game, f.richese).forces[f.location], 8 - richeseDial);
|
||||
assert.equal(player(game, f.guild).tanks, 6);
|
||||
assert.equal(game.strongholdCards!.owners.habbanya_ridge_sietch, f.richese);
|
||||
const collection = quoteSpiceCollection(game).receipts;
|
||||
for (const p of revealed.players) {
|
||||
const paid = quote.payments.find(r => r.player === p.id)?.ownPayment ?? 0;
|
||||
const choam = quote.choamIncome?.owner === p.id ? quote.choamIncome.amount : 0;
|
||||
const city = collection.find(r => r.player === p.id);
|
||||
assert.equal(player(game, p.id).spice, p.spice - paid + choam + (city?.strongholds ?? 0) + (city?.collected ?? 0), 'Plan support and native CHOAM bank legs are separate from city Collection');
|
||||
}
|
||||
for (const token of ownedTech(revealed.techTokens, f.guild)) assert.equal(game.techTokens![token].owner, f.richese);
|
||||
conserved(game, f.afterSetup);
|
||||
});
|
||||
|
||||
void test('paired original undealt CLI setup preserves authenticated actors and first deal while continuing real closing Nexus participation', () => {
|
||||
const lobby = createGame('CHOAMCLIAUDIT', newPlayer('auth-richese', 'R', 'richese'), true, ['choam']);
|
||||
joinGame(lobby, newPlayer('auth-choam', 'C', 'choam'));
|
||||
joinGame(lobby, newPlayer('auth-guild', 'G', 'guild'));
|
||||
joinGame(lobby, newPlayer('auth-atreides', 'A', 'atreides'));
|
||||
const setup = initializePairedChoamModules({ initial: lobby });
|
||||
const original = structuredClone(setup);
|
||||
const f = createPairedChoamNexusModulesFixture({ initial: setup });
|
||||
assert.deepEqual(setup, original);
|
||||
assert.deepEqual(f.initial, setup, 'Supplied undealt initializer is not restarted or redealt');
|
||||
assert.equal(f.richese, 'auth-richese');
|
||||
assert.equal(f.choam, 'auth-choam');
|
||||
assert.equal(f.guild, 'auth-guild');
|
||||
assert.equal(f.observer, 'auth-atreides');
|
||||
const live = viewGame(f.game, f.richese);
|
||||
live.players.find(p => p.id === f.richese)!.bot = 'Hard';
|
||||
const request = botActions(live)[0];
|
||||
assert.ok(request?.type === 'ship' && request.noField && request.revealedToken);
|
||||
const completed = allowPairedChoam(applyAction(f.game, f.richese, request));
|
||||
assert.equal(completed.nexusRicheseCunningLast!.stage, 'shipped');
|
||||
assert.equal(completed.nexusCards!.cards!.hands[f.richese], null);
|
||||
conserved(completed, f.afterSetup);
|
||||
});
|
||||
|
||||
void test('paired Basic Tech ordinary battle uses revealed physical pools and no Advanced support payment or CHOAM bank-support income', () => {
|
||||
const f = createPairedChoamNexusModulesFixture({ advanced: false, strongholds: false });
|
||||
let game = allowPairedChoam(applyAction(f.game, f.richese, pairedRicheseCunningRequest(f)));
|
||||
game = revealPairedRichese(game, f.richese);
|
||||
placePairedChoam(game, f.guild, f.location, 6);
|
||||
game = advancePairedChoam(game, g => g.phase === 6 && pairedChoamClean(g));
|
||||
assert.ok(game.active === f.richese || game.active === f.guild);
|
||||
game = applyAction(game, game.active!, { type: 'chooseBattle', territory: 'habbanya_ridge_sietch', target: game.active === f.richese ? f.guild : f.richese });
|
||||
game = advancePairedChoam(game, g => pairedChoamClean(g) && !!g.battle && (!g.battle.preLeader || g.battle.preLeader.closed) && !g.battle.preparation);
|
||||
const winner = player(game, f.richese).leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0];
|
||||
const loser = player(game, f.guild).leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0];
|
||||
assert.ok(winner && loser);
|
||||
assert.ok(winner.strength + 3 > loser.strength);
|
||||
for (const [actor, leader, dial] of [[f.richese, winner.id, 3], [f.guild, loser.id, 0]] as const) {
|
||||
game = applyAction(game, actor, { type: 'battlePlan', leader, dial, support: 0, weapon: null, defense: null });
|
||||
game = allowPairedChoam(game);
|
||||
}
|
||||
assert.ok(game.battle?.revealed);
|
||||
const revealed = structuredClone(game), quote = quoteStrongholdFactionsBattle(game);
|
||||
assert.equal(quote.winner, f.richese);
|
||||
assert.equal(quote.choamIncome, null);
|
||||
for (const payment of quote.payments) {
|
||||
assert.equal(payment.ownPayment, 0);
|
||||
assert.equal(payment.bankSupport, 0);
|
||||
assert.equal(payment.cost, 0, 'Basic dials never become Advanced support invoices');
|
||||
}
|
||||
game = advancePairedChoam(game, g => !g.battle && !g.response && !g.decision && !g.pendingTreacheryDiscard);
|
||||
assert.equal(player(game, f.richese).tanks, 3);
|
||||
assert.equal(player(game, f.richese).forces[f.location], 5);
|
||||
assert.equal(player(game, f.guild).tanks, 6);
|
||||
for (const p of revealed.players) assert.equal(player(game, p.id).spice, p.spice);
|
||||
for (const token of ownedTech(revealed.techTokens, f.guild)) assert.equal(game.techTokens![token].owner, f.richese);
|
||||
conserved(game, f.afterSetup);
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { MOBILE_STRONGHOLD, territory } from '../game/board';
|
||||
import { quoteSpiceCollection } from '../game/board-resolution-quote';
|
||||
import { nexusFaceDancersAction } from '../game/nexus-tleilaxu-options';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import { strongholdBenefit, strongholdControllers } from '../game/stronghold-cards';
|
||||
import { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions';
|
||||
import {
|
||||
advancePairedIxNexusModules, choosePairedIxMobileBattle, createPairedIxNexusModulesFixture,
|
||||
finishPairedIxNexusModulesBattle, finishPairedIxNexusModulesTurn, pairedIxNexusModulesPlayer as player,
|
||||
preparePairedIxExpiryBattle, revealPairedIxNexusModulesBattle, type PairedIxNexusModulesFixture,
|
||||
type PairedIxNexusModulesStep,
|
||||
} from './fixture-paired-ix-nexus-modules';
|
||||
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before, 'An illegal consumer action cannot spend the original singleton, forces or spice');
|
||||
}
|
||||
function inventory(game: Game) {
|
||||
return [...game.deck, ...game.discard, ...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []),
|
||||
...(game.auction?.cards.slice(game.auction.index) ?? []), ...game.players.flatMap(p => p.hand)].map(c => c.id).sort();
|
||||
}
|
||||
function custody(game: Game, original: Game) {
|
||||
assert.deepEqual(inventory(game), inventory(original));
|
||||
for (const p of game.players) {
|
||||
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0), 20, p.faction);
|
||||
if (p.elites) {
|
||||
const old = player(original, p.id).elites!;
|
||||
assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0),
|
||||
old.reserves + old.tanks + Object.values(old.forces).reduce((sum, n) => sum + n, 0));
|
||||
for (const [key, count] of Object.entries(p.elites.forces)) assert.ok(count <= (p.forces[key] ?? 0));
|
||||
}
|
||||
}
|
||||
const traitors = [...(game.traitorReserve ?? []), ...game.players.flatMap(p => [...p.traitors, ...(p.faceDancers ?? []).map(c => c.leader)])].sort();
|
||||
const originalTraitors = [...(original.traitorReserve ?? []), ...original.players.flatMap(p => [...p.traitors, ...(p.faceDancers ?? []).map(c => c.leader)])].sort();
|
||||
assert.deepEqual(traitors, originalTraitors);
|
||||
assert.equal(new Set(traitors).size, traitors.length);
|
||||
}
|
||||
function reward(f: PairedIxNexusModulesFixture, game: Game) {
|
||||
assert.equal(game.lastBattleContext!.winner, f.ixians);
|
||||
if (f.game.techTokens) {
|
||||
const token = ownedTech(f.game.techTokens, f.opponent);
|
||||
assert.equal(token.length, 1);
|
||||
assert.equal(game.techTokens![token[0]].owner, f.ixians);
|
||||
assert.equal(game.techTokens![token[0]].spice, f.game.techTokens![token[0]].spice);
|
||||
assert.equal(ownedTech(game.techTokens, f.opponent).length, 0);
|
||||
}
|
||||
}
|
||||
function money(revealed: Game, settled: Game) {
|
||||
const quote = quoteStrongholdFactionsBattle(revealed);
|
||||
const collection = settled.phase === 7 && !settled.phaseOpening ? quoteSpiceCollection(settled).receipts : [];
|
||||
for (const p of revealed.players) {
|
||||
const payment = quote.payments.find(r => r.player === p.id)?.ownPayment ?? 0;
|
||||
const income = quote.strongholdIncome.filter(r => r.player === p.id).reduce((sum, r) => sum + r.amount, 0);
|
||||
const bounty = quote.bounty?.player === p.id ? quote.bounty.amount : 0;
|
||||
const city = collection.filter(r => r.player === p.id).reduce((sum, r) => sum + r.collected + r.strongholds, 0);
|
||||
assert.equal(player(settled, p.id).spice, p.spice - payment + income + bounty + city, p.faction);
|
||||
}
|
||||
return quote;
|
||||
}
|
||||
function useSuboids(f: PairedIxNexusModulesFixture): Game {
|
||||
const offer = viewGame(f.game, f.ixians).nexusSuboids!.offer!;
|
||||
assert.equal(offer.blocked, null);
|
||||
reject(f.game, f.tleilaxu, { type: 'nexusSuboids', event: offer.event });
|
||||
const game = applyAction(f.game, f.ixians, { type: 'nexusSuboids', event: offer.event });
|
||||
assert.equal(game.nexusCards!.cards!.hands[f.ixians], null);
|
||||
assert.equal(game.nexusCards!.cards!.discard.filter(face => face === 'ixians').length, 1);
|
||||
assert.equal(player(game, f.ixians).spice, player(f.game, f.ixians).spice);
|
||||
assert.deepEqual(player(game, f.ixians).forces, player(f.game, f.ixians).forces);
|
||||
assert.deepEqual(player(game, f.ixians).elites, player(f.game, f.ixians).elites);
|
||||
reject(game, f.ixians, { type: 'nexusSuboids', event: offer.event });
|
||||
return game;
|
||||
}
|
||||
|
||||
for (const mode of [
|
||||
{ advanced: false, tech: true, strongholds: false },
|
||||
{ advanced: true, tech: true, strongholds: false },
|
||||
{ advanced: true, tech: false, strongholds: true },
|
||||
{ advanced: true, tech: true, strongholds: true },
|
||||
]) void test(`paired E1 ${JSON.stringify(mode)}: original Nx Cunning, exact cyborg losses, equal substitution and held-site support`, () => {
|
||||
const f = createPairedIxNexusModulesFixture(mode);
|
||||
assert.equal(f.afterNexusDraw.nexusCards!.cards!.hands[f.ixians], 'ixians');
|
||||
assert.equal(f.afterNexusDraw.nexusCards!.cards!.hands[f.tleilaxu], 'tleilaxu');
|
||||
assert.equal(player(f.afterNexusDraw, f.partners[0]).ally, f.partners[1]);
|
||||
assert.equal(player(f.afterNexusDraw, f.ixians).ally, null);
|
||||
if (mode.strongholds) {
|
||||
assert.equal(f.afterFirstMentat.strongholdCards!.owners.arrakeen, f.ixians);
|
||||
assert.equal(f.afterFirstMentat.strongholdCards!.owners[MOBILE_STRONGHOLD], f.ixians);
|
||||
}
|
||||
const game = useSuboids(f);
|
||||
const revealed = revealPairedIxNexusModulesBattle(game, f.planActions);
|
||||
const quote = quoteStrongholdFactionsBattle(revealed);
|
||||
assert.equal(quote.winner, f.ixians);
|
||||
assert.equal(quote.casualties!.forces.normalFixedHalf, false);
|
||||
assert.equal(quote.casualties!.forces.normalFreeSupport, true);
|
||||
const payment = quote.payments.find(r => r.player === f.ixians)!;
|
||||
if (mode.advanced) {
|
||||
assert.equal(payment.bankSupport, mode.strongholds ? 1 : 0);
|
||||
assert.equal(payment.ownPayment, mode.strongholds ? 0 : 1);
|
||||
} else {
|
||||
assert.deepEqual(quote.payments, [], 'Basic battles create no spice-support payment receipts');
|
||||
}
|
||||
let losses = advancePairedIxNexusModules(revealed, g => g.decision?.kind === 'battleLosses' || g.decision?.kind === 'ixSubstitution');
|
||||
if (losses.decision?.kind === 'battleLosses') {
|
||||
const choice = losses.decision.options.findIndex(o => o.normal === 0 && o.elite === 3);
|
||||
assert.ok(choice >= 0, 'The actual committed dial can lose exactly the three original cyborgs');
|
||||
losses = applyAction(losses, f.ixians, { type: 'decision', choice });
|
||||
}
|
||||
const pending = advancePairedIxNexusModules(losses, g => g.decision?.kind === 'ixSubstitution');
|
||||
assert.equal(player(pending, f.ixians).forces[f.location], 3);
|
||||
assert.equal(player(pending, f.ixians).elites!.forces[f.location] ?? 0, 0);
|
||||
assert.equal(player(pending, f.ixians).tanks, player(revealed, f.ixians).tanks + 3);
|
||||
assert.equal(player(pending, f.ixians).elites!.tanks, 3);
|
||||
assert.equal(player(pending, f.ixians).spice, player(revealed, f.ixians).spice - (mode.advanced ? payment.ownPayment : 0));
|
||||
assert.deepEqual(pending.techTokens, revealed.techTokens, 'Native substitution precedes the actual loser Tech award');
|
||||
reject(pending, f.ixians, { type: 'decision', sources: { [f.location]: 3 }, recover: { [f.location]: 4 } });
|
||||
const exchanged = applyAction(pending, f.ixians, { type: 'decision', sources: { [f.location]: 3 }, recover: { [f.location]: 3 } });
|
||||
const settled = finishPairedIxNexusModulesBattle(exchanged);
|
||||
assert.equal(player(settled, f.ixians).forces[f.location], 3);
|
||||
assert.equal(player(settled, f.ixians).elites!.forces[f.location], 3);
|
||||
assert.equal(player(settled, f.ixians).tanks, player(revealed, f.ixians).tanks + 3);
|
||||
assert.equal(player(settled, f.ixians).elites!.tanks, 0);
|
||||
assert.equal(player(settled, f.opponent).tanks, player(revealed, f.opponent).tanks + 6);
|
||||
money(revealed, settled); reward(f, settled); custody(settled, f.afterSetup);
|
||||
|
||||
// A second real battle in the original turn uses the spent card's effect.
|
||||
const mobile = choosePairedIxMobileBattle(settled, f.ixians, f.opponent);
|
||||
assert.equal(viewGame(mobile, f.ixians).nexusSuboids!.active, true);
|
||||
if (mode.strongholds) assert.equal(viewGame(mobile, f.ixians).battle!.strongholdEffects[f.ixians], 'arrakeen');
|
||||
const leader = player(mobile, f.ixians).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => b.strength - a.strength)[0];
|
||||
const enemy = player(mobile, f.opponent).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => a.strength - b.strength)[0];
|
||||
const plans: PairedIxNexusModulesStep[] = [
|
||||
{ actor: f.ixians, action: { type: 'battlePlan', dial: 3, support: mode.advanced ? 1 : 0, leader: leader.id, weapon: null, defense: null } },
|
||||
{ actor: f.opponent, action: { type: 'battlePlan', dial: 0, support: 0, leader: enemy.id, weapon: null, defense: null } },
|
||||
];
|
||||
const second = revealPairedIxNexusModulesBattle(mobile, plans), secondQuote = quoteStrongholdFactionsBattle(second);
|
||||
assert.equal(secondQuote.casualties!.forces.normalFixedHalf, false);
|
||||
assert.equal(secondQuote.casualties!.forces.normalFreeSupport, true);
|
||||
if (mode.advanced) {
|
||||
const own = secondQuote.payments.find(r => r.player === f.ixians)!;
|
||||
assert.equal(own.bankSupport, mode.strongholds ? 1 : 0);
|
||||
assert.equal(own.ownPayment, mode.strongholds ? 0 : 1);
|
||||
} else assert.deepEqual(secondQuote.payments, []);
|
||||
let secondLosses = advancePairedIxNexusModules(second, g => g.decision?.kind === 'battleLosses' || g.decision?.kind === 'ixSubstitution');
|
||||
if (secondLosses.decision?.kind === 'battleLosses') {
|
||||
const choice = secondLosses.decision.options.findIndex(o => o.normal === 1 && o.elite === 1);
|
||||
assert.ok(choice >= 0);
|
||||
secondLosses = applyAction(secondLosses, f.ixians, { type: 'decision', choice });
|
||||
}
|
||||
const secondSettled = finishPairedIxNexusModulesBattle(secondLosses);
|
||||
money(second, secondSettled); custody(secondSettled, f.afterSetup);
|
||||
const nextTurn = finishPairedIxNexusModulesTurn(secondSettled);
|
||||
assert.equal(viewGame(nextTurn, f.ixians).nexusSuboids!.active, false);
|
||||
const expired = preparePairedIxExpiryBattle(nextTurn, f.ixians, f.tleilaxu);
|
||||
const expiredLeader = player(expired, f.ixians).leaders.find(l => !l.dead)!;
|
||||
reject(expired, f.ixians, { type: 'battlePlan', dial: 3, support: 0, leader: expiredLeader.id, weapon: null, defense: null });
|
||||
const sealed = applyAction(expired, f.ixians, { type: 'battlePlan', dial: 1.5, support: 0, leader: expiredLeader.id, weapon: null, defense: null });
|
||||
assert.equal(sealed.battle!.plans[f.ixians].dial, 1.5);
|
||||
custody(sealed, f.afterSetup);
|
||||
});
|
||||
|
||||
void test('native paired Cunning requires an own unsealed plan and grants full unsupported Suboid casualties, not force manufacture', () => {
|
||||
const f = createPairedIxNexusModulesFixture({ advanced: true, strongholds: false });
|
||||
const offer = viewGame(f.game, f.ixians).nexusSuboids!.offer!;
|
||||
const sealed = applyAction(f.game, f.ixians, f.planActions[0].action);
|
||||
reject(sealed, f.ixians, { type: 'nexusSuboids', event: offer.event });
|
||||
const plans = structuredClone(f.planActions);
|
||||
plans[0].action.dial = 3; plans[0].action.support = 0;
|
||||
const empowered = revealPairedIxNexusModulesBattle(useSuboids(f), plans);
|
||||
const ordinary = revealPairedIxNexusModulesBattle(f.game, plans);
|
||||
const full = quoteStrongholdFactionsBattle(empowered), half = quoteStrongholdFactionsBattle(ordinary);
|
||||
assert.ok(full.casualties!.options.some(o => o.normal === 3 && o.elite === 0));
|
||||
assert.ok(!half.casualties!.options.some(o => o.normal === 3 && o.elite === 0));
|
||||
assert.equal(full.payments.find(r => r.player === f.ixians)!.ownPayment, 0);
|
||||
assert.deepEqual(player(empowered, f.ixians).forces, player(ordinary, f.ixians).forces);
|
||||
assert.deepEqual(player(empowered, f.ixians).elites, player(ordinary, f.ixians).elites);
|
||||
const losses = advancePairedIxNexusModules(empowered, g => g.decision?.kind === 'battleLosses');
|
||||
assert.equal(losses.decision!.kind, 'battleLosses');
|
||||
if (losses.decision?.kind !== 'battleLosses') throw Error('Missing native Suboid casualty decision');
|
||||
const choice = losses.decision.options.findIndex(o => o.normal === 3 && o.elite === 0);
|
||||
assert.ok(choice >= 0);
|
||||
const paid = applyAction(losses, f.ixians, { type: 'decision', choice });
|
||||
const settled = finishPairedIxNexusModulesBattle(paid);
|
||||
assert.equal(player(settled, f.ixians).forces[f.location], 3);
|
||||
assert.equal(player(settled, f.ixians).elites!.forces[f.location], 3);
|
||||
assert.equal(player(settled, f.ixians).tanks, player(empowered, f.ixians).tanks + 3);
|
||||
assert.equal(player(settled, f.ixians).elites!.tanks, player(empowered, f.ixians).elites!.tanks);
|
||||
money(empowered, settled); reward(f, settled); custody(settled, f.afterSetup);
|
||||
});
|
||||
|
||||
void test('paired native Face Dance preserves original winner rewards, cards and Tech, then claims Stronghold at actual END Mentat; partial-stock Cunning draws before retiring originals', () => {
|
||||
const f = createPairedIxNexusModulesFixture({ program: 'faceDance', advanced: true, tech: true, strongholds: true });
|
||||
const revealed = revealPairedIxNexusModulesBattle(f.game, f.planActions);
|
||||
const pending = advancePairedIxNexusModules(revealed, g => g.decision?.kind === 'faceDance');
|
||||
const quote = money(revealed, pending);
|
||||
assert.deepEqual(quote.strongholdIncome, [{ player: f.ixians, amount: 1 }]);
|
||||
assert.equal(quote.bounty!.player, f.ixians);
|
||||
reward(f, pending);
|
||||
const winner = player(pending, f.ixians), survivorCount = winner.forces[f.location];
|
||||
const elite = winner.elites!.forces[f.location] ?? 0;
|
||||
for (const id of [f.planActions[0].action.weapon, f.planActions[0].action.defense]) assert.ok(winner.hand.some(c => c.id === id));
|
||||
const dancerBefore = structuredClone(player(pending, f.tleilaxu).faceDancers!);
|
||||
assert.ok(dancerBefore.every(c => !c.revealed));
|
||||
reject(pending, f.tleilaxu, { type: 'decision', reveal: true, sources: { reserves: survivorCount + 1 }, sector: territory(f.kind).sectors[0] });
|
||||
const after = applyAction(pending, f.tleilaxu, { type: 'decision', reveal: true, sources: { [f.replacementSource]: 2, reserves: survivorCount - 2 }, sector: territory(f.kind).sectors[0] });
|
||||
const returned = player(after, f.ixians), replacement = player(after, f.tleilaxu);
|
||||
assert.equal(returned.forces[f.location] ?? 0, 0);
|
||||
assert.equal(returned.elites!.forces[f.location] ?? 0, 0);
|
||||
assert.equal(returned.reserves, winner.reserves + survivorCount);
|
||||
assert.equal(returned.elites!.reserves, winner.elites!.reserves + elite);
|
||||
assert.equal(returned.tanks, winner.tanks); assert.equal(returned.elites!.tanks, winner.elites!.tanks);
|
||||
assert.equal(returned.spice, winner.spice); assert.deepEqual(returned.hand, winner.hand);
|
||||
assert.deepEqual(after.techTokens, pending.techTokens);
|
||||
assert.equal(replacement.forces[f.location], survivorCount);
|
||||
assert.equal(replacement.forces[f.replacementSource], 1);
|
||||
assert.equal(replacement.reserves, player(pending, f.tleilaxu).reserves - survivorCount + 2);
|
||||
assert.equal(strongholdControllers(after.players, true)[f.kind], f.tleilaxu);
|
||||
assert.equal(strongholdBenefit(after.strongholdCards, f.ixians, f.kind), f.kind);
|
||||
assert.equal(strongholdBenefit(after.strongholdCards, f.tleilaxu, f.kind), null);
|
||||
assert.equal(returned.leaders.find(l => l.id === f.planActions[0].action.leader)!.dead, true);
|
||||
const revealedStock = replacement.faceDancers!.filter(c => c.revealed);
|
||||
assert.equal(revealedStock.length, 1, 'Partial stock comes only from the actual successful Face Dance');
|
||||
const untouched = replacement.faceDancers!.filter(c => !c.revealed);
|
||||
assert.deepEqual(untouched, dancerBefore.filter(c => c.leader !== revealedStock[0].leader));
|
||||
const beforeDraw = [...after.traitorReserve!], allowance = replacement.faceDancerReplacedTurn;
|
||||
const view = viewGame(after, f.tleilaxu), offer = view.nexusTleilaxu!.cunning!;
|
||||
const action = nexusFaceDancersAction(view, offer.event); assert.ok(action);
|
||||
reject(after, f.tleilaxu, { ...action, leaders: [revealedStock[0].leader] });
|
||||
const replaced = applyAction(after, f.tleilaxu, action);
|
||||
const finalStock = player(replaced, f.tleilaxu).faceDancers!;
|
||||
assert.deepEqual(finalStock.filter(c => untouched.some(old => old.leader === c.leader)), untouched);
|
||||
assert.deepEqual(finalStock.filter(c => !untouched.some(old => old.leader === c.leader)), [{ leader: beforeDraw[0], revealed: false }]);
|
||||
assert.ok(!revealedStock.some(c => c.leader === beforeDraw[0]), 'Set-aside originals cannot be their own immediate draw');
|
||||
assert.deepEqual([...replaced.traitorReserve!].sort(), [...beforeDraw.slice(1), revealedStock[0].leader].sort());
|
||||
assert.equal(player(replaced, f.tleilaxu).faceDancerReplacedTurn, allowance);
|
||||
assert.equal(replaced.nexusCards!.cards!.hands[f.tleilaxu], null);
|
||||
assert.equal(replaced.nexusCards!.cards!.discard.filter(face => face === 'tleilaxu').length, 1);
|
||||
assert.deepEqual(replaced.techTokens, after.techTokens);
|
||||
reject(replaced, f.tleilaxu, action);
|
||||
custody(replaced, f.afterSetup);
|
||||
const mentat = advancePairedIxNexusModules(replaced, g => g.phase === 8 && !g.phaseOpening && !g.response && !g.decision);
|
||||
const ordinary = applyAction(mentat, f.tleilaxu, { type: 'replaceFaceDancer', leader: untouched[0].leader });
|
||||
const settled = advancePairedIxNexusModules(ordinary, g => !g.response && !g.decision);
|
||||
assert.equal(player(settled, f.tleilaxu).faceDancerReplacedTurn, settled.turn);
|
||||
reject(settled, f.tleilaxu, { type: 'replaceFaceDancer', leader: player(settled, f.tleilaxu).faceDancers![0].leader });
|
||||
const claimed = finishPairedIxNexusModulesTurn(settled);
|
||||
assert.equal(claimed.strongholdCards!.owners[f.kind], f.tleilaxu);
|
||||
assert.deepEqual(claimed.techTokens, after.techTokens);
|
||||
custody(claimed, f.afterSetup);
|
||||
});
|
||||
|
||||
void test('representative native bot Face Dance consumes only actual paired reserve/board sources', () => {
|
||||
const f = createPairedIxNexusModulesFixture({ program: 'faceDance' });
|
||||
const pending = advancePairedIxNexusModules(revealPairedIxNexusModulesBattle(f.game, f.planActions), g => g.decision?.kind === 'faceDance');
|
||||
const view = viewGame(pending, f.tleilaxu);
|
||||
view.players.find(p => p.id === f.tleilaxu)!.bot = 'Easy';
|
||||
const action = botActions(view).find(a => a.type === 'decision'); assert.ok(action);
|
||||
assert.equal(action.reveal, true);
|
||||
const after = applyAction(pending, f.tleilaxu, action);
|
||||
assert.equal(player(after, f.ixians).forces[f.location] ?? 0, 0);
|
||||
assert.equal(player(after, f.tleilaxu).forces[f.location], player(pending, f.ixians).forces[f.location]);
|
||||
assert.deepEqual(after.techTokens, pending.techTokens);
|
||||
custody(after, f.afterSetup);
|
||||
});
|
||||
|
||||
void test('original authenticated lobby and undealt CLI setup continue to actor-bound real Nexus Cunning without redealing', () => {
|
||||
const lobby = createGame('PAIREDIXAUTH', newPlayer('authenticated-ix', 'Ix', 'ixians'), true, ['ix']);
|
||||
for (const [id, faction] of [['authenticated-tl', 'tleilaxu'], ['authenticated-guild', 'guild'], ['authenticated-emperor', 'emperor']] as const)
|
||||
joinGame(lobby, newPlayer(id, faction, faction));
|
||||
const first = createPairedIxNexusModulesFixture({ initial: lobby, tech: true, strongholds: false });
|
||||
const continued = createPairedIxNexusModulesFixture({ initial: first.initial });
|
||||
assert.deepEqual(player(continued.afterSetup, first.ixians).hand, player(first.afterSetup, first.ixians).hand,
|
||||
'The original Ix setup offer and actual first deal are continued, not replaced');
|
||||
const empowered = useSuboids(continued);
|
||||
const revealed = revealPairedIxNexusModulesBattle(empowered, continued.planActions);
|
||||
assert.equal(quoteStrongholdFactionsBattle(revealed).winner, 'authenticated-ix');
|
||||
assert.equal(revealed.nexusSuboidHistory![0].owner, 'authenticated-ix');
|
||||
assert.equal(player(revealed, 'authenticated-tl').ally, null);
|
||||
custody(revealed, continued.afterSetup);
|
||||
});
|
||||
Reference in new issue
Block a user