Compose native Ecaz Betrayal and Moritani Atomics
Retain original selected modules, typed native returns, advisors and hidden No-Field recall. Preserve a full unspent Guild/Sapho opening, paid ordered arrivals, capped provisional No-Field destruction and native World Sneak withdrawal. Bind desert Aftermath to its original Moritani Cunning placement. Validation: frozen 16-file115/115, types/lint/build, eight finite original-action and GameTable SSR programmes, four minimal legal policies. Document provisional overlaps and corrected genuine recipes. No stored-game change or deployment; full combinations, comprehensive assurance and deployed acceptance remain required.
This commit is contained in:
1 parent
8793e23d6b
commit
bb9d292c4c
23 files changed
+1824
-88
No files matched your search
@@ -0,0 +1,167 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import type { Game } from '../game/engine';
|
||||
import { MOBILE_LOCATION } from '../game/board';
|
||||
import { homeworldForceGroups } from '../game/homeworld-custody';
|
||||
import { homeworldContext } from '../game/homeworld-game';
|
||||
import { deployRicheseNoField, projectRicheseNoField } from '../game/richese-no-field';
|
||||
import {
|
||||
createEcazBetrayalModulesFixture, ecazBetrayalModulesPlayer as player,
|
||||
resolveEcazBetrayalModules, smokeEcazBetrayalModules,
|
||||
type EcazBetrayalModulesFixture,
|
||||
} from './fixture-ecaz-betrayal-modules';
|
||||
|
||||
function queue(game: Game) {
|
||||
return { turn: game.turn, phase: game.phase, active: game.active, order: game.order,
|
||||
remaining: game.movementRemaining, choices: game.players.map(player => [player.id, player.shipped, player.moved]) };
|
||||
}
|
||||
function pool(game: Game, owner: string, location: string) {
|
||||
return homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!)
|
||||
.find(group => group.id === location)!.forces[owner];
|
||||
}
|
||||
function completion(f: EcazBetrayalModulesFixture, game: Game, cancel: boolean) {
|
||||
assert.deepEqual(queue(game), queue(f.game), 'Betrayal neither consumes nor reorders the original Shipment/Movement queue');
|
||||
assert.equal(game.nexusEcazBetrayalHistory!.at(-1)!.stage, 'complete');
|
||||
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length, 1);
|
||||
assert.equal(game.discard.filter(card => card.id === f.karama).length, cancel ? 1 : 0);
|
||||
assert.equal(player(game, f.ally).forces['arrakeen:10'], player(f.game, f.ally).forces['arrakeen:10'],
|
||||
'The actual paid outside group remains in its own territory');
|
||||
assert.deepEqual(player(game, 'ecaz').forces, player(f.game, 'ecaz').forces);
|
||||
assert.deepEqual(player(game, 'guild').forces, player(f.game, 'guild').forces);
|
||||
assert.deepEqual(game.strongholdCards, f.game.strongholdCards,
|
||||
'Returning troops does not reassign previously earned Stronghold Cards before Mentat');
|
||||
}
|
||||
|
||||
for (const modules of [false, true]) {
|
||||
void test(`original ${modules ? 'selected-family Worlds/all14/phase' : 'Ecaz'} typed Emperor Betrayal allows a native return or actual earned Karama cancellation`, () => {
|
||||
const f = createEcazBetrayalModulesFixture({ program: 'typed', advanced: true,
|
||||
expansions: modules ? ['ix', 'choam', 'ecaz'] : ['ecaz'], homeworlds: modules,
|
||||
skills: modules, tech: modules, strongholds: modules });
|
||||
assert.equal(f.game.turn, 3);
|
||||
assert.equal(f.game.phase, 5);
|
||||
assert.ok(f.game.players.every(player => !player.shipped && player.moved === 0));
|
||||
assert.equal(f.acquired.bid.actor, 'ecaz');
|
||||
assert.equal(f.acquired.bid.action.amount, 1);
|
||||
assert.ok(player(f.game, 'ecaz').hand.some(card => card.id === f.karama));
|
||||
assert.equal(f.afterDraw.nexusCards!.cards!.hands.guild, 'ecaz');
|
||||
assert.equal(player(f.afterDraw, 'ecaz').ally, 'emperor');
|
||||
assert.equal(player(f.afterDraw, 'emperor').ally, 'ecaz');
|
||||
assert.equal(player(f.afterDraw, 'guild').ally, null);
|
||||
assert.deepEqual(f.row.total, { normal: 1, elite: 2 });
|
||||
assert.equal(player(f.game, 'ecaz').forces[f.location], 6, 'The original six-counter Ecaz setup remains in Imperial Basin');
|
||||
assert.equal(player(f.game, 'emperor').forces[f.location], 3);
|
||||
assert.equal(player(f.game, 'emperor').elites!.forces[f.location], 2);
|
||||
const before = player(f.game, 'emperor');
|
||||
for (const cancel of [false, true]) {
|
||||
const { game, declared } = resolveEcazBetrayalModules(f, cancel);
|
||||
assert.equal(declared.response?.kind, 'nexusEcazBetrayal');
|
||||
assert.deepEqual(player(declared, 'emperor').forces, before.forces, 'The declared return waits for real counters');
|
||||
const after = player(game, 'emperor');
|
||||
completion(f, game, cancel);
|
||||
assert.equal(after.forces[f.location] ?? 0, cancel ? 3 : 0);
|
||||
assert.equal(after.elites!.forces[f.location] ?? 0, cancel ? 2 : 0);
|
||||
assert.equal(after.reserves, before.reserves + (cancel ? 0 : 3));
|
||||
assert.equal(after.elites!.reserves, before.elites!.reserves + (cancel ? 0 : 2));
|
||||
assert.equal(after.tanks, before.tanks);
|
||||
assert.equal(after.elites!.tanks, before.elites!.tanks);
|
||||
if (modules) {
|
||||
const k = 'homeworld:emperor', s = 'homeworld:emperor:salusa';
|
||||
assert.deepEqual(pool(game, 'emperor', k), { normal: pool(f.game, 'emperor', k).normal + (cancel ? 0 : 1),
|
||||
elite: pool(f.game, 'emperor', k).elite });
|
||||
assert.deepEqual(pool(game, 'emperor', s), { normal: pool(f.game, 'emperor', s).normal,
|
||||
elite: pool(f.game, 'emperor', s).elite + (cancel ? 0 : 2) });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void test('original Richese zero/three/five markers recall face down under Ecaz Betrayal; actual Karama preserves each concealed deployment', () => {
|
||||
const rows: unknown[] = [];
|
||||
for (const value of [0, 3, 5] as const) {
|
||||
const f = createEcazBetrayalModulesFixture({ program: 'richese', marker: value, advanced: true,
|
||||
expansions: ['choam', 'ecaz'], homeworlds: true, skills: true, tech: true, strongholds: true });
|
||||
const before = player(f.game, 'richese'), marker = structuredClone(before.noField!);
|
||||
const shipment = f.shipments.at(-1)!;
|
||||
assert.equal(shipment.step.action.noField, f.marker, 'The selected original token arrives through its paid own shipment');
|
||||
assert.equal(player(shipment.after, 'richese').noField!.deployed!.tokenId, f.marker);
|
||||
assert.equal(player(f.cooccupied, 'richese').noField!.deployed!.tokenId, f.marker);
|
||||
assert.ok(f.actions.every(step => step.action.type !== 'offerRicheseNoField'),
|
||||
'The continuation declines a separate optional allied shipment, whose acceptance would reveal the existing marker');
|
||||
assert.equal(marker.deployed!.tokenId, f.marker);
|
||||
assert.equal(marker.tokens.find(token => token.id === f.marker)!.value, value);
|
||||
assert.equal(before.forces[f.location] ?? 0, 0, 'The genuine concealed marker has not materialized counters');
|
||||
assert.equal(player(f.game, 'ecaz').forces[f.location], 6);
|
||||
assert.deepEqual(f.row.total, { normal: 0, elite: 0 });
|
||||
assert.equal('concealed' in f.row && f.row.concealed, true);
|
||||
assert.ok(!JSON.stringify(f.row).includes(f.marker!), 'Only public concealed presence enters the target row');
|
||||
rows.push(structuredClone(f.row));
|
||||
for (const cancel of [false, true]) {
|
||||
const { game } = resolveEcazBetrayalModules(f, cancel), after = player(game, 'richese');
|
||||
completion(f, game, cancel);
|
||||
assert.equal(after.reserves, before.reserves, 'A face-down recall neither materializes nor returns hidden counters');
|
||||
assert.equal(after.tanks, before.tanks);
|
||||
assert.deepEqual(after.forces, before.forces);
|
||||
assert.deepEqual(game.homeworlds, f.game.homeworlds, 'A marker-only recall changes no native physical pool');
|
||||
if (cancel) assert.deepEqual(after.noField, marker);
|
||||
else {
|
||||
assert.equal(after.noField!.deployed, null);
|
||||
assert.equal(after.noField!.lastShipped, f.marker, 'The original repeat ban survives a hidden return');
|
||||
assert.equal(projectRicheseNoField(after.noField!, false).lastUsed, null, 'Recalling face down reveals no denomination');
|
||||
assert.throws(() => deployRicheseNoField(after.noField!, marker.deployed!), /same No-Field|twice|row/i);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(rows[1], rows[0]);
|
||||
assert.deepEqual(rows[2], rows[0], 'All three original denominations have the same public target projection');
|
||||
});
|
||||
|
||||
void test('original Bene Gesserit advisors return completely from Ecaz cooccupation while an actual outside group and stance remain unchanged', () => {
|
||||
const f = createEcazBetrayalModulesFixture({ program: 'advisors', advanced: true,
|
||||
expansions: ['ix', 'choam', 'ecaz'], homeworlds: true, skills: true, tech: true, strongholds: true });
|
||||
const before = player(f.game, f.ally);
|
||||
assert.equal(before.forces[f.location], 2, 'One original setup advisor and one paid advisor shipment share Imperial Basin');
|
||||
assert.ok(before.advisors?.[f.target]);
|
||||
assert.deepEqual(f.row.total, { normal: 2, elite: 0 });
|
||||
for (const cancel of [false, true]) {
|
||||
const { game } = resolveEcazBetrayalModules(f, cancel), after = player(game, f.ally);
|
||||
completion(f, game, cancel);
|
||||
assert.equal(after.reserves, before.reserves + (cancel ? 0 : 2));
|
||||
assert.equal(after.forces[f.location] ?? 0, cancel ? 2 : 0);
|
||||
assert.equal(after.tanks, before.tanks);
|
||||
assert.equal(!!after.advisors?.[f.target], cancel);
|
||||
assert.deepEqual(after.advisors?.arrakeen, before.advisors?.arrakeen);
|
||||
}
|
||||
});
|
||||
|
||||
void test('actual parent-to-HMS Ecaz movement admits original mixed Ix garrison return without moving the structure or consuming its queue', () => {
|
||||
const f = createEcazBetrayalModulesFixture({ program: 'hms', advanced: true,
|
||||
expansions: ['ix', 'ecaz'], homeworlds: true, skills: true, tech: true, strongholds: true });
|
||||
assert.equal(f.entryMove!.action.type, 'move');
|
||||
assert.equal(f.entryMove!.action.from, 'polar_sink:0');
|
||||
assert.equal(player(f.game, 'ecaz').forces[MOBILE_LOCATION], 1);
|
||||
const before = player(f.game, 'ixians');
|
||||
assert.equal(before.forces[MOBILE_LOCATION], 6);
|
||||
assert.equal(before.elites!.forces[MOBILE_LOCATION], 3);
|
||||
assert.deepEqual(f.row.total, { normal: 3, elite: 3 });
|
||||
for (const cancel of [false, true]) {
|
||||
const { game } = resolveEcazBetrayalModules(f, cancel), after = player(game, 'ixians');
|
||||
completion(f, game, cancel);
|
||||
assert.equal(after.forces[MOBILE_LOCATION] ?? 0, cancel ? 6 : 0);
|
||||
assert.equal(after.elites!.forces[MOBILE_LOCATION] ?? 0, cancel ? 3 : 0);
|
||||
assert.equal(after.reserves, before.reserves + (cancel ? 0 : 6));
|
||||
assert.equal(after.elites!.reserves, before.elites!.reserves + (cancel ? 0 : 3));
|
||||
assert.equal(after.tanks, before.tanks);
|
||||
assert.equal(after.elites!.tanks, before.elites!.tanks);
|
||||
assert.deepEqual(game.mobileStronghold, f.game.mobileStronghold);
|
||||
assert.equal(player(game, 'ecaz').forces[MOBILE_LOCATION], 1);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal policies propose original Ecaz Betrayal for typed and concealed-only cooccupation', () => {
|
||||
for (const program of ['typed', 'richese'] as const) {
|
||||
const report = smokeEcazBetrayalModules({ program, marker: 0, advanced: true,
|
||||
homeworlds: true, expansions: program === 'richese' ? ['choam', 'ecaz'] : ['ecaz'] });
|
||||
assert.equal(report.length, 4);
|
||||
assert.ok(report.every(result => result.completed && result.phase === 5 && result.actions > 0));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { MOBILE_LOCATION, MOBILE_STRONGHOLD, splitLocation, territory } from '../game/board';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import type { NoFieldValue } from '../game/richese-no-field';
|
||||
import type { SelectedIndependentEntryOptions } from './fixture-selected-independent-nexus-entry';
|
||||
import {
|
||||
actE3IndependentDuke, e3IndependentDukeClean, nextE3IndependentDukeStep,
|
||||
} from './fixture-e3-independent-nexus-duke';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
|
||||
export type EcazBetrayalModulesStep = { actor: string; action: Action };
|
||||
export type EcazBetrayalModulesOptions = Omit<SelectedIndependentEntryOptions, 'factions' | 'expansions'> & {
|
||||
expansions?: Game['expansions'];
|
||||
program?: 'typed' | 'richese' | 'advisors' | 'hms';
|
||||
marker?: NoFieldValue;
|
||||
};
|
||||
export const ecazBetrayalModulesClean = e3IndependentDukeClean;
|
||||
export const ecazBetrayalModulesPlayer = (game: Game, actor: string) => {
|
||||
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
|
||||
};
|
||||
export function actEcazBetrayalModules(game: Game, actor: string, action: Action,
|
||||
actions?: EcazBetrayalModulesStep[]): Game {
|
||||
return actE3IndependentDuke(game, actor, action, actions);
|
||||
}
|
||||
export function advanceEcazBetrayalModules(game: Game, until: (state: Game) => boolean,
|
||||
actions: EcazBetrayalModulesStep[] = []): Game {
|
||||
for (let count = 0; count < 2600; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'Original Betrayal programme ended before its next opening');
|
||||
// An accepted extra allied shipment must reveal the existing marker first.
|
||||
// This programme keeps its genuine own shipment concealed by declining the
|
||||
// separate optional offer, rather than changing already-deployed custody.
|
||||
const step: EcazBetrayalModulesStep | null = !game.response && !game.phaseOpening &&
|
||||
!game.pendingTreacheryDiscard && game.decision?.kind === 'richeseAllyOpportunity'
|
||||
? { actor: game.decision.player, action: { type: 'decision', decline: true } }
|
||||
: nextE3IndependentDukeStep(game);
|
||||
assert.ok(step, `Choose the actual ${game.decision?.kind ?? 'Battle Plan'} at ${game.turn}/${game.phase}`);
|
||||
game = actEcazBetrayalModules(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('Original Ecaz Betrayal module programme did not reach its finite boundary');
|
||||
}
|
||||
|
||||
/** Same normal host entry as the shared selected-entry helper. The two local
|
||||
* setup choices are original controls: a sheltered first HMS placement and a
|
||||
* BG advisor beside the original six Ecaz counters. Neither relocates a piece. */
|
||||
function originalSetup(options: EcazBetrayalModulesOptions, ally: 'emperor' | 'richese' | 'beneGesserit' | 'ixians') {
|
||||
const defaultExpansions: Game['expansions'] = ['ecaz',
|
||||
...(ally === 'richese' ? ['choam'] as const : ally === 'ixians' ? ['ix'] as const : [])];
|
||||
let game = createGame('ECAZBETRAYALMODULES', newPlayer('ecaz', 'Ecaz', 'ecaz'), options.advanced ?? true,
|
||||
options.expansions ?? defaultExpansions);
|
||||
for (const faction of [ally, 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const actions: EcazBetrayalModulesStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actEcazBetrayalModules(game, actor, action, actions); };
|
||||
for (const selection of ['ecazTreachery', 'nexusCards', ...(options.skills ? ['leaderSkills'] : [])])
|
||||
act(game.host, { type: 'moduleOption', module: selection, enabled: true });
|
||||
for (const [type, enabled] of [['homeworlds', options.homeworlds], ['techTokens', options.tech],
|
||||
['strongholdCards', options.strongholds]] as const) if (enabled) act(game.host, { type, enabled: true });
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
act(game.host, game.advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
|
||||
const setup = structuredClone(game);
|
||||
for (let count = 0; game.status === 'setup' && count < 180; count++) {
|
||||
let step: EcazBetrayalModulesStep | undefined;
|
||||
if (game.decision?.kind === 'mobileStronghold' && game.decision.placement)
|
||||
step = { actor: game.decision.player, action: { type: 'decision', location: 'polar_sink:0' } };
|
||||
else if (game.advanced && ally === 'beneGesserit' && game.setupStage === 'forces' &&
|
||||
ecazBetrayalModulesPlayer(game, 'ecaz').reserves === 14 &&
|
||||
!ecazBetrayalModulesPlayer(game, ally).advisorSetup && viewGame(game, ally).setupPending.includes(ally))
|
||||
step = { actor: ally, action: { type: 'advisorSetup', territory: 'imperial_basin',
|
||||
sector: territory('imperial_basin').sectors[0] } };
|
||||
else for (const player of game.players) {
|
||||
const view = viewGame(game, player.id);
|
||||
view.players.find(seat => seat.id === player.id)!.bot = 'Easy';
|
||||
const action = botActions(view)[0];
|
||||
if (action) { step = { actor: player.id, action }; break; }
|
||||
}
|
||||
assert.ok(step, 'Original setup must expose its next owned private choice');
|
||||
act(step.actor, step.action);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
return { lobby, setup, game, actions };
|
||||
}
|
||||
function orderBlow(game: Game, worm = false) {
|
||||
const front: Game['spiceDeck'] = [];
|
||||
if (worm) {
|
||||
const at = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
|
||||
assert.ok(at >= 0); front.push(game.spiceDeck.splice(at, 1)[0]);
|
||||
}
|
||||
for (let count = 0; count < (game.advanced ? 2 : 1); count++) {
|
||||
const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery && card.territory !== 'imperial_basin');
|
||||
assert.ok(at >= 0); front.push(game.spiceDeck.splice(at, 1)[0]);
|
||||
}
|
||||
game.spiceDeck.unshift(...front);
|
||||
}
|
||||
/** Actual paid one-spice auction, including the original optional Ixian pool.
|
||||
* Desired singleton positions are selected while the original deck is unopened. */
|
||||
function acquireKarama(game: Game, actions: EcazBetrayalModulesStep[]) {
|
||||
assert.equal(game.phase, 2);
|
||||
const at = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(at >= 0);
|
||||
const card = game.deck.splice(at, 1)[0]; game.deck.unshift(card);
|
||||
let bid: EcazBetrayalModulesStep | undefined;
|
||||
const before = structuredClone(game);
|
||||
for (let count = 0; count < 350; count++) {
|
||||
if (ecazBetrayalModulesPlayer(game, 'ecaz').hand.some(held => held.id === card.id)) {
|
||||
assert.ok(bid, 'Ecaz must acquire its real Karama through the actual paid auction');
|
||||
return { game, card: card.id, before, bid };
|
||||
}
|
||||
let step: EcazBetrayalModulesStep | null = null;
|
||||
if (!game.response && !game.phaseOpening && game.decision?.kind === 'ixAuction') {
|
||||
const returned = game.ixAuction!.cards.find(held => held.id !== card.id); assert.ok(returned);
|
||||
step = { actor: game.decision.player, action: { type: 'decision', card: returned.id, position: 'bottom' } };
|
||||
} else if (ecazBetrayalModulesClean(game) && game.auction?.active === 'ecaz' &&
|
||||
game.auction.cards[game.auction.index].id === card.id && game.auction.bid === 0) {
|
||||
step = { actor: 'ecaz', action: { type: 'bid', amount: 1, allyPayment: 0 } }; bid = structuredClone(step);
|
||||
}
|
||||
step ??= nextE3IndependentDukeStep(game); assert.ok(step);
|
||||
game = actEcazBetrayalModules(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original auction did not deliver its genuine Karama');
|
||||
}
|
||||
|
||||
/** Original first deal, paid outside group, real worm alliance/closing draw,
|
||||
* and original cooccupation. Return before turn-three first Shipment actions.
|
||||
* Marker values select only an owner's existing physical token; no concealment,
|
||||
* force, stance, receipt, wallet or historical ownership is assigned. */
|
||||
export function createEcazBetrayalModulesFixture(options: EcazBetrayalModulesOptions = {}) {
|
||||
const program = options.program ?? 'typed';
|
||||
const ally = program === 'richese' ? 'richese' : program === 'advisors' ? 'beneGesserit' :
|
||||
program === 'hms' ? 'ixians' : 'emperor';
|
||||
const source = withClassicDiscoveryNexusLottery(() => originalSetup(options, ally));
|
||||
let game = structuredClone(source.game);
|
||||
const afterSetup = structuredClone(game), actions = [...source.actions];
|
||||
const staging = ['Use original host/setup controls and only unopened land, worm, Karama and Ecaz Nexus card ordering; all paid/typed placements remain original actions.'];
|
||||
const act = (actor: string, action: Action) => { game = actEcazBetrayalModules(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceEcazBetrayalModules(game, until, actions); };
|
||||
const shipments: { before: Game; step: EcazBetrayalModulesStep; after: Game }[] = [];
|
||||
const ship = (actor: string, place: string, sector: number, amount: number, elite: number, field?: string) => {
|
||||
const homeworldSources = game.homeworlds && !field ? nativeShipmentSources(viewGame(game, actor), amount, elite) : undefined;
|
||||
assert.ok(!game.homeworlds || field || homeworldSources);
|
||||
const step: EcazBetrayalModulesStep = { actor, action: { type: 'ship', territory: place, sector,
|
||||
...(field ? { noField: field, event: ecazBetrayalModulesPlayer(game, actor).noFieldEvent }
|
||||
: { amount, elite, ...(homeworldSources ? { homeworldSources } : {}) }), allyPayment: 0 } };
|
||||
const before = structuredClone(game); act(actor, step.action); reach(ecazBetrayalModulesClean);
|
||||
shipments.push({ before, step, after: structuredClone(game) });
|
||||
};
|
||||
orderBlow(game);
|
||||
reach(state => state.phase === 2 && ecazBetrayalModulesClean(state));
|
||||
const acquired = acquireKarama(game, actions); game = acquired.game;
|
||||
reach(state => state.phase === 5 && state.active === ally && ecazBetrayalModulesClean(state));
|
||||
ship(ally, 'arrakeen', 10, 1, 0);
|
||||
reach(state => state.turn === 2 && state.phase === 1 && ecazBetrayalModulesClean(state));
|
||||
const afterFirstTurn = structuredClone(game);
|
||||
orderBlow(game, true);
|
||||
reach(state => state.nexus && !state.spiceWindow && !state.spiceResolution && ecazBetrayalModulesClean(state));
|
||||
const beforeAlliance = structuredClone(game);
|
||||
act('ecaz', { type: 'alliance', target: ally }); act(ally, { type: 'alliance', target: 'ecaz' });
|
||||
reach(state => state.nexusCards?.phase?.stage === 'drawing' && ecazBetrayalModulesClean(state));
|
||||
const cards = game.nexusCards!.cards!, at = cards.deck.indexOf('ecaz'); assert.ok(at >= 0);
|
||||
cards.deck.unshift(cards.deck.splice(at, 1)[0]);
|
||||
const beforeDraw = structuredClone(game), draw: EcazBetrayalModulesStep = { actor: 'guild', action: {
|
||||
type: 'nexusCardChoice', turn: game.turn, card: cards.hands.guild, choice: 'draw', ownRedraws: 0,
|
||||
} };
|
||||
act(draw.actor, draw.action);
|
||||
const afterDraw = structuredClone(game);
|
||||
const target = program === 'hms' ? MOBILE_STRONGHOLD : 'imperial_basin';
|
||||
const location = program === 'hms' ? MOBILE_LOCATION : `imperial_basin:${territory('imperial_basin').sectors[0]}`;
|
||||
let marker: string | undefined, entryMove: EcazBetrayalModulesStep | undefined;
|
||||
if (program === 'hms') {
|
||||
reach(state => state.phase === 5 && state.active === 'ecaz' && ecazBetrayalModulesClean(state));
|
||||
const pointer = game.mobileStronghold!.location!; assert.equal(pointer, 'polar_sink:0');
|
||||
const parent = splitLocation(pointer); ship('ecaz', parent.territory, parent.sector, 1, 0);
|
||||
entryMove = { actor: 'ecaz', action: { type: 'move', from: pointer, amount: 1, elite: 0,
|
||||
territory: MOBILE_STRONGHOLD, sector: 0 } };
|
||||
act(entryMove.actor, entryMove.action); reach(ecazBetrayalModulesClean);
|
||||
} else {
|
||||
reach(state => state.phase === 5 && state.active === ally && ecazBetrayalModulesClean(state));
|
||||
if (program === 'richese') {
|
||||
marker = ecazBetrayalModulesPlayer(game, ally).noField!.tokens.find(token => token.value === (options.marker ?? 3))!.id;
|
||||
ship(ally, target, territory(target).sectors[0], 1, 0, marker);
|
||||
} else ship(ally, target, territory(target).sectors[0], program === 'typed' ? 3 : 1,
|
||||
program === 'typed' && game.advanced ? 2 : 0);
|
||||
}
|
||||
const cooccupied = structuredClone(game);
|
||||
reach(state => state.turn === 3 && state.phase === 1 && ecazBetrayalModulesClean(state));
|
||||
orderBlow(game);
|
||||
reach(state => state.turn === 3 && state.phase === 5 && ecazBetrayalModulesClean(state));
|
||||
const offer = viewGame(game, 'guild').nexusEcazBetrayal;
|
||||
assert.ok(offer && !offer.blocked, offer?.blocked ?? 'The genuine closing card needs a live first-Shipment offer');
|
||||
const row = offer.territories.find(row => row.territory === target); assert.ok(row);
|
||||
const action: Action = { type: 'nexusEcazBetrayal', event: offer.event, territory: target };
|
||||
return { ...source, afterSetup, afterFirstTurn, acquired, beforeAlliance, beforeDraw, draw, afterDraw,
|
||||
cooccupied, game, actions, staging, shipments, program, ally, target, location, marker, entryMove,
|
||||
owner: 'guild', counter: 'ecaz', karama: acquired.card, offer, row, action };
|
||||
}
|
||||
export type EcazBetrayalModulesFixture = ReturnType<typeof createEcazBetrayalModulesFixture>;
|
||||
|
||||
/** Resolve actual Karama/allowance response queues without consuming Shipment
|
||||
* or Movement. The counter card was paid for in the original first auction. */
|
||||
export function resolveEcazBetrayalModules(fixture: EcazBetrayalModulesFixture, cancel = false) {
|
||||
const actions: EcazBetrayalModulesStep[] = [];
|
||||
let game = actEcazBetrayalModules(structuredClone(fixture.game), fixture.owner, fixture.action, actions);
|
||||
const declared = structuredClone(game);
|
||||
if (cancel) game = actEcazBetrayalModules(game, fixture.counter,
|
||||
{ type: 'card', card: fixture.karama, mode: 'cancel' }, actions);
|
||||
game = advanceEcazBetrayalModules(game, ecazBetrayalModulesClean, actions);
|
||||
return { game, declared, actions };
|
||||
}
|
||||
|
||||
/** Finite minimal-legal policy smoke; every candidate comes from its owner's
|
||||
* actual view and is accepted through the original response continuation. */
|
||||
export function smokeEcazBetrayalModules(options: EcazBetrayalModulesOptions = {}) {
|
||||
const fixture = createEcazBetrayalModulesFixture(options);
|
||||
return (['Easy', 'Medium', 'Hard', 'Brutal'] as const).map(difficulty => {
|
||||
const view = viewGame(fixture.game, fixture.owner);
|
||||
view.players.find(player => player.id === fixture.owner)!.bot = difficulty;
|
||||
const action = botActions(view).find(action => action.type === 'nexusEcazBetrayal' && action.territory === fixture.target);
|
||||
assert.ok(action, `${difficulty} must retain the original opening return action`);
|
||||
const actions: EcazBetrayalModulesStep[] = [];
|
||||
const game = advanceEcazBetrayalModules(actEcazBetrayalModules(structuredClone(fixture.game), fixture.owner, action, actions),
|
||||
ecazBetrayalModulesClean, actions);
|
||||
return { difficulty, actions: actions.length, phase: game.phase, active: game.active,
|
||||
completed: game.nexusEcazBetrayalHistory!.at(-1)!.stage === 'complete' };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { createIndependentCardModulesFixture, type IndependentCardModulesOptions, type IndependentCardModulesStep } from './fixture-independent-card-modules';
|
||||
import { nextPairedChoamSkillsStep } from './fixture-paired-choam-nexus-skills';
|
||||
import { applyClassicDiscoveryNexusStep, withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
|
||||
export type AtomicsModulesStep = IndependentCardModulesStep;
|
||||
export type AtomicsModulesOptions = Pick<IndependentCardModulesOptions, 'advanced' | 'skills' | 'homeworlds' | 'tech' | 'strongholds' | 'expansions'>;
|
||||
export const atomicsModulesPlayer = (game: Game, actor: string) => {
|
||||
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
|
||||
};
|
||||
export const atomicsModulesClean = (game: Game) => !game.response && !game.phaseOpening && !game.decision &&
|
||||
!game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingGuildBetrayal &&
|
||||
!game.pendingNexusIxianReplacement && !game.pendingNexusIxianBetrayal && !game.pendingNexusHarkonnenBetrayal;
|
||||
export function actAtomicsModules(game: Game, actor: string, action: Action, actions?: AtomicsModulesStep[]): Game {
|
||||
return applyClassicDiscoveryNexusStep(game, { actor, action }, actions);
|
||||
}
|
||||
function nextAtomicsModules(game: Game): AtomicsModulesStep | null {
|
||||
if (game.pendingGuildBetrayal) {
|
||||
const pending = game.pendingGuildBetrayal, actor = pending.required.find(actor => !pending.passed.includes(actor)); assert.ok(actor);
|
||||
return { actor, action: { type: 'guildBetrayalPass', event: pending.invoice.event } };
|
||||
}
|
||||
if (game.pendingNexusIxianReplacement) return { actor: game.pendingNexusIxianReplacement.buyer,
|
||||
action: { type: 'nexusIxianReplacementPass', event: game.pendingNexusIxianReplacement.event } };
|
||||
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'moritaniTerror' || decision?.kind === 'ecazAmbassador') return null;
|
||||
if (decision?.kind === 'moritaniPlacement' || decision?.kind === 'ecazPlacement')
|
||||
return { actor: decision.player, action: { type: 'decision', decline: true } };
|
||||
if (decision?.kind === 'guildTiming') return { actor: decision.player, action: { type: 'decision', take: false } };
|
||||
if (decision?.kind === 'advisor') return { actor: decision.player,
|
||||
action: { type: 'decision', accept: game.turn === 1 && decision.destination === 'carthag:11', amount: 1, accompany: true } };
|
||||
if (decision?.kind === 'intrusion') return { actor: decision.player, action: { type: 'decision', accept: false } };
|
||||
if (decision?.kind === 'homeworldDefense') return { actor: decision.player,
|
||||
action: { type: 'decision', event: decision.event, use: false } };
|
||||
if (decision?.kind === 'homeworldRevivalDeployment' || decision?.kind === 'caladanReinforcement' || decision?.kind === 'grummanCollection')
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } };
|
||||
if (decision?.kind === 'richeseUnbid') return { actor: decision.player,
|
||||
action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
|
||||
if (decision?.kind === 'techToken') return { actor: decision.player, action: { type: 'decision', token: decision.choices[0] } };
|
||||
}
|
||||
return nextPairedChoamSkillsStep(game);
|
||||
}
|
||||
export function advanceAtomicsModules(game: Game, until: (state: Game) => boolean,
|
||||
actions: AtomicsModulesStep[] = []): Game {
|
||||
for (let count = 0; count < 2400; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The actual Atomics programme ended before its control');
|
||||
const step = nextAtomicsModules(game); assert.ok(step, `Choose the original ${game.decision?.kind ?? 'sealed plan'}`);
|
||||
game = actAtomicsModules(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original module Atomics programme did not reach its requested boundary');
|
||||
}
|
||||
function orderOriginalBlow(game: Game, worm = false): void {
|
||||
const front: Game['spiceDeck'] = [];
|
||||
if (worm) {
|
||||
const index = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
|
||||
assert.ok(index >= 0); front.push(game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
for (let count = 0; count < (game.advanced ? 2 : 1); count++) {
|
||||
const index = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery && card.territory !== 'imperial_basin');
|
||||
assert.ok(index >= 0); front.push(game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
game.spiceDeck.unshift(...front);
|
||||
}
|
||||
/** Actual bank lots fill the owner's printed four slots, at one spice each.
|
||||
* No desired identity is put in a hand and no wallet is reassigned. */
|
||||
function buyOriginalHand(game: Game, actor: string, actions: AtomicsModulesStep[]) {
|
||||
const bids: Array<{ before: Game; step: AtomicsModulesStep }> = [];
|
||||
for (let count = 0; count < 650; count++) {
|
||||
if (atomicsModulesPlayer(game, actor).hand.length === 4) return { game, bids };
|
||||
let step: AtomicsModulesStep | null = null;
|
||||
if (atomicsModulesClean(game) && game.auction && !game.richeseAuction &&
|
||||
game.auction.active === actor && game.auction.bid === 0) {
|
||||
step = { actor, action: { type: 'bid', amount: 1, allyPayment: 0 } };
|
||||
bids.push({ before: structuredClone(game), step: structuredClone(step) });
|
||||
}
|
||||
step ??= nextAtomicsModules(game); assert.ok(step, 'The original bank auction must expose its current bid');
|
||||
game = actAtomicsModules(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original bank lots did not fill the actual hand');
|
||||
}
|
||||
function shipNative(game: Game, actor: string, territory: string, sector: number, amount: number, elite: number,
|
||||
actions: AtomicsModulesStep[]) {
|
||||
const sources = game.homeworlds ? nativeShipmentSources(viewGame(game, actor), amount, elite) : undefined;
|
||||
assert.ok(!game.homeworlds || sources);
|
||||
const before = structuredClone(game), step: AtomicsModulesStep = { actor, action: { type: 'ship', territory, sector,
|
||||
amount, elite, allyPayment: 0, ...(sources ? { homeworldSources: sources } : {}) } };
|
||||
game = actAtomicsModules(game, step.actor, step.action, actions);
|
||||
return { before, step, game };
|
||||
}
|
||||
/** Original native setup, paid hand-filling auctions, real placements, a worm
|
||||
* alliance and closing draw. The paired route additionally earns its one BG
|
||||
* advisor through an original first-turn Guild shipment. */
|
||||
function atomicsModulesProgramme(options: AtomicsModulesOptions, paired: boolean) {
|
||||
const factions: FactionId[] = paired
|
||||
? ['moritani', 'ecaz', 'guild', 'emperor', 'beneGesserit'] : ['moritani', 'richese', 'guild', 'emperor'];
|
||||
const source = withClassicDiscoveryNexusLottery(() => createIndependentCardModulesFixture({ ...options,
|
||||
expansions: options.expansions ?? (paired ? ['ecaz'] : ['ecaz', 'choam']), factions,
|
||||
independent: true, nexus: true, tech: options.tech ?? false }));
|
||||
let game = structuredClone(source.game);
|
||||
const afterSetup = structuredClone(game), actions = [...source.steps], staging: string[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actAtomicsModules(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceAtomicsModules(game, until, actions); };
|
||||
orderOriginalBlow(game);
|
||||
reach(state => state.phase === 2 && atomicsModulesClean(state));
|
||||
const first = buyOriginalHand(game, 'moritani', actions); game = first.game;
|
||||
const purchases = [...first.bids];
|
||||
let firstShipment: { before: Game; step: AtomicsModulesStep; after: Game } | null = null;
|
||||
if (paired) {
|
||||
reach(state => state.phase === 5 && state.active === 'guild' && atomicsModulesClean(state));
|
||||
const advisorsBefore = atomicsModulesPlayer(game, 'beneGesserit').forces['carthag:11'] ?? 0;
|
||||
const shipment = shipNative(game, 'guild', 'carthag', 11, 1, 0, actions); game = shipment.game;
|
||||
reach(atomicsModulesClean);
|
||||
firstShipment = { before: shipment.before, step: shipment.step, after: structuredClone(game) };
|
||||
assert.equal(atomicsModulesPlayer(game, 'guild').forces['carthag:11'], 1);
|
||||
assert.equal(atomicsModulesPlayer(game, 'beneGesserit').forces['carthag:11'], advisorsBefore + 1);
|
||||
assert.ok(atomicsModulesPlayer(game, 'beneGesserit').advisors?.carthag);
|
||||
}
|
||||
reach(state => state.turn === 1 && !state.response && !state.phaseOpening && state.decision?.kind === 'moritaniPlacement');
|
||||
const atomics = game.moritaniTerror!.tokens.find(token => token.kind === 'atomics' && token.status === 'available'); assert.ok(atomics);
|
||||
const beforePlacement = structuredClone(game);
|
||||
act('moritani', { type: 'decision', token: atomics.id, territory: 'carthag' });
|
||||
reach(state => state.turn === 2 && state.phase === 1 && atomicsModulesClean(state));
|
||||
orderOriginalBlow(game, true);
|
||||
reach(state => state.nexus && !state.spiceWindow && !state.spiceResolution && atomicsModulesClean(state));
|
||||
const ally = paired ? 'ecaz' : 'guild', holder = paired ? 'emperor' : 'richese';
|
||||
const beforeAlliance = structuredClone(game);
|
||||
act('moritani', { type: 'alliance', target: ally }); act(ally, { type: 'alliance', target: 'moritani' });
|
||||
reach(state => state.nexusCards?.phase?.stage === 'drawing' && atomicsModulesClean(state));
|
||||
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf('tleilaxu'); assert.ok(index >= 0);
|
||||
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
|
||||
const beforeDraw = structuredClone(game), drawStep: AtomicsModulesStep = { actor: holder,
|
||||
action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands[holder], choice: 'draw', ownRedraws: 0 } };
|
||||
act(drawStep.actor, drawStep.action);
|
||||
const afterDraw = structuredClone(game);
|
||||
reach(state => state.phase === 2 && atomicsModulesClean(state));
|
||||
const second = buyOriginalHand(game, ally, actions); game = second.game; purchases.push(...second.bids);
|
||||
// Ecaz places after Revival, before Shipment; the first-turn opportunity
|
||||
// preceded the original Guild accompaniment. Use the later earned opening
|
||||
// after the real worm alliance and bank purchases, while Atomics is already
|
||||
// on the board, rather than skipping the first Mentat in search of Ecaz.
|
||||
let ambassador: string | null = null;
|
||||
if (paired) {
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
|
||||
const token = game.ecazAmbassadors!.tokens.find(token => token.effect === 'ecaz' && token.zone === 'supply'); assert.ok(token);
|
||||
ambassador = token.id;
|
||||
act('ecaz', { type: 'decision', token: token.id, territory: 'carthag' });
|
||||
}
|
||||
staging.push('Only unopened original Spice and Nexus order is selected. Six paid one-spice bank bids fill the actual Moritani and activation-ally hands; no card, force, wallet or token is staged.');
|
||||
return { source, game, afterSetup, actions, staging, firstShipment, beforePlacement, atomics: atomics.id,
|
||||
ambassador, ally, holder, beforeAlliance, draw: { before: beforeDraw, step: drawStep, after: afterDraw }, purchases };
|
||||
}
|
||||
|
||||
/** Concealed native Richese 3-marker uses its real paid shipment and Guild
|
||||
* acknowledgement. Atomics has not yet revealed/materialized or killed it. */
|
||||
export function createNoFieldAtomicsModulesFixture(options: AtomicsModulesOptions = {}) {
|
||||
const programme = atomicsModulesProgramme(options, false), actions = programme.actions;
|
||||
let game = advanceAtomicsModules(programme.game, state => state.phase === 5 && state.active === 'richese' && atomicsModulesClean(state), actions);
|
||||
const richese = atomicsModulesPlayer(game, 'richese'), token = richese.noField!.tokens.find(token => token.value === 3); assert.ok(token);
|
||||
const beforeEntry = structuredClone(game), entry: AtomicsModulesStep = { actor: 'richese', action: { type: 'ship',
|
||||
noField: token.id, event: richese.noFieldEvent, territory: 'carthag', sector: 11, allyPayment: 0 } };
|
||||
game = actAtomicsModules(game, entry.actor, entry.action, actions);
|
||||
game = advanceAtomicsModules(game, state => !state.phaseOpening && !state.response && state.decision?.kind === 'moritaniTerror', actions);
|
||||
assert.ok(atomicsModulesPlayer(game, 'richese').noField!.deployed);
|
||||
return { ...programme, game, actions, beforeEntry, entry, noField: token.id };
|
||||
}
|
||||
|
||||
/** Paired native Ambassador/Terror arrival queue with a real ordinary Guild
|
||||
* counter, real BG advisor and the Emperor's typed paid incoming army. */
|
||||
export function createPairedAtomicsModulesFixture(options: AtomicsModulesOptions = {}) {
|
||||
const programme = atomicsModulesProgramme({ ...options, advanced: true }, true), actions = programme.actions;
|
||||
let game = advanceAtomicsModules(programme.game, state => state.phase === 5 && state.active === 'emperor' && atomicsModulesClean(state), actions);
|
||||
const arrival = shipNative(game, 'emperor', 'carthag', 11, 3, 2, actions); game = arrival.game;
|
||||
game = advanceAtomicsModules(game, state => !state.phaseOpening && !state.response && !!state.pendingArrivalOverlap, actions);
|
||||
return { ...programme, game, actions, beforeEntry: arrival.before, entry: arrival.step };
|
||||
}
|
||||
/** The live native arrival order chooses which original child is first. After
|
||||
* destruction, its surviving parent resumes once, without a second invoice. */
|
||||
export function finishAtomicsModulesReveal(game: Game, actions: AtomicsModulesStep[] = []) {
|
||||
const seen: string[] = [];
|
||||
let beforeActivation: Game | null = null;
|
||||
for (let count = 0; count < 200; count++) {
|
||||
if (game.moritaniAtomics && !game.pendingArrivalOverlap && !game.pendingAmbassador && !game.pendingTerrorEntry && atomicsModulesClean(game)) {
|
||||
assert.ok(beforeActivation); return { game, beforeActivation, seen };
|
||||
}
|
||||
let step: AtomicsModulesStep | null = null;
|
||||
if (!game.response && !game.phaseOpening && game.decision?.kind === 'ecazAmbassador') {
|
||||
seen.push('ambassador'); step = { actor: game.decision.player,
|
||||
action: { type: 'decision', event: game.pendingAmbassador!.event, decline: true } };
|
||||
}
|
||||
if (!game.response && !game.phaseOpening && game.decision?.kind === 'moritaniTerror') {
|
||||
assert.equal(game.pendingTerrorEntry!.stage, 'offer');
|
||||
beforeActivation = structuredClone(game); seen.push('atomics');
|
||||
step = { actor: game.decision.player, action: { type: 'decision', reveal: true } };
|
||||
}
|
||||
step ??= nextAtomicsModules(game); assert.ok(step, 'The original arrival child needs its owned control');
|
||||
game = actAtomicsModules(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original Atomics reveal did not settle its native arrival queue');
|
||||
}
|
||||
export function openLaterAtomicsModulesNexus(game: Game, actions: AtomicsModulesStep[] = []): Game {
|
||||
const nextTurn = game.turn + 1;
|
||||
game = advanceAtomicsModules(game, state => state.turn === nextTurn && state.phase === 1 && atomicsModulesClean(state), actions);
|
||||
orderOriginalBlow(game, true);
|
||||
return advanceAtomicsModules(game, state => state.nexus && !state.spiceWindow && !state.spiceResolution && atomicsModulesClean(state), actions);
|
||||
}
|
||||
|
||||
/** A later real Sneak Attack placement, followed by a permitted paid shipment
|
||||
* to a different territory and ordinary movement into Aftermath. No clocks,
|
||||
* counters, resources, movement flags or pending Terror frames are assigned. */
|
||||
export function createAtomicsAftermathSneakContinuation(game: Game, actions: AtomicsModulesStep[] = []) {
|
||||
const activationTurn = game.turn;
|
||||
game = advanceAtomicsModules(game, state => state.turn === activationTurn && !state.response && !state.phaseOpening && state.decision?.kind === 'moritaniPlacement', actions);
|
||||
const token = game.moritaniTerror!.tokens.find(token => token.kind === 'sneakAttack' && token.status === 'available'); assert.ok(token);
|
||||
game = actAtomicsModules(game, 'moritani', { type: 'decision', token: token.id, territory: 'carthag' }, actions);
|
||||
game = advanceAtomicsModules(game, state => state.turn === activationTurn + 1 && state.phase === 1 && atomicsModulesClean(state), actions);
|
||||
orderOriginalBlow(game);
|
||||
game = advanceAtomicsModules(game, state => state.phase === 5 && state.active === 'emperor' && atomicsModulesClean(state), actions);
|
||||
const blockedSources = game.homeworlds ? nativeShipmentSources(viewGame(game, 'emperor'), 1, 0) : undefined;
|
||||
assert.ok(!game.homeworlds || blockedSources);
|
||||
const beforeShipment = structuredClone(game), blockedShipment: Action = { type: 'ship', territory: 'carthag', sector: 11,
|
||||
amount: 1, elite: 0, allyPayment: 0, ...(blockedSources ? { homeworldSources: blockedSources } : {}) };
|
||||
const arrival = shipNative(game, 'emperor', 'imperial_basin', 11, 1, 0, actions); game = arrival.game;
|
||||
game = advanceAtomicsModules(game, atomicsModulesClean, actions);
|
||||
const beforeMove = structuredClone(game), movement: AtomicsModulesStep = { actor: 'emperor', action: { type: 'move',
|
||||
from: 'imperial_basin:11', amount: 1, territory: 'carthag', sector: 11 } };
|
||||
game = actAtomicsModules(game, movement.actor, movement.action, actions);
|
||||
game = advanceAtomicsModules(game, state => !state.response && !state.phaseOpening && state.decision?.kind === 'moritaniTerror', actions);
|
||||
game = actAtomicsModules(game, 'moritani', { type: 'decision', reveal: true }, actions);
|
||||
game = advanceAtomicsModules(game, state => !state.response && !state.phaseOpening && state.pendingTerrorEntry?.stage === 'sneakAttack', actions);
|
||||
const beforeSneak = structuredClone(game);
|
||||
game = actAtomicsModules(game, 'moritani', { type: 'decision', amount: 1 }, actions);
|
||||
game = advanceAtomicsModules(game, atomicsModulesClean, actions);
|
||||
return { game, actions, beforeShipment, blockedShipment, beforeMove, movement, beforeSneak, token: token.id };
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { territory } from '../game/board';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { nexusMoritaniAction } from '../game/nexus-moritani-options';
|
||||
import { validateNexusMoritani } from '../game/nexus-moritani';
|
||||
import { nexusMoritaniRecordSignature } from '../game/terror-location';
|
||||
import {
|
||||
createE3IndependentNexusCardSetup, e3IndependentNexusCardPlayer as player,
|
||||
type E3IndependentNexusCardOptions,
|
||||
} from './fixture-e3-independent-nexus-cards';
|
||||
import {
|
||||
actAtomicsModules, advanceAtomicsModules, atomicsModulesClean,
|
||||
finishAtomicsModulesReveal, type AtomicsModulesStep,
|
||||
} from './fixture-moritani-atomics-modules';
|
||||
|
||||
export type NexusMoritaniAtomicsOptions = E3IndependentNexusCardOptions;
|
||||
export const NEXUS_ATOMICS_TERRITORY = 'pasty_mesa';
|
||||
|
||||
/** Only unopened original Spice Cards are ordered. In particular, the real
|
||||
* six-counter Moritani start is never exposed to a selected worm or sand blow. */
|
||||
function orderOriginalBlow(game: Game, worm = false): void {
|
||||
const front: Game['spiceDeck'] = [];
|
||||
if (worm) {
|
||||
const index = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
|
||||
assert.ok(index >= 0); front.push(game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
for (let count = 0; count < (game.advanced ? 2 : 1); count++) {
|
||||
const index = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery &&
|
||||
!['pasty_mesa', 'tueks_sietch'].includes(card.territory));
|
||||
assert.ok(index >= 0); front.push(game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
game.spiceDeck.unshift(...front);
|
||||
}
|
||||
function nativeShipment(game: Game, destination: string, sector: number, amount: number): Action {
|
||||
const sources = game.homeworlds ? nativeShipmentSources(viewGame(game, 'guild'), amount, 0) : undefined;
|
||||
assert.ok(!game.homeworlds || sources);
|
||||
return { type: 'ship', territory: destination, sector, amount, elite: 0, allyPayment: 0,
|
||||
...(sources ? { homeworldSources: sources } : {}) };
|
||||
}
|
||||
|
||||
/** Fresh selected E3/independent/Nexus setup, real closing draw and own
|
||||
* Moritani Cunning. No dealt hand, spice, army, token location or receipt is
|
||||
* assigned. The desert is Moritani's original physical starting territory. */
|
||||
export function createNexusMoritaniAtomicsFixture(options: NexusMoritaniAtomicsOptions = {}) {
|
||||
const source = createE3IndependentNexusCardSetup(options), actions = source.actions;
|
||||
let game = structuredClone(source.game);
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceAtomicsModules(game, until, actions); };
|
||||
const act = (actor: string, action: Action) => { game = actAtomicsModules(game, actor, action, actions); };
|
||||
orderOriginalBlow(game);
|
||||
reach(state => state.turn === 2 && state.phase === 1 && atomicsModulesClean(state));
|
||||
orderOriginalBlow(game, true);
|
||||
reach(state => state.nexus && !state.spiceWindow && !state.spiceResolution && atomicsModulesClean(state));
|
||||
const beforeAlliance = structuredClone(game);
|
||||
act('guild', { type: 'alliance', target: 'emperor' });
|
||||
act('emperor', { type: 'alliance', target: 'guild' });
|
||||
reach(state => state.nexusCards?.phase?.stage === 'drawing' && atomicsModulesClean(state));
|
||||
const deck = game.nexusCards!.cards!.deck, index = deck.indexOf('moritani'); assert.ok(index >= 0);
|
||||
deck.unshift(deck.splice(index, 1)[0]);
|
||||
const drawBefore = structuredClone(game), drawStep: AtomicsModulesStep = { actor: 'moritani', action: {
|
||||
type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 } };
|
||||
act(drawStep.actor, drawStep.action);
|
||||
const drawAfter = structuredClone(game);
|
||||
// Basic cannot use the Advanced three-territory flight from the original
|
||||
// Tuek's army. Earn a separate adjacent counter with an earlier paid native
|
||||
// shipment; the original five remain at Tuek's throughout the Basic scene.
|
||||
let movementSupply: { before: Game; step: AtomicsModulesStep; after: Game } | null = null;
|
||||
if (!game.advanced) {
|
||||
reach(state => state.turn === 2 && state.phase === 5 && state.active === 'guild' && atomicsModulesClean(state));
|
||||
const before = structuredClone(game), step: AtomicsModulesStep = {
|
||||
actor: 'guild', action: nativeShipment(game, 'south_mesa', 6, 1) };
|
||||
act(step.actor, step.action); reach(atomicsModulesClean);
|
||||
assert.equal(player(game, 'guild').forces['south_mesa:6'], 1);
|
||||
assert.equal(player(game, 'guild').forces['tueks_sietch:5'], 5);
|
||||
movementSupply = { before, step, after: structuredClone(game) };
|
||||
}
|
||||
reach(state => state.turn === 2 && !state.response && !state.phaseOpening && state.decision?.kind === 'moritaniPlacement');
|
||||
const atomics = game.moritaniTerror!.tokens.find(token => token.kind === 'atomics' && token.status === 'available'); assert.ok(atomics);
|
||||
const own = viewGame(game, 'moritani'), offer = own.nexusMoritani; assert.ok(offer);
|
||||
const cunning = nexusMoritaniAction(own, offer.event, atomics.id, NEXUS_ATOMICS_TERRITORY); assert.ok(cunning);
|
||||
const placementBefore = structuredClone(game), placementStep: AtomicsModulesStep = { actor: 'moritani', action: cunning };
|
||||
act(placementStep.actor, placementStep.action);
|
||||
const placementPending = structuredClone(game);
|
||||
reach(state => state.nexusMoritaniLast?.stage === 'complete' && atomicsModulesClean(state));
|
||||
const placementAfter = structuredClone(game), record = game.nexusMoritaniHistory?.find(record =>
|
||||
record.receipt.event === game.nexusMoritaniLast!.event && record.stage === 'complete');
|
||||
assert.ok(record, 'The completed Cunning pointer must identify its original committed placement');
|
||||
assert.equal(record.signature, nexusMoritaniRecordSignature(record));
|
||||
validateNexusMoritani(game, record.receipt);
|
||||
const receipt = structuredClone(record.receipt);
|
||||
reach(state => state.turn === 3 && state.phase === 1 && atomicsModulesClean(state));
|
||||
orderOriginalBlow(game);
|
||||
reach(state => state.turn === 3 && state.phase === 5 && state.active === 'guild' && atomicsModulesClean(state));
|
||||
assert.equal(player(game, 'moritani').forces['pasty_mesa:5'], 6);
|
||||
assert.equal(player(game, 'guild').forces['tueks_sietch:5'], 5);
|
||||
const sector = territory(NEXUS_ATOMICS_TERRITORY).sectors.find(sector => sector !== game.storm); assert.ok(sector !== undefined);
|
||||
const beforeEntry = structuredClone(game), entry: AtomicsModulesStep = {
|
||||
actor: 'guild', action: nativeShipment(game, NEXUS_ATOMICS_TERRITORY, sector, 3) };
|
||||
act(entry.actor, entry.action);
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'moritaniTerror');
|
||||
source.staging.push('Keep original armies; use an actual worm and reciprocal Guild/Emperor alliance to draw the unopened Moritani Nexus, then spend it on original Mentat supply Atomics in Pasty Mesa and enter with three paid Guild counters.');
|
||||
return { ...source, game, actions, beforeAlliance, atomics: atomics.id, receipt, movementSupply,
|
||||
draw: { before: drawBefore, step: drawStep, after: drawAfter },
|
||||
placement: { before: placementBefore, step: placementStep, pending: placementPending, after: placementAfter },
|
||||
beforeEntry, entry };
|
||||
}
|
||||
|
||||
/** Finite native runtime smoke: activate desert Atomics, retain the unused
|
||||
* Guild movement, then reach a later fresh shipment and provide the real
|
||||
* forbidden Aftermath request. A separate original paid shipment remains legal. */
|
||||
export function continueNexusMoritaniAtomics(game: Game, actions: AtomicsModulesStep[] = []) {
|
||||
const activation = finishAtomicsModulesReveal(game, actions); game = activation.game;
|
||||
const beforeMove = structuredClone(game), sector = territory(NEXUS_ATOMICS_TERRITORY).sectors.find(sector => sector !== game.storm);
|
||||
assert.ok(sector !== undefined);
|
||||
const from = game.advanced ? 'tueks_sietch:5' : 'south_mesa:6', amount = game.advanced ? 5 : 1;
|
||||
assert.equal(player(game, 'guild').forces[from], amount);
|
||||
const movement: AtomicsModulesStep = { actor: 'guild', action: { type: 'move', from,
|
||||
amount, territory: NEXUS_ATOMICS_TERRITORY, sector } };
|
||||
game = actAtomicsModules(game, movement.actor, movement.action, actions);
|
||||
game = advanceAtomicsModules(game, atomicsModulesClean, actions);
|
||||
const afterMove = structuredClone(game), nextTurn = game.turn + 1;
|
||||
game = advanceAtomicsModules(game, state => state.turn === nextTurn && state.phase === 1 && atomicsModulesClean(state), actions);
|
||||
orderOriginalBlow(game);
|
||||
game = advanceAtomicsModules(game, state => state.turn === nextTurn && state.phase === 5 && state.active === 'guild' && atomicsModulesClean(state), actions);
|
||||
assert.equal(player(game, 'guild').shipped, false);
|
||||
const forbiddenSector = territory(NEXUS_ATOMICS_TERRITORY).sectors.find(sector => sector !== game.storm); assert.ok(forbiddenSector !== undefined);
|
||||
const beforeShipment = structuredClone(game), blockedShipment = nativeShipment(game, NEXUS_ATOMICS_TERRITORY, forbiddenSector, 1);
|
||||
const allowedShipment: AtomicsModulesStep = { actor: 'guild', action: nativeShipment(game, 'polar_sink', 0, 1) };
|
||||
game = actAtomicsModules(game, allowedShipment.actor, allowedShipment.action, actions);
|
||||
game = advanceAtomicsModules(game, atomicsModulesClean, actions);
|
||||
return { game, actions, activation, beforeMove, movement, afterMove, beforeShipment, blockedShipment, allowedShipment };
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { ecazTreacheryCards } from '../game/ecaz-cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { NEXUS_FACTIONS } from '../game/nexus-cards';
|
||||
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { botActions } from '../game/bots';
|
||||
import {
|
||||
actAtomicsModules, atomicsModulesPlayer as player, createAtomicsAftermathSneakContinuation,
|
||||
createNoFieldAtomicsModulesFixture, createPairedAtomicsModulesFixture,
|
||||
finishAtomicsModulesReveal, openLaterAtomicsModulesNexus,
|
||||
} from './fixture-moritani-atomics-modules';
|
||||
|
||||
function stocks(game: Game): void {
|
||||
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
|
||||
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
|
||||
...(game.auction?.cards.slice(game.auction.index + Number(!!game.currentAuctionSale)) ?? [])];
|
||||
assert.deepEqual(cards.map(card => card.id).sort(), [...treacheryDeck(game.expansions), ...ecazTreacheryCards(),
|
||||
...(game.players.some(player => player.faction === 'richese') ? richeseCards() : [])].map(card => card.id).sort());
|
||||
const nexus = game.nexusCards!.cards!;
|
||||
assert.deepEqual([...nexus.deck, ...nexus.discard, ...Object.values(nexus.hands).filter(card => card !== null)].sort(), [...NEXUS_FACTIONS].sort());
|
||||
if (game.leaderSkills) assert.deepEqual([...game.leaderSkills.deck,
|
||||
...Object.values(game.leaderSkills.offers).flatMap(offer => offer.cards),
|
||||
...game.leaderSkills.assignments.map(assignment => assignment.skill)].sort(), LEADER_SKILL_CARDS.map(card => card.id).sort());
|
||||
}
|
||||
function handLimit(game: Game, actor: string): number {
|
||||
return viewGame(game, actor).players.find(player => player.id === actor)!.handLimit;
|
||||
}
|
||||
function reducedExactlyOnce(before: Game, after: Game, targets: readonly string[]): void {
|
||||
for (const target of targets) {
|
||||
const original = player(before, target).hand.map(card => card.id);
|
||||
assert.equal(original.length, 4, 'The paid bank bids filled the original hand');
|
||||
assert.equal(player(after, target).hand.length, 3);
|
||||
assert.equal(handLimit(after, target), 3);
|
||||
assert.equal(player(after, target).atomicsHandLimitPenalty, true);
|
||||
assert.equal(after.discard.filter(card => original.includes(card.id)).length -
|
||||
before.discard.filter(card => original.includes(card.id)).length, 1);
|
||||
}
|
||||
for (const other of before.players.filter(player => !targets.includes(player.id)))
|
||||
assert.deepEqual(player(after, other.id).hand, other.hand);
|
||||
}
|
||||
|
||||
for (const modules of [false, true]) void test(`${modules ? 'Advanced selected all14/World/phase' : 'Basic selected'} actual Richese marker Atomics materializes capped native reserves before casualties and retains the original Guild invoice`, () => {
|
||||
const fixture = createNoFieldAtomicsModulesFixture({ advanced: modules, skills: modules, homeworlds: modules,
|
||||
tech: modules, strongholds: modules, expansions: modules ? ['ecaz', 'ix', 'choam'] : ['ecaz', 'choam'] });
|
||||
assert.equal(fixture.purchases.filter(bid => bid.step.actor === 'moritani').length, 3);
|
||||
assert.equal(fixture.purchases.filter(bid => bid.step.actor === 'guild').length, 3);
|
||||
assert.equal(fixture.draw.after.nexusCards!.cards!.hands.richese, 'tleilaxu');
|
||||
assert.equal(player(fixture.game, 'richese').noField!.deployed!.tokenId, fixture.noField);
|
||||
assert.equal(player(fixture.game, 'richese').forces['carthag:11'] ?? 0, 0);
|
||||
assert.equal(player(fixture.game, 'richese').reserves, 20);
|
||||
assert.equal(player(fixture.game, 'richese').spice, player(fixture.beforeEntry, 'richese').spice - 1);
|
||||
assert.equal(player(fixture.game, 'guild').spice, player(fixture.beforeEntry, 'guild').spice + 1);
|
||||
const invoice = fixture.game.nexusGuildBetrayalHistory!.at(-1)!;
|
||||
assert.equal(invoice.invoice.shipper, 'richese');
|
||||
assert.equal(invoice.invoice.price, 1);
|
||||
assert.equal(invoice.invoice.originalReceiver, 'guild');
|
||||
assert.ok(fixture.actions.some(step => step.actor === 'richese' && step.action.type === 'guildBetrayalPass' && step.action.event === invoice.invoice.event));
|
||||
const ownForces = structuredClone(player(fixture.game, 'moritani').forces);
|
||||
const receiptCount = fixture.game.nexusGuildBetrayalHistory!.length;
|
||||
const finished = finishAtomicsModulesReveal(fixture.game, fixture.actions), game = finished.game;
|
||||
assert.deepEqual(finished.seen, ['atomics']);
|
||||
assert.equal(player(game, 'richese').noField!.deployed, null);
|
||||
assert.equal(player(game, 'richese').noField!.lastShipped, fixture.noField);
|
||||
assert.equal(player(game, 'richese').reserves, 17);
|
||||
assert.equal(player(game, 'richese').tanks, player(fixture.game, 'richese').tanks + 3);
|
||||
assert.equal(player(game, 'richese').forces['carthag:11'] ?? 0, 0);
|
||||
assert.deepEqual(player(game, 'moritani').forces, ownForces, 'Atomics affects its actual territory, not the separate original Moritani start');
|
||||
assert.deepEqual(game.moritaniAtomics, { territory: 'carthag', turn: finished.beforeActivation.turn, moritaniId: 'moritani',
|
||||
allyAtActivation: 'guild', alliancePolicy: 'activation-targets' });
|
||||
assert.equal(game.moritaniTerror!.tokens.find(token => token.id === fixture.atomics)!.status, 'removed');
|
||||
reducedExactlyOnce(finished.beforeActivation, game, ['moritani', 'guild']);
|
||||
assert.equal(game.nexusGuildBetrayalHistory!.length, receiptCount);
|
||||
assert.equal(player(game, 'richese').spice, player(fixture.game, 'richese').spice);
|
||||
assert.equal(player(game, 'guild').spice, player(fixture.game, 'guild').spice);
|
||||
assert.equal(player(game, 'richese').shipped, true);
|
||||
assert.equal(game.active, 'richese');
|
||||
assert.equal(game.nexusCards!.cards!.hands.richese, 'tleilaxu');
|
||||
if (modules) {
|
||||
const world = viewGame(game, 'richese').homeworlds!.worlds!.find(world => world.id === 'homeworld:richese'); assert.ok(world);
|
||||
assert.deepEqual(world.forces.richese, { normal: 17, elite: 0 });
|
||||
}
|
||||
assert.throws(() => applyAction(game, 'moritani', { type: 'decision', reveal: true }));
|
||||
stocks(game);
|
||||
});
|
||||
|
||||
void test('paired native Ecaz Atomics consumes its original arrival queue once and destroys ordinary counters, Sardaukar and actual spiritual advisors', () => {
|
||||
const fixture = createPairedAtomicsModulesFixture({ skills: true, homeworlds: true, tech: true, strongholds: true });
|
||||
assert.ok(fixture.firstShipment);
|
||||
assert.equal(fixture.firstShipment.step.actor, 'guild');
|
||||
assert.ok(fixture.actions.some(step => step.actor === 'beneGesserit' && step.action.type === 'decision' && step.action.accept === true));
|
||||
const queue = structuredClone(fixture.game.pendingArrivalOverlap!); assert.ok(queue);
|
||||
assert.equal(queue.entrant, 'emperor');
|
||||
assert.equal(queue.territory, 'carthag');
|
||||
assert.equal(player(fixture.game, 'emperor').forces['carthag:11'], 3);
|
||||
assert.equal(player(fixture.game, 'emperor').elites!.forces['carthag:11'], 2);
|
||||
assert.equal(player(fixture.game, 'guild').forces['carthag:11'], 1);
|
||||
const advisors = player(fixture.game, 'beneGesserit').forces['carthag:11']; assert.ok(advisors >= 1);
|
||||
assert.ok(player(fixture.game, 'beneGesserit').advisors?.carthag);
|
||||
const invoice = fixture.game.nexusGuildBetrayalHistory!.at(-1)!;
|
||||
assert.equal(invoice.invoice.shipper, 'emperor');
|
||||
assert.equal(invoice.invoice.price, 3);
|
||||
assert.ok(fixture.actions.some(step => step.actor === 'emperor' && step.action.type === 'guildBetrayalPass' && step.action.event === invoice.invoice.event));
|
||||
const finished = finishAtomicsModulesReveal(fixture.game, fixture.actions), game = finished.game;
|
||||
assert.equal(finished.seen[0], queue.first === 'terror' ? 'atomics' : 'ambassador');
|
||||
assert.equal(finished.seen.filter(entry => entry === 'atomics').length, 1);
|
||||
assert.ok(finished.seen.filter(entry => entry === 'ambassador').length <= 1);
|
||||
if (queue.first === 'ambassador') assert.deepEqual(finished.seen, ['ambassador', 'atomics']);
|
||||
assert.ok(!game.pendingArrivalOverlap && !game.pendingAmbassador && !game.pendingTerrorEntry);
|
||||
assert.equal(player(game, 'emperor').tanks, player(fixture.game, 'emperor').tanks + 3);
|
||||
assert.equal(player(game, 'emperor').elites!.tanks, player(fixture.game, 'emperor').elites!.tanks + 2);
|
||||
assert.equal(player(game, 'guild').tanks, player(fixture.game, 'guild').tanks + 1);
|
||||
assert.equal(player(game, 'beneGesserit').tanks, player(fixture.game, 'beneGesserit').tanks + advisors);
|
||||
for (const actor of ['emperor', 'guild', 'beneGesserit']) assert.equal(player(game, actor).forces['carthag:11'] ?? 0, 0);
|
||||
assert.equal(player(game, 'beneGesserit').advisors?.carthag, undefined);
|
||||
assert.equal(game.moritaniAtomics!.allyAtActivation, 'ecaz');
|
||||
reducedExactlyOnce(finished.beforeActivation, game, ['moritani', 'ecaz']);
|
||||
assert.equal(handLimit(game, 'guild'), 4);
|
||||
assert.equal(game.nexusGuildBetrayalHistory!.length, fixture.game.nexusGuildBetrayalHistory!.length);
|
||||
assert.equal(player(game, 'emperor').spice, player(fixture.game, 'emperor').spice);
|
||||
assert.equal(player(game, 'guild').spice, player(fixture.game, 'guild').spice);
|
||||
stocks(game);
|
||||
});
|
||||
|
||||
void test('module Atomics keeps its original hand targets after an actual later worm changes Moritani’s alliance', () => {
|
||||
const fixture = createNoFieldAtomicsModulesFixture();
|
||||
const detonation = finishAtomicsModulesReveal(fixture.game).game;
|
||||
const aftermath = structuredClone(detonation.moritaniAtomics), discard = detonation.discard.map(card => card.id);
|
||||
let game = openLaterAtomicsModulesNexus(detonation);
|
||||
game = actAtomicsModules(game, 'moritani', { type: 'alliance', target: null });
|
||||
game = actAtomicsModules(game, 'moritani', { type: 'alliance', target: 'emperor' });
|
||||
game = actAtomicsModules(game, 'emperor', { type: 'alliance', target: 'moritani' });
|
||||
assert.equal(player(game, 'guild').ally, null);
|
||||
assert.equal(player(game, 'moritani').ally, 'emperor');
|
||||
assert.equal(handLimit(game, 'moritani'), 3);
|
||||
assert.equal(handLimit(game, 'guild'), 3);
|
||||
assert.equal(handLimit(game, 'emperor'), 4);
|
||||
assert.deepEqual(game.moritaniAtomics, aftermath);
|
||||
assert.deepEqual(game.discard.map(card => card.id), discard);
|
||||
assert.equal(player(game, 'richese').tanks, player(detonation, 'richese').tanks);
|
||||
});
|
||||
|
||||
for (const modules of [false, true]) void test(`${modules ? 'Advanced all14/World/phase' : 'Basic'} Aftermath rejects a genuine new shipment while original adjacent movement and the later placed Sneak Attack remain available`, () => {
|
||||
const fixture = createNoFieldAtomicsModulesFixture({ advanced: modules, skills: modules, homeworlds: modules,
|
||||
tech: modules, strongholds: modules, expansions: modules ? ['ecaz', 'ix', 'choam'] : ['ecaz', 'choam'] });
|
||||
const detonation = finishAtomicsModulesReveal(fixture.game).game;
|
||||
const continued = createAtomicsAftermathSneakContinuation(detonation);
|
||||
assert.equal(player(continued.beforeShipment, 'emperor').shipped, false);
|
||||
assert.throws(() => applyAction(continued.beforeShipment, 'emperor', continued.blockedShipment), /Atomics Aftermath/);
|
||||
assert.equal(player(continued.beforeMove, 'emperor').forces['imperial_basin:11'], 1);
|
||||
assert.equal(continued.movement.action.type, 'move');
|
||||
assert.equal(continued.beforeSneak.pendingTerrorEntry!.stage, 'sneakAttack');
|
||||
assert.equal(player(continued.game, 'emperor').forces['carthag:11'], 1);
|
||||
assert.equal(player(continued.game, 'moritani').forces['carthag:11'], 1);
|
||||
assert.equal(player(continued.game, 'moritani').reserves, player(continued.beforeSneak, 'moritani').reserves - 1);
|
||||
assert.equal(continued.game.moritaniTerror!.tokens.find(token => token.id === continued.token)!.status, 'removed');
|
||||
assert.deepEqual(continued.game.moritaniAtomics, detonation.moritaniAtomics);
|
||||
assert.equal(player(continued.game, 'richese').tanks, player(detonation, 'richese').tanks);
|
||||
assert.ok(!continued.game.pendingTerrorEntry);
|
||||
if (modules) {
|
||||
const world = (state: Game) => {
|
||||
const home = viewGame(state, 'moritani').homeworlds!.worlds!.find(world => world.id === 'homeworld:moritani'); assert.ok(home);
|
||||
return home.forces.moritani;
|
||||
};
|
||||
assert.deepEqual(world(continued.game), { normal: world(continued.beforeSneak).normal - 1, elite: 0 },
|
||||
'Sneak Attack withdraws its actual ordinary counter from the original Grumman pool');
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(continued.beforeSneak, 'moritani');
|
||||
view.players.find(player => player.id === 'moritani')!.bot = difficulty;
|
||||
const action = botActions(view)[0];
|
||||
assert.ok(action, `${difficulty} must expose a legal owned Sneak Attack control`);
|
||||
assert.doesNotThrow(() => applyAction(continued.beforeSneak, 'moritani', action),
|
||||
`${difficulty} uses the actual native World withdrawal without policy calibration`);
|
||||
}
|
||||
}
|
||||
stocks(continued.game);
|
||||
});
|
||||
@@ -84,7 +84,7 @@ void test('placement, existing Aftermath, alliance inconsistency and malformed p
|
||||
/still-placed token/);
|
||||
assert.throws(() => quoteMoritaniAtomics({ token, territory: 'carthag',
|
||||
moritaniId: 'm', turn: 3, aftermath: null, players: [moritani] }),
|
||||
/entered ordinary stronghold/);
|
||||
/still-placed token/);
|
||||
assert.throws(() => quote([{ ...moritani, ally: 'missing' }]), /mutual ally/);
|
||||
assert.throws(() => quote([{ ...moritani, forces: { 'arrakeen:11': 1 } }]),
|
||||
/valid typed forces/);
|
||||
@@ -108,3 +108,53 @@ void test('a token at a storm-exposed stronghold still quotes all occupied count
|
||||
[{ playerId: 'm', location: 'tueks_sietch:5', normal: 1, elite: 0, advisor: 0 }]);
|
||||
assert.equal(stormExposed.aftermath.territory, 'tueks_sietch');
|
||||
});
|
||||
|
||||
void test('an authenticated Cunning desert placement destroys every typed sector and retains its grant in Aftermath', () => {
|
||||
// The engine authenticates this original event against placement history;
|
||||
// the pure effect consumes that authority without creating a placement.
|
||||
const nexusPlacement = 'original-cunning-placement';
|
||||
const players: AtomicsPlayer[] = [
|
||||
{ ...moritani, forces: { 'wind_pass:14': 2, 'wind_pass:15': 1, 'arrakeen:10': 3 } },
|
||||
{ id: 'e', faction: 'emperor', ally: null, tanks: 0, handSize: 4, baseHandLimit: 4,
|
||||
forces: { 'wind_pass:14': 3, 'wind_pass:15': 2, 'carthag:11': 1 },
|
||||
elites: { reserves: 0, tanks: 0, revived: 0,
|
||||
forces: { 'wind_pass:14': 1, 'wind_pass:15': 2 } } },
|
||||
{ id: 'bg', faction: 'beneGesserit', ally: null, tanks: 0, handSize: 0, baseHandLimit: 4,
|
||||
forces: { 'wind_pass:15': 1 }, advisors: { wind_pass: {} } },
|
||||
];
|
||||
const before = structuredClone(players);
|
||||
const plan = quoteMoritaniAtomics({ token: { ...token, location: 'wind_pass' },
|
||||
territory: 'wind_pass', players, moritaniId: 'm', turn: 4, aftermath: null, nexusPlacement });
|
||||
assert.deepEqual(plan.casualties, [
|
||||
{ playerId: 'm', location: 'wind_pass:14', normal: 2, elite: 0, advisor: 0 },
|
||||
{ playerId: 'm', location: 'wind_pass:15', normal: 1, elite: 0, advisor: 0 },
|
||||
{ playerId: 'e', location: 'wind_pass:14', normal: 2, elite: 1, advisor: 0 },
|
||||
{ playerId: 'e', location: 'wind_pass:15', normal: 0, elite: 2, advisor: 0 },
|
||||
{ playerId: 'bg', location: 'wind_pass:15', normal: 1, elite: 0, advisor: 1 },
|
||||
]);
|
||||
assert.deepEqual(plan.aftermath, { territory: 'wind_pass', turn: 4, moritaniId: 'm',
|
||||
allyAtActivation: null, alliancePolicy: 'activation-targets', nexusPlacement });
|
||||
assert.deepEqual(plan.handReductions, [{ playerId: 'm', limit: 3, randomDiscards: 1 }]);
|
||||
assert.equal(atomicsShipmentBlocked(plan.aftermath, 'wind_pass'), true);
|
||||
assert.equal(atomicsShipmentBlocked(plan.aftermath, 'arrakeen'), false);
|
||||
assert.deepEqual(players, before, 'Quoting changes neither board counters nor Tanks.');
|
||||
});
|
||||
|
||||
void test('Cunning Atomics requires a grant for a printed non-stronghold and cannot admit another location kind', () => {
|
||||
for (const nexusPlacement of [undefined, ''])
|
||||
assert.throws(() => quoteMoritaniAtomics({ token: { ...token, location: 'wind_pass' },
|
||||
territory: 'wind_pass', players: [moritani], moritaniId: 'm', turn: 3,
|
||||
aftermath: null, nexusPlacement }), /original Nexus placement/);
|
||||
for (const territory of ['homeworld:moritani', 'hidden_mobile_stronghold', 'jacurutu-sietch', 'not-a-territory'])
|
||||
assert.throws(() => quoteMoritaniAtomics({ token: { ...token, location: territory },
|
||||
territory, players: [moritani], moritaniId: 'm', turn: 3,
|
||||
aftermath: null, nexusPlacement: 'original-cunning-placement' }), /original Nexus placement/);
|
||||
});
|
||||
|
||||
void test('ordinary stronghold Aftermath keeps its original JSON shape even with supplied Cunning context', () => {
|
||||
const ordinary = quote([moritani]);
|
||||
const withGrant = quoteMoritaniAtomics({ token, territory: 'arrakeen', players: [moritani],
|
||||
moritaniId: 'm', turn: 3, aftermath: null, nexusPlacement: 'original-cunning-placement' });
|
||||
assert.deepEqual(withGrant, ordinary);
|
||||
assert.equal(Object.hasOwn(withGrant.aftermath, 'nexusPlacement'), false);
|
||||
});
|
||||
@@ -1,10 +1,16 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { Game } from '../game/engine';
|
||||
import { MOBILE_LOCATION, MOBILE_STRONGHOLD } from '../game/board';
|
||||
import { createDiscoveryState, placeDiscovery, revealDiscoveryToken } from '../game/discoveries';
|
||||
import { createHomeworldCustody, quoteHomeworldCustody } from '../game/homeworld-custody';
|
||||
import { homeworldContext } from '../game/homeworld-game';
|
||||
import { reorderOrderedOpportunity } from '../game/ordered-opportunity';
|
||||
import {
|
||||
ecazBetrayalOffer, quoteEcazBetrayal, validateEcazBetrayalSnapshot,
|
||||
ecazBetrayalModeSupported, ecazBetrayalOffer, quoteEcazBetrayal, validateEcazBetrayalSnapshot,
|
||||
type EcazBetrayalSnapshot,
|
||||
} from '../game/nexus-ecaz-betrayal';
|
||||
import { createRicheseNoField, deployRicheseNoField, type NoFieldValue } from '../game/richese-no-field';
|
||||
import { nexusFixture, nexusReload } from './fixture-nexus-cards';
|
||||
|
||||
/** Ordinary printed-board occupation; no other expansion or optional modules. */
|
||||
@@ -77,7 +83,7 @@ void test('Ecaz Betrayal quotes one selected public territory with every typed s
|
||||
assert.deepEqual(ecazBetrayalOffer(alteredPrivateAndOpponent, holder), offer);
|
||||
});
|
||||
|
||||
void test('Ecaz Betrayal needs reciprocal native alliance, cooccupation, proper card custody and simple roster', () => {
|
||||
void test('Ecaz Betrayal needs reciprocal native alliance, cooccupation, proper card custody and a supported profile', () => {
|
||||
const { g, ecaz, ally, holder } = position();
|
||||
assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.players.find(p => p.id === ally)!.ally = null; }), holder)!.blocked!, /reciprocal/);
|
||||
assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.players.find(p => p.id === ecaz)!.ally = null; }), holder)!.blocked!, /reciprocal/);
|
||||
@@ -85,10 +91,177 @@ void test('Ecaz Betrayal needs reciprocal native alliance, cooccupation, proper
|
||||
assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.players.find(p => p.id === ecaz)!.forces = {}; }), holder)!.blocked!, /share no territory/);
|
||||
assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.players.find(p => p.id === ally)!.forces = {}; copy.players.find(p => p.id === ally)!.elites!.forces = {}; }), holder)!.blocked!, /share no territory/);
|
||||
assert.equal(ecazBetrayalOffer(editGame(g, copy => { copy.nexusCards!.cards!.hands[holder] = null; }), holder), null);
|
||||
assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.players.find(p => p.id === holder)!.faction = 'richese'; }), holder)!.blocked!, /Combined modules/);
|
||||
assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.homeworlds = { custody: null }; }), holder)!.blocked!, /Combined modules/);
|
||||
assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.players.find(p => p.id === ally)!.advisors = { wind_pass: {} }; }), holder)!.blocked!, /advisor/);
|
||||
assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.expansions.push('ix'); }), holder)!.blocked!, /Combined modules/);
|
||||
assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.players.find(p => p.id === holder)!.faction = 'richese'; }), holder)!.blocked!, /combination is unavailable/);
|
||||
assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.expansions.push('ix'); }), holder)!.blocked!, /combination is unavailable/);
|
||||
});
|
||||
|
||||
void test('canonical E3 and selected-independent profiles admit original optional modules without global presence exclusions', () => {
|
||||
const { g, holder, ally } = position();
|
||||
g.expansions = ['ecaz'];
|
||||
g.homeworlds = { custody: null };
|
||||
g.techTokens = {} as NonNullable<Game['techTokens']>;
|
||||
g.strongholdCards = {} as NonNullable<Game['strongholdCards']>;
|
||||
assert.equal(ecazBetrayalModeSupported(g), true);
|
||||
assert.equal(ecazBetrayalOffer(g, holder)!.blocked, null);
|
||||
|
||||
// This is a pure quote admission fixture, not a module initializer/runtime
|
||||
// certificate. The original-action lane proves native physical module play.
|
||||
const selected = editGame(g, copy => {
|
||||
copy.expansions = ['ecaz', 'ix', 'choam'];
|
||||
copy.ecazTreachery = true;
|
||||
copy.players.find(p => p.id === holder)!.faction = 'choam';
|
||||
copy.leaderSkills = {} as NonNullable<Game['leaderSkills']>;
|
||||
});
|
||||
assert.equal(ecazBetrayalModeSupported(selected), true,
|
||||
'Selected decks need not seat one native from each selected family.');
|
||||
assert.equal(ecazBetrayalOffer(selected, holder)!.blocked, null);
|
||||
assert.equal(ecazBetrayalModeSupported(editGame(selected, copy => {
|
||||
copy.players.find(p => p.id === holder)!.faction = 'harkonnen';
|
||||
})), false, 'The existing Advanced actual-E3/Harkonnen/Skills boundary stays.');
|
||||
|
||||
const skills = editGame(g, copy => {
|
||||
copy.players.find(p => p.id === holder)!.faction = 'guild';
|
||||
copy.leaderSkills = {} as NonNullable<Game['leaderSkills']>;
|
||||
const bg = copy.players.find(p => p.id === ally)!;
|
||||
bg.faction = 'beneGesserit';
|
||||
delete bg.elites;
|
||||
bg.advisors = { wind_pass: { lockedTurn: copy.turn }, carthag: {} };
|
||||
});
|
||||
const offer = ecazBetrayalOffer(skills, holder)!;
|
||||
assert.equal(offer.blocked, null);
|
||||
assert.deepEqual(offer.territories[0].total, { normal: 5, elite: 0 },
|
||||
'Advisor stance is public physical custody, not a reason to suppress a whole module.');
|
||||
});
|
||||
|
||||
function markerPosition(value: NoFieldValue, onlyMarker = false) {
|
||||
const fixture = position();
|
||||
const { g, ally } = fixture;
|
||||
g.expansions = ['ecaz', 'choam'];
|
||||
g.ecazTreachery = true;
|
||||
const richese = g.players.find(p => p.id === ally)!;
|
||||
richese.faction = 'richese';
|
||||
delete richese.elites;
|
||||
if (onlyMarker) richese.forces = {};
|
||||
const inventory = createRicheseNoField(['opaque-one', 'opaque-two', 'opaque-three']);
|
||||
const token = inventory.tokens.find(t => t.value === value)!;
|
||||
richese.noField = deployRicheseNoField(inventory, {
|
||||
tokenId: token.id, controller: ally, location: { territory: 'wind_pass', sector: 14 },
|
||||
});
|
||||
richese.noFieldEvent = 'private-marker-generation';
|
||||
return { ...fixture, token };
|
||||
}
|
||||
|
||||
void test('marker-only and mixed public quotes are independent of hidden denomination and reserves', () => {
|
||||
for (const onlyMarker of [false, true]) {
|
||||
let baseline: ReturnType<typeof ecazBetrayalOffer> | undefined;
|
||||
for (const value of [0, 3, 5] as const) {
|
||||
const { g, ally, holder, token } = markerPosition(value, onlyMarker);
|
||||
g.players.find(p => p.id === ally)!.reserves = value;
|
||||
const offer = ecazBetrayalOffer(g, holder)!;
|
||||
assert.equal(offer.blocked, null);
|
||||
assert.equal(offer.territories[0].concealed, true);
|
||||
assert.deepEqual(offer.territories[0].total, { normal: onlyMarker ? 0 : 5, elite: 0 });
|
||||
if (onlyMarker) assert.deepEqual(offer.territories[0].sectors, {});
|
||||
if (baseline) assert.deepEqual(offer, baseline);
|
||||
baseline = offer;
|
||||
const snapshot = quoteEcazBetrayal(g, holder, offer.event, 'wind_pass');
|
||||
assert.deepEqual(snapshot.noField, { supplier: ally, event: 'private-marker-generation', tokenId: token.id,
|
||||
location: { territory: 'wind_pass', sector: 14 } });
|
||||
assert.equal(Object.hasOwn(snapshot, 'concealed'), false);
|
||||
assert.equal(Object.hasOwn(snapshot.noField!, 'value'), false);
|
||||
assert.equal(Object.hasOwn(snapshot.noField!, 'reserves'), false);
|
||||
assert.doesNotThrow(() => validateEcazBetrayalSnapshot(g, snapshot));
|
||||
assert.equal(JSON.stringify(offer).includes(token.id), false);
|
||||
assert.equal(JSON.stringify(offer).includes('private-marker-generation'), false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('pending marker recall binds exact private source while leaving denomination outside the quote', () => {
|
||||
const { g, holder, ally, token } = markerPosition(5, true);
|
||||
const snapshot = quoteEcazBetrayal(g, holder, ecazBetrayalOffer(g, holder)!.event, 'wind_pass');
|
||||
const pending = editGame(g, copy => {
|
||||
copy.nexusCards!.cards!.hands[holder] = null;
|
||||
copy.nexusCards!.cards!.discard.push('ecaz');
|
||||
});
|
||||
assert.doesNotThrow(() => validateEcazBetrayalSnapshot(pending, snapshot, true));
|
||||
const mutations: Array<(copy: Game) => void> = [
|
||||
copy => { copy.players.find(p => p.id === ally)!.noFieldEvent = 'new-event'; },
|
||||
copy => { copy.players.find(p => p.id === ally)!.noField!.deployed = null; },
|
||||
copy => { copy.players.find(p => p.id === ally)!.noField!.deployed!.location.sector = 15; },
|
||||
copy => { copy.players.find(p => p.id === ally)!.noField!.deployed!.controller = holder; },
|
||||
copy => {
|
||||
const state = copy.players.find(p => p.id === ally)!.noField!;
|
||||
state.deployed!.tokenId = state.tokens.find(t => t.id !== token.id)!.id;
|
||||
state.lastShipped = state.deployed!.tokenId;
|
||||
},
|
||||
];
|
||||
for (const mutate of mutations)
|
||||
assert.throws(() => validateEcazBetrayalSnapshot(editGame(pending, mutate), snapshot, true));
|
||||
assert.throws(() => validateEcazBetrayalSnapshot(pending, { ...snapshot,
|
||||
noField: { ...snapshot.noField!, supplier: holder } }, true));
|
||||
const { noField: omitted, ...missingMarker } = snapshot;
|
||||
assert.ok(omitted);
|
||||
assert.throws(() => validateEcazBetrayalSnapshot(pending, missingMarker, true));
|
||||
const unrelatedReserves = editGame(pending, copy => { copy.players.find(p => p.id === ally)!.reserves = 0; });
|
||||
assert.doesNotThrow(() => validateEcazBetrayalSnapshot(unrelatedReserves, snapshot, true));
|
||||
});
|
||||
|
||||
void test('only the target ally marker joins a quote and Ecaz still needs ordinary physical co-presence', () => {
|
||||
const { g, ecaz, holder, ally } = markerPosition(3, true);
|
||||
const noEcazCounters = editGame(g, copy => { copy.players.find(p => p.id === ecaz)!.forces = {}; });
|
||||
assert.match(ecazBetrayalOffer(noEcazCounters, holder)!.blocked!, /share no territory/);
|
||||
const unrelated = editGame(g, copy => {
|
||||
copy.players.find(p => p.id === ally)!.noField!.deployed!.controller = holder;
|
||||
});
|
||||
assert.match(ecazBetrayalOffer(unrelated, holder)!.blocked!, /share no territory/);
|
||||
});
|
||||
|
||||
void test('current mobile and revealed Discovery territories quote complete groups without treating Homeworlds as territories', () => {
|
||||
const { g, ecaz, ally, holder } = position();
|
||||
g.expansions = ['ecaz', 'ix'];
|
||||
g.players.find(p => p.id === holder)!.faction = 'ixians';
|
||||
g.mobileStronghold = { location: 'arrakeen:9' };
|
||||
g.players.find(p => p.id === ecaz)!.forces[MOBILE_LOCATION] = 1;
|
||||
g.players.find(p => p.id === ally)!.forces[MOBILE_LOCATION] = 2;
|
||||
const mobileOffer = ecazBetrayalOffer(g, holder)!;
|
||||
assert.equal(mobileOffer.blocked, null);
|
||||
assert.deepEqual(mobileOffer.territories.find(row => row.territory === MOBILE_STRONGHOLD), {
|
||||
territory: MOBILE_STRONGHOLD, sectors: { [MOBILE_LOCATION]: { normal: 2, elite: 0 } },
|
||||
total: { normal: 2, elite: 0 },
|
||||
});
|
||||
const mobileSnapshot = quoteEcazBetrayal(g, holder, mobileOffer.event, MOBILE_STRONGHOLD);
|
||||
assert.doesNotThrow(() => validateEcazBetrayalSnapshot(g, mobileSnapshot));
|
||||
|
||||
// Build a real pure Discovery placement/reveal; this is a quote boundary,
|
||||
// not a claim that a synthetic game has played the intervening spice blow.
|
||||
g.discoveryEnabled = true;
|
||||
const supply = createDiscoveryState(() => 0.25);
|
||||
const hiereg = supply.tokens.filter(token => token.type === 'hiereg');
|
||||
const chosen = hiereg.findIndex(token => token.face === 'jacurutu-sietch');
|
||||
assert.ok(chosen >= 0);
|
||||
const placed = placeDiscovery(supply, 'discovery-hagga-basin', () => (chosen + 0.25) / hiereg.length);
|
||||
const hidden = placed.tokens.find(token => token.face === 'jacurutu-sietch')!;
|
||||
g.discoveries = revealDiscoveryToken(placed, hidden.id, g.turn).state;
|
||||
g.players.find(p => p.id === ecaz)!.forces['jacurutu-sietch:0'] = 1;
|
||||
g.players.find(p => p.id === ally)!.forces['jacurutu-sietch:0'] = 3;
|
||||
const discoveryOffer = ecazBetrayalOffer(g, holder)!;
|
||||
assert.equal(discoveryOffer.blocked, null);
|
||||
assert.deepEqual(discoveryOffer.territories.find(row => row.territory === 'jacurutu-sietch')!.total,
|
||||
{ normal: 3, elite: 0 });
|
||||
const discoverySnapshot = quoteEcazBetrayal(g, holder, discoveryOffer.event, 'jacurutu-sietch');
|
||||
assert.doesNotThrow(() => validateEcazBetrayalSnapshot(g, discoverySnapshot));
|
||||
assert.throws(() => ecazBetrayalOffer(editGame(g, copy => {
|
||||
copy.discoveries!.tokens.find(token => token.face === 'jacurutu-sietch')!.revealedTurn = null;
|
||||
}), holder), /board custody/, 'Unrevealed Discovery faces cannot supply a board location.');
|
||||
|
||||
const context = homeworldContext(g);
|
||||
g.homeworlds = { custody: quoteHomeworldCustody(context, createHomeworldCustody(context), [
|
||||
{ homeworld: 'homeworld:ixians', player: ecaz, withdraw: { normal: 0, elite: 0 }, deposit: { normal: 1, elite: 0 } },
|
||||
]).state };
|
||||
assert.equal(ecazBetrayalOffer(g, holder)!.blocked, null,
|
||||
'Unrelated valid Homeworld custody does not disable an Arrakis quote.');
|
||||
assert.throws(() => quoteEcazBetrayal(g, holder, ecazBetrayalOffer(g, holder)!.event, 'homeworld:ixians'), /shared territory/);
|
||||
});
|
||||
|
||||
void test('opening ends with any shipment, movement, first-seat pass, pending decision or automatic continuation', () => {
|
||||
@@ -102,6 +275,10 @@ void test('opening ends with any shipment, movement, first-seat pass, pending de
|
||||
assert.match(blocked(copy => { copy.players[1].moved = 1; }), /before the first/);
|
||||
assert.match(blocked(copy => { copy.movementRemaining!.shift(); copy.active = copy.order[1]; }), /before the first/);
|
||||
assert.match(blocked(copy => { copy.movementRemaining!.shift(); copy.active = copy.order[0]; }), /before the first/);
|
||||
assert.match(blocked(copy => { copy.movementRemaining = []; }), /before the first/);
|
||||
assert.match(blocked(copy => { copy.movementRemaining![1] = copy.movementRemaining![0]; }), /before the first/);
|
||||
assert.match(blocked(copy => { copy.movementRemaining![1] = 'not-seated'; }), /before the first/);
|
||||
assert.match(blocked(copy => { copy.active = copy.order[1]; }), /before the first/);
|
||||
assert.match(blocked(copy => { copy.active = null; }), /before the first/);
|
||||
assert.match(blocked(copy => { copy.phase = 4; }), /before the first/);
|
||||
assert.match(blocked(copy => { copy.phaseOpening = { passed: [], initialize: true }; }), /current interaction/);
|
||||
@@ -109,6 +286,70 @@ void test('opening ends with any shipment, movement, first-seat pass, pending de
|
||||
assert.match(ecazBetrayalOffer(g, holder, true)!.blocked!, /current interaction/);
|
||||
});
|
||||
|
||||
void test('quiet Guild first or wait keeps the pre-action Betrayal opening without changing physical Storm order', () => {
|
||||
const { g, holder, ecaz, ally } = position();
|
||||
g.expansions = ['ecaz'];
|
||||
g.players.find(p => p.id === holder)!.faction = 'guild';
|
||||
g.guildTimingGranted = true;
|
||||
g.guildTimingLocked = false;
|
||||
// Native chooseGuildTiming(take=true) grants the whole turn without
|
||||
// rewriting movementRemaining. The original-action module fixture exercises
|
||||
// the real decision/response; this test isolates its resulting quote facts.
|
||||
g.active = holder;
|
||||
const physicalOrder = [...g.order];
|
||||
assert.deepEqual(g.movementRemaining, [ecaz, holder, ally]);
|
||||
const first = ecazBetrayalOffer(g, holder)!;
|
||||
assert.equal(first.blocked, null);
|
||||
assert.doesNotThrow(() => validateEcazBetrayalSnapshot(g,
|
||||
quoteEcazBetrayal(g, holder, first.event, 'wind_pass')));
|
||||
|
||||
// Native wait places Guild at the back of the remaining queue; nobody has
|
||||
// taken a Shipment/Movement turn merely because this timing choice settled.
|
||||
const waiting = editGame(g, copy => {
|
||||
copy.movementRemaining = [ecaz, ally, holder];
|
||||
copy.active = ecaz;
|
||||
});
|
||||
assert.equal(ecazBetrayalOffer(waiting, holder)!.blocked, null);
|
||||
assert.deepEqual(waiting.order, physicalOrder);
|
||||
|
||||
for (const change of [
|
||||
(copy: Game) => { copy.guildTimingGranted = false; },
|
||||
(copy: Game) => { copy.guildTimingLocked = true; },
|
||||
(copy: Game) => { copy.advanced = false; },
|
||||
(copy: Game) => { copy.active = ally; },
|
||||
(copy: Game) => { copy.movementRemaining = [ecaz, ally]; },
|
||||
]) assert.match(ecazBetrayalOffer(editGame(g, change), holder)!.blocked!, /before the first/);
|
||||
assert.match(ecazBetrayalOffer(editGame(g, copy => {
|
||||
copy.decision = { kind: 'guildTiming', player: holder, next: ecaz, following: ally };
|
||||
}), holder)!.blocked!, /current interaction/);
|
||||
});
|
||||
|
||||
void test('an unspent Sapho-reordered first turn and its pending return keep the complete opening queue', () => {
|
||||
const { g, holder, ally } = position();
|
||||
const physicalOrder = [...g.order];
|
||||
const event = `movement:${g.turn}`;
|
||||
const reordered = reorderOrderedOpportunity({ event, eligible: g.order,
|
||||
remaining: g.movementRemaining!, completed: [], current: g.active, currentStarted: false },
|
||||
{ event, player: ally, position: 'first' });
|
||||
g.movementRemaining = [...reordered.remaining];
|
||||
g.active = reordered.current;
|
||||
assert.notEqual(g.active, g.order[0]);
|
||||
assert.deepEqual(g.order, physicalOrder);
|
||||
assert.ok(g.players.every(p => !p.shipped && p.moved === 0));
|
||||
const offer = ecazBetrayalOffer(g, holder)!;
|
||||
assert.equal(offer.blocked, null);
|
||||
const snapshot = quoteEcazBetrayal(g, holder, offer.event, 'wind_pass');
|
||||
const pending = editGame(g, copy => {
|
||||
copy.nexusCards!.cards!.hands[holder] = null;
|
||||
copy.nexusCards!.cards!.discard.push('ecaz');
|
||||
});
|
||||
assert.doesNotThrow(() => validateEcazBetrayalSnapshot(pending, snapshot, true));
|
||||
assert.throws(() => validateEcazBetrayalSnapshot(editGame(pending, copy => {
|
||||
copy.movementRemaining!.shift();
|
||||
copy.active = copy.movementRemaining![0];
|
||||
}), snapshot, true), /pending card or alliance/);
|
||||
});
|
||||
|
||||
void test('accepted quote rejects foreign territory, stale event and changing typed board custody', () => {
|
||||
const { g, ecaz, ally, holder } = position();
|
||||
const event = ecazBetrayalOffer(g, holder)!.event;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame } from '../game/engine';
|
||||
import { terrorLocationAllowed, terrorEntryLocationAllowed } from '../game/terror-location';
|
||||
import { NEXUS_FACTIONS } from '../game/nexus-cards';
|
||||
import { e3IndependentNexusCardPlayer as player } from './fixture-e3-independent-nexus-cards';
|
||||
import {
|
||||
createNexusMoritaniAtomicsFixture, continueNexusMoritaniAtomics, NEXUS_ATOMICS_TERRITORY,
|
||||
} from './fixture-nexus-moritani-atomics';
|
||||
|
||||
for (const modules of [false, true]) void test(`${modules ? 'Advanced all14/World/phase selected decks' : 'Basic selected E3'} actual Moritani Cunning Atomics destroys the original desert armies and keeps movement with a receipt-bound Aftermath shipment ban`, () => {
|
||||
const fixture = createNexusMoritaniAtomicsFixture({ advanced: modules, skills: modules,
|
||||
homeworlds: modules, tech: modules, strongholds: modules,
|
||||
expansions: modules ? ['ecaz', 'ix', 'choam'] : ['ecaz'] });
|
||||
assert.ok(fixture.afterSetup.ecazTreachery && fixture.afterSetup.nexusCards);
|
||||
assert.equal(!!fixture.afterSetup.homeworlds, modules);
|
||||
assert.equal(!!fixture.afterSetup.leaderSkills, modules);
|
||||
assert.equal(!!fixture.afterSetup.techTokens, modules);
|
||||
assert.equal(!!fixture.afterSetup.strongholdCards, modules);
|
||||
assert.deepEqual(player(fixture.afterSetup, 'moritani').forces, { 'pasty_mesa:5': 6 });
|
||||
assert.equal(fixture.draw.after.nexusCards!.cards!.hands.moritani, 'moritani');
|
||||
assert.equal(fixture.placement.pending.nexusCards!.cards!.hands.moritani, null);
|
||||
assert.equal(fixture.placement.pending.nexusCards!.cards!.discard.filter(card => card === 'moritani').length, 1);
|
||||
assert.equal(player(fixture.placement.after, 'moritani').spice, player(fixture.placement.before, 'moritani').spice);
|
||||
assert.deepEqual(fixture.placement.after.nexusMoritaniLocations?.[fixture.atomics], {
|
||||
event: fixture.receipt.event, territory: NEXUS_ATOMICS_TERRITORY });
|
||||
assert.equal(fixture.receipt.source, 'mentat');
|
||||
assert.equal(fixture.receipt.turn, fixture.placement.before.turn);
|
||||
assert.equal(fixture.receipt.token, fixture.atomics);
|
||||
assert.equal(fixture.receipt.territory, NEXUS_ATOMICS_TERRITORY);
|
||||
assert.equal(terrorLocationAllowed(fixture.game, fixture.atomics, NEXUS_ATOMICS_TERRITORY, fixture.receipt.event), true);
|
||||
assert.equal(terrorEntryLocationAllowed(fixture.game, fixture.game.pendingTerrorEntry!), true);
|
||||
assert.equal(player(fixture.game, 'guild').reserves, player(fixture.beforeEntry, 'guild').reserves - 3);
|
||||
assert.equal(player(fixture.game, 'guild').forces['tueks_sietch:5'], 5);
|
||||
assert.equal(player(fixture.game, 'guild').spice, player(fixture.beforeEntry, 'guild').spice - 3);
|
||||
if (!modules) {
|
||||
const supply = fixture.movementSupply; assert.ok(supply);
|
||||
assert.equal(supply.step.action.type, 'ship');
|
||||
assert.equal(player(supply.after, 'guild').spice, player(supply.before, 'guild').spice - 1);
|
||||
assert.equal(player(supply.after, 'guild').reserves, player(supply.before, 'guild').reserves - 1);
|
||||
assert.equal(player(supply.after, 'guild').forces['south_mesa:6'], 1);
|
||||
assert.equal(player(supply.after, 'guild').forces['tueks_sietch:5'], 5);
|
||||
}
|
||||
const finished = continueNexusMoritaniAtomics(fixture.game, fixture.actions);
|
||||
const activated = finished.activation.game, before = finished.activation.beforeActivation;
|
||||
assert.deepEqual(finished.activation.seen, ['atomics']);
|
||||
assert.equal(player(activated, 'moritani').tanks, player(before, 'moritani').tanks + 6);
|
||||
assert.equal(player(activated, 'guild').tanks, player(before, 'guild').tanks + 3);
|
||||
assert.equal(Object.entries(player(activated, 'moritani').forces).filter(([key]) => key.startsWith('pasty_mesa:')).reduce((sum, [, amount]) => sum + amount, 0), 0);
|
||||
assert.equal(Object.entries(player(activated, 'guild').forces).filter(([key]) => key.startsWith('pasty_mesa:')).reduce((sum, [, amount]) => sum + amount, 0), 0);
|
||||
assert.deepEqual(activated.moritaniAtomics, { territory: NEXUS_ATOMICS_TERRITORY, turn: before.turn,
|
||||
moritaniId: 'moritani', allyAtActivation: null, alliancePolicy: 'activation-targets', nexusPlacement: fixture.receipt.event });
|
||||
assert.equal(activated.moritaniTerror!.tokens.find(token => token.id === fixture.atomics)!.status, 'removed');
|
||||
assert.equal(activated.nexusMoritaniLocations?.[fixture.atomics], undefined);
|
||||
assert.deepEqual(activated.nexusMoritaniHistory, fixture.placement.after.nexusMoritaniHistory);
|
||||
assert.equal(terrorLocationAllowed(activated, fixture.atomics, NEXUS_ATOMICS_TERRITORY, fixture.receipt.event, false, true), true);
|
||||
assert.equal(terrorLocationAllowed(activated, fixture.atomics, NEXUS_ATOMICS_TERRITORY, fixture.receipt.event), false);
|
||||
assert.equal(terrorLocationAllowed(activated, 'another-token', NEXUS_ATOMICS_TERRITORY, fixture.receipt.event, false, true), false);
|
||||
assert.equal(player(activated, 'moritani').atomicsHandLimitPenalty, true);
|
||||
assert.equal(viewGame(activated, 'moritani').players.find(player => player.id === 'moritani')!.handLimit, 3);
|
||||
assert.equal(player(activated, 'guild').atomicsHandLimitPenalty ?? false, false);
|
||||
assert.equal(player(finished.beforeMove, 'guild').shipped, true);
|
||||
assert.equal(player(finished.beforeMove, 'guild').moved, 0);
|
||||
assert.equal(Object.entries(player(finished.afterMove, 'guild').forces).filter(([key]) => key.startsWith('pasty_mesa:')).reduce((sum, [, amount]) => sum + amount, 0), modules ? 5 : 1);
|
||||
if (!modules) assert.equal(player(finished.afterMove, 'guild').forces['tueks_sietch:5'], 5);
|
||||
assert.equal(player(finished.afterMove, 'guild').moved, 1);
|
||||
assert.throws(() => applyAction(finished.beforeShipment, 'guild', finished.blockedShipment), /Aftermath|Atomics/i);
|
||||
assert.equal(player(finished.game, 'guild').shipped, true);
|
||||
assert.equal(player(finished.game, 'guild').forces['polar_sink:0'], 1);
|
||||
assert.deepEqual(finished.game.moritaniAtomics, activated.moritaniAtomics);
|
||||
const nexus = finished.game.nexusCards!.cards!;
|
||||
assert.deepEqual([...nexus.deck, ...nexus.discard, ...Object.values(nexus.hands).filter(card => card !== null)].sort(), [...NEXUS_FACTIONS].sort());
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createRicheseNoField, deployRicheseNoField, deployRicheseNoFieldPair,
|
||||
recallRicheseNoField, projectRicheseNoField, revealRicheseNoField,
|
||||
validateRicheseNoField, type NoFieldValue,
|
||||
} from '../game/richese-no-field';
|
||||
|
||||
for (const value of [0,3,5] as const) void test(`forced face-down ${value} No-Field return keeps its last-shipment ban without revealing its value`, () => {
|
||||
const start = createRicheseNoField(['zero','three','five']);
|
||||
const token = start.tokens.find(token => token.value === value)!;
|
||||
const location = {territory:'imperial_basin',sector:9};
|
||||
const deployed = deployRicheseNoField(start, {tokenId:token.id,controller:'richese',location});
|
||||
const before = structuredClone(deployed);
|
||||
const returned = recallRicheseNoField(deployed,token.id);
|
||||
assert.deepEqual(deployed,before);
|
||||
assert.deepEqual(projectRicheseNoField(returned,false), {deployed:null,lastUsed:null});
|
||||
assert.equal(returned.lastShipped,token.id);
|
||||
assert.equal(returned.hiddenReturn,true);
|
||||
assert.deepEqual(projectRicheseNoField(returned,true).private,returned);
|
||||
assert.throws(()=>recallRicheseNoField(returned,token.id),/not deployed/);
|
||||
assert.throws(()=>deployRicheseNoField(returned,{tokenId:token.id,controller:'richese',location}),/twice in a row/);
|
||||
const next = returned.tokens.find(candidate=>candidate.id!==token.id)!;
|
||||
const redeployed = deployRicheseNoField(returned,{tokenId:next.id,controller:'richese',location});
|
||||
assert.equal(Object.hasOwn(redeployed,'hiddenReturn'),false);
|
||||
const revealed = revealRicheseNoField(redeployed,{tokenId:next.id,reserves:2,cause:'atomics'});
|
||||
assert.equal(revealed.forces,Math.min(next.value,2));
|
||||
assert.equal(projectRicheseNoField(revealed.state,false).lastUsed,next.value);
|
||||
const other = returned.tokens.find(candidate=>candidate.id!==token.id&&candidate.id!==next.id)!;
|
||||
const pair = deployRicheseNoFieldPair(returned,{tokenId:next.id,controller:'richese',location},other.id,7);
|
||||
assert.equal(Object.hasOwn(pair.state,'hiddenReturn'),false);
|
||||
assert.equal(pair.revealed.forces,other.value);
|
||||
});
|
||||
|
||||
void test('the hidden-return marker requires an actual prior shipment and cannot coexist with a deployed token', () => {
|
||||
const start = createRicheseNoField(['zero','three','five']);
|
||||
assert.throws(()=>validateRicheseNoField({...start,hiddenReturn:true}),/previous shipment/);
|
||||
const deployed=deployRicheseNoField(start,{tokenId:'three',controller:'richese',location:{territory:'arrakeen',sector:10}});
|
||||
assert.throws(()=>validateRicheseNoField({...deployed,hiddenReturn:true}),/previous shipment/);
|
||||
assert.throws(()=>recallRicheseNoField(deployed,'five'),/not deployed/);
|
||||
const oldReveal=revealRicheseNoField(deployed,{tokenId:'three',reserves:7,cause:'voluntary'});
|
||||
assert.equal(Object.hasOwn(oldReveal.state,'hiddenReturn'),false);
|
||||
assert.equal(projectRicheseNoField(oldReveal.state,false).lastUsed,3 as NoFieldValue);
|
||||
});
|
||||
Reference in new issue
Block a user