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:
Quintine committed 2026-10-18 11:21:28 +11:00
1 parent 8793e23d6b
commit bb9d292c4c
23 files changed
+1824 -88

No files matched your search

+167
View File
@@ -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));
}
});
+226
View File
@@ -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' };
});
}
+238
View File
@@ -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 };
}
+132
View File
@@ -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 };
}
+180
View File
@@ -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);
});
+51 -1
View File
@@ -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);
});
+247 -6
View File
@@ -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);
});