Offer Semuta on Spiritual Advisor and relocation costs
This commit is contained in:
1 parent
92f2e8125b
commit
81fbbca309
12 files changed
+1559
-10
No files matched your search
@@ -0,0 +1,362 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame,
|
||||
type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { isAdvisor } from '../game/advisors';
|
||||
import { AMBASSADOR_EFFECTS, type AmbassadorEffect } from '../game/ecaz-ambassadors';
|
||||
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_TOKEN_BY_ID } from '../game/discoveries';
|
||||
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
import {
|
||||
nextSemutaRicheseCacheKarama, semutaRicheseCacheKaramaClean,
|
||||
semutaRicheseCacheKaramaInventory, semutaRicheseCacheKaramaPlayer,
|
||||
} from './fixture-semuta-richese-cache-karama';
|
||||
import {
|
||||
passSemutaFremenGuildMovement, claimSemutaFremenGuildMovement,
|
||||
} from './fixture-semuta-fremen-guild-movement';
|
||||
|
||||
export type SemutaAdvisorRelocationKind = 'ambassador' | 'discovery';
|
||||
export type SemutaAdvisorRelocationStep = { actor: string; action: Action };
|
||||
export const passSemutaAdvisorRelocation = passSemutaFremenGuildMovement;
|
||||
export const claimSemutaAdvisorRelocation = claimSemutaFremenGuildMovement;
|
||||
export const semutaAdvisorRelocationPlayer = semutaRicheseCacheKaramaPlayer;
|
||||
export const semutaAdvisorRelocationInventory = semutaRicheseCacheKaramaInventory;
|
||||
export const semutaAdvisorRelocationClean = semutaRicheseCacheKaramaClean;
|
||||
export const semutaAdvisorRelocationPhysical = (game: Game) => ({
|
||||
turn: game.turn, phase: game.phase, storm: game.storm, order: game.order,
|
||||
spice: game.spice, spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard,
|
||||
players: game.players.map(player => ({ id: player.id, spice: player.spice,
|
||||
reserves: player.reserves, tanks: player.tanks, forces: player.forces, elites: player.elites,
|
||||
leaders: player.leaders, shipped: player.shipped, moved: player.moved, revived: player.revived, ally: player.ally })),
|
||||
});
|
||||
const cisternPlacement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
|
||||
|
||||
/** Select only real scalar packet/token draws. UUID and byte entropy stay
|
||||
* native; optional scalars must all be consumed by the actual producer. */
|
||||
function originalRelocationAction(game: Game, actor: string, action: Action,
|
||||
actions: SemutaAdvisorRelocationStep[], scalars: readonly number[] = []): Game {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const original = crypto.getRandomValues.bind(crypto); let cursor = 0;
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1)
|
||||
array[0] = cursor < scalars.length ? scalars[cursor++] : 0xffffffff;
|
||||
else Reflect.apply(original, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try {
|
||||
const gameAfter = applyAction(game, actor, action);
|
||||
assert.equal(cursor, scalars.length, 'The actual original producer consumes every selected scalar draw');
|
||||
actions.push({ actor, action: structuredClone(action) }); return gameAfter;
|
||||
} finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
export function actSemutaAdvisorRelocation(game: Game, actor: string, action: Action,
|
||||
actions: SemutaAdvisorRelocationStep[] = []): Game {
|
||||
return originalRelocationAction(game, actor, action, actions);
|
||||
}
|
||||
export function nextSemutaAdvisorRelocation(game: Game): SemutaAdvisorRelocationStep | null {
|
||||
if (game.pendingTreacheryDiscard || game.truthtrance || game.response?.automaticAdvisorFlip) return null;
|
||||
if (!game.phaseOpening && !game.response) {
|
||||
const bg = game.players.find(player => player.faction === 'beneGesserit'); assert.ok(bg);
|
||||
const ecaz = game.players.find(player => player.faction === 'ecaz');
|
||||
if (game.status === 'setup' && game.setupStage === 'prediction')
|
||||
return { actor: bg.id, action: { type: 'predict', faction: 'atreides', turn: 10 } };
|
||||
if (game.status === 'setup' && game.setupStage === 'forces') {
|
||||
if (ecaz?.reserves === 20)
|
||||
return { actor: ecaz.id, action: { type: 'ecazSetup', placements: { 'imperial_basin:10': 6 } } };
|
||||
if (!bg.advisorSetup)
|
||||
return { actor: bg.id, action: { type: 'advisorSetup', territory: ecaz ? 'imperial_basin' : cisternPlacement.territory,
|
||||
sector: ecaz ? 10 : cisternPlacement.sector } };
|
||||
}
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'ecazPlacement' || decision?.kind === 'ecazAmbassador') return null;
|
||||
if (decision?.kind === 'advisorBattle' || decision?.kind === 'advisor')
|
||||
return { actor: decision.player, action: { type: 'decision', accept: false } };
|
||||
if (decision?.kind === 'intrusion')
|
||||
return { actor: decision.player, action: { type: 'decision', accept: true } };
|
||||
if (decision?.kind === 'discoveryEntry')
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event, accept: false } };
|
||||
}
|
||||
return nextSemutaRicheseCacheKarama(game);
|
||||
}
|
||||
export function advanceSemutaAdvisorRelocation(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaAdvisorRelocationStep[] = []): Game {
|
||||
for (let count = 0; count < 2000; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextSemutaAdvisorRelocation(game);
|
||||
assert.ok(step, 'Choose the original Ambassador, conversion or fresh discard before continuing');
|
||||
game = actSemutaAdvisorRelocation(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original relocation-conversion programme did not reach its finite boundary');
|
||||
}
|
||||
export function allowSemutaAdvisorRelocation(game: Game,
|
||||
actions: SemutaAdvisorRelocationStep[] = []): Game {
|
||||
assert.equal(game.response?.automaticAdvisorFlip?.producer, 'specialRelocation');
|
||||
for (let count = 0; game.response?.kind === 'advisorFlip' && count < game.players.length; count++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actSemutaAdvisorRelocation(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.notEqual(game.response?.kind, 'advisorFlip'); return game;
|
||||
}
|
||||
/** Finish only the original entry parent. In phase zero stop as soon as the
|
||||
* Discovery round clears, before unrelated Storm readiness or its movement. */
|
||||
export function finishSemutaAdvisorRelocation(game: Game,
|
||||
actions: SemutaAdvisorRelocationStep[] = []): Game {
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingKarama ?? null, null);
|
||||
const discovery = game.phase === 0;
|
||||
return advanceSemutaAdvisorRelocation(game, state => discovery
|
||||
? !state.discoveryEntry && !state.response?.automaticAdvisorFlip
|
||||
: !state.pendingAmbassador && semutaAdvisorRelocationClean(state), actions);
|
||||
}
|
||||
|
||||
function fremenPacket() {
|
||||
const original = AMBASSADOR_EFFECTS.filter(effect => effect !== 'ecaz');
|
||||
const supply: Exclude<AmbassadorEffect, 'ecaz'>[] = ['fremen', ...original.filter(effect => effect !== 'fremen').slice(0, 4)];
|
||||
const order = [...supply, ...original.filter(effect => !supply.includes(effect))];
|
||||
const current = [...original], scalars: number[] = [];
|
||||
for (let index = current.length - 1; index > 0; index--) {
|
||||
const from = current.indexOf(order[index]); assert.ok(from >= 0 && from <= index);
|
||||
scalars.push(Math.floor((from + 0.5) * 4294967296 / (index + 1)));
|
||||
[current[index], current[from]] = [current[from], current[index]];
|
||||
}
|
||||
assert.deepEqual(current, order); assert.equal(scalars.length, 9); return { supply, order, scalars };
|
||||
}
|
||||
|
||||
/** Real Advanced Semuta setup for the original narrow E3 or Discovery
|
||||
* initializer. No played cards, pieces, resources, tokens or controls are set. */
|
||||
function createRelocationSetup(kind: SemutaAdvisorRelocationKind) {
|
||||
const richese = 'richese', holder = 'choam', owner = 'beneGesserit', atreides = 'atreides';
|
||||
const spender = kind === 'ambassador' ? atreides : 'emperor';
|
||||
let game = createGame('SEMUTAADVISORRELOCATION', newPlayer(richese, 'Richese', 'richese'), true,
|
||||
kind === 'ambassador' ? ['choam', 'ecaz'] : ['choam']);
|
||||
const roster = kind === 'ambassador' ? [holder, 'ecaz', owner, atreides] as const
|
||||
: [holder, owner, atreides, 'emperor'] as const;
|
||||
for (const faction of roster) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaAdvisorRelocationStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaAdvisorRelocation(game, actor, action, actions); };
|
||||
if (kind === 'discovery') act(game.host, { type: 'moduleOption', module: 'discoveries', enabled: true });
|
||||
const configured = structuredClone(game);
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
|
||||
const setup = structuredClone(game), initialization = { before: lobby, after: setup,
|
||||
entry: kind === 'ambassador' ? 'original-undealt-bare-advanced-ecaz-semuta' : 'original-undealt-semuta-discovery' };
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.equal(game.semutaPreview, true);
|
||||
assert.equal(!!game.discoveryEnabled, kind === 'discovery');
|
||||
assert.ok(game.players.every(player => player.hand.length === 0));
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.ecazTreachery);
|
||||
const originalInventory = semutaAdvisorRelocationInventory(game);
|
||||
assert.equal(originalInventory.length, 45); assert.equal(game.deck.length, 35); assert.equal(game.richeseCache!.length, 10);
|
||||
const printed = game.deck.find(card => card.effect === 'karama'); assert.ok(printed);
|
||||
const ordinary = game.deck.filter(card => card.kind === 'projectile' && !card.effect).slice(0, 4);
|
||||
assert.equal(ordinary.length, 4);
|
||||
const selected = [...ordinary, printed], cost = printed.id;
|
||||
const dealOrder = selected.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: card.id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game), packet = kind === 'ambassador' ? fremenPacket() : null;
|
||||
let packetCreation: { before: Game; steps: SemutaAdvisorRelocationStep[]; after: Game } | null = null;
|
||||
let advisorSetup: { before: Game; steps: SemutaAdvisorRelocationStep[]; after: Game } | null = null;
|
||||
for (let count = 0; game.status === 'setup' && count < 120; count++) {
|
||||
const step = nextSemutaAdvisorRelocation(game); assert.ok(step);
|
||||
const before = structuredClone(game), start = actions.length;
|
||||
const initializesForces = packet && game.setupStage === 'traitors' && step.action.type === 'traitor' &&
|
||||
game.players.filter(player => player.traitorChoices.length).length === 1;
|
||||
game = initializesForces
|
||||
? originalRelocationAction(game, step.actor, step.action, actions, packet!.scalars)
|
||||
: actSemutaAdvisorRelocation(game, step.actor, step.action, actions);
|
||||
const record = { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
|
||||
if (initializesForces) packetCreation = record;
|
||||
if (step.action.type === 'advisorSetup') advisorSetup = record;
|
||||
}
|
||||
assert.equal(game.status, 'playing'); assert.ok(advisorSetup);
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.ok(semutaAdvisorRelocationPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
if (packet) {
|
||||
assert.ok(packetCreation);
|
||||
assert.deepEqual(game.ecazAmbassadors!.cohort.map(id => game.ecazAmbassadors!.tokens.find(token => token.id === id)!.effect), packet.supply);
|
||||
}
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder = [0, 1].map(position => {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card &&
|
||||
(kind === 'discovery' && position === 0 ? card.discovery === 'discovery-hagga-basin' : !card.discovery));
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
|
||||
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
|
||||
});
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
return { game, initial, configured, lobby, setup, initialization, beforeStartingDeal, afterSetup,
|
||||
packet, packetCreation, advisorSetup, richese, holder, owner, bg: owner, atreides, spender,
|
||||
cost, semuta: SEMUTA_DRUG_ID, actions, originalInventory, originalSpice, dealOrder, spiceOrder };
|
||||
}
|
||||
|
||||
/** Actual paid cache purchase. Never substitute a physical card into a played
|
||||
* hand when acquiring Semuta for either relocation chronology. */
|
||||
function acquireSemuta(game: Game, actions: SemutaAdvisorRelocationStep[]) {
|
||||
const holder = 'choam', richese = 'richese';
|
||||
let beforeBid: Game | undefined, bid: SemutaAdvisorRelocationStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 1000 && !semutaAdvisorRelocationPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const step = nextSemutaAdvisorRelocation(game); assert.ok(step);
|
||||
if (step.actor === holder && step.action.type === 'richeseBid' && step.action.amount === 1) {
|
||||
beforeBid = structuredClone(game); bid = structuredClone(step); bidAt = actions.length;
|
||||
}
|
||||
game = actSemutaAdvisorRelocation(game, step.actor, step.action, actions);
|
||||
}
|
||||
assert.ok(beforeBid && bid && bidAt >= 0, 'CHOAM really purchases the original cache Semuta for one spice');
|
||||
const acquired = structuredClone(game), acquisition = { before: beforeBid, bid,
|
||||
steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 };
|
||||
assert.equal(semutaAdvisorRelocationPlayer(beforeBid, holder).spice - semutaAdvisorRelocationPlayer(game, holder).spice, 1);
|
||||
assert.equal(semutaAdvisorRelocationPlayer(game, richese).spice - semutaAdvisorRelocationPlayer(beforeBid, richese).spice, 1);
|
||||
return { game, acquired, acquisition };
|
||||
}
|
||||
|
||||
export function createSemutaAdvisorRelocationFixture(options: { kind: SemutaAdvisorRelocationKind }) {
|
||||
const { kind } = options, base = createRelocationSetup(kind), { actions, owner, holder, atreides } = base;
|
||||
let game = base.game;
|
||||
const act = (actor: string, action: Action) => { game = actSemutaAdvisorRelocation(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaAdvisorRelocation(game, until, actions); };
|
||||
const transition = (run: () => void) => {
|
||||
const before = structuredClone(game), start = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
|
||||
};
|
||||
let tokenPlacement: ReturnType<typeof transition> | null = null, tokenScalar: number | null = null;
|
||||
if (kind === 'discovery') {
|
||||
reach(state => state.phase === 1 && semutaAdvisorRelocationClean(state));
|
||||
const before = structuredClone(game), start = actions.length;
|
||||
const eligible = game.discoveries!.tokens.filter(token => token.status === 'supply' && token.type === DISCOVERY_TOKEN_BY_ID.cistern.type);
|
||||
const selected = eligible.findIndex(token => token.face === 'cistern'); assert.ok(selected >= 0);
|
||||
tokenScalar = Math.floor((selected + 0.5) * 4294967296 / eligible.length);
|
||||
for (let count = 0; !game.discoveries!.tokens.some(token => token.face === 'cistern' && token.status === 'placed') && count < 100; count++) {
|
||||
const step = nextSemutaAdvisorRelocation(game); assert.ok(step);
|
||||
const places = !game.response && !game.phaseOpening && !game.decision && step.action.type === 'ready' &&
|
||||
game.ready.length === game.players.length - 1;
|
||||
game = originalRelocationAction(game, step.actor, step.action, actions, places ? [tokenScalar] : []);
|
||||
}
|
||||
tokenPlacement = { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
|
||||
const placed = game.discoveries!.tokens.find(token => token.face === 'cistern'); assert.ok(placed);
|
||||
assert.equal(placed.status, 'placed'); assert.equal(placed.territory, cisternPlacement.territory);
|
||||
}
|
||||
const paidSemuta = acquireSemuta(game, actions); game = paidSemuta.game;
|
||||
let placement: ReturnType<typeof transition> | null = null, trigger: ReturnType<typeof transition> | null = null;
|
||||
let intrusion: ReturnType<typeof transition> | null = null, revelation: ReturnType<typeof transition> | null = null;
|
||||
let offer: Game | null = null;
|
||||
let token: string, event: string, territory: string, from: string, destination: string, sector: number, amount: number;
|
||||
let mover: string, nativeAction: Action, shipment: ReturnType<typeof transition>;
|
||||
if (kind === 'ambassador') {
|
||||
const ecaz = 'ecaz'; territory = 'imperial_basin'; from = 'imperial_basin:10'; mover = ecaz;
|
||||
assert.equal(semutaAdvisorRelocationPlayer(game, ecaz).forces[from], 6);
|
||||
assert.equal(semutaAdvisorRelocationPlayer(game, owner).forces[from], 1);
|
||||
assert.equal(isAdvisor(semutaAdvisorRelocationPlayer(game, owner), territory), true);
|
||||
reach(state => state.phase === 4 && !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
|
||||
const physicalToken = game.ecazAmbassadors!.tokens.find(candidate => candidate.effect === 'fremen'); assert.ok(physicalToken);
|
||||
token = physicalToken.id; assert.equal(physicalToken.zone, 'supply');
|
||||
placement = transition(() => {
|
||||
act(ecaz, { type: 'decision', token, territory: 'carthag' });
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
|
||||
});
|
||||
assert.equal(semutaAdvisorRelocationPlayer(placement.before, ecaz).spice - semutaAdvisorRelocationPlayer(game, ecaz).spice, 1);
|
||||
act(ecaz, { type: 'decision', decline: true });
|
||||
reach(state => state.phase === 5 && state.active === atreides && semutaAdvisorRelocationClean(state));
|
||||
shipment = transition(() => {
|
||||
act(atreides, { type: 'ship', territory: 'carthag', sector: 11, amount: 1, elite: 0, allyPayment: 0 });
|
||||
reach(state => !state.response && !state.phaseOpening && state.pendingAmbassador?.stage === 'offer');
|
||||
});
|
||||
offer = structuredClone(game); event = game.pendingAmbassador!.event;
|
||||
trigger = transition(() => {
|
||||
act(ecaz, { type: 'decision', event, trigger: true, beneficiary: ecaz });
|
||||
reach(state => !state.response && !state.phaseOpening && state.pendingAmbassador?.stage === 'move');
|
||||
});
|
||||
assert.equal(game.pendingAmbassador!.effect, 'fremen'); assert.equal(game.pendingAmbassador!.beneficiary, ecaz);
|
||||
destination = 'tueks_sietch'; sector = 5; amount = 6;
|
||||
nativeAction = { type: 'decision', event, forces: { [from]: amount }, territory: destination, sector };
|
||||
} else {
|
||||
territory = cisternPlacement.territory; from = `${territory}:${cisternPlacement.sector}`; mover = atreides;
|
||||
const physicalToken = game.discoveries!.tokens.find(candidate => candidate.face === 'cistern'); assert.ok(physicalToken);
|
||||
token = physicalToken.id; assert.equal(physicalToken.status, 'placed');
|
||||
assert.equal(isAdvisor(semutaAdvisorRelocationPlayer(base.afterSetup, owner), territory), false);
|
||||
reach(state => state.phase === 5 && state.active === owner && semutaAdvisorRelocationClean(state));
|
||||
act(owner, { type: 'endMovement' });
|
||||
reach(state => state.phase === 5 && state.active === atreides && semutaAdvisorRelocationClean(state));
|
||||
shipment = transition(() => {
|
||||
act(atreides, { type: 'ship', territory, sector: cisternPlacement.sector, amount: 1, elite: 0, allyPayment: 0 });
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'intrusion');
|
||||
});
|
||||
intrusion = transition(() => {
|
||||
act(owner, { type: 'decision', accept: true }); reach(semutaAdvisorRelocationClean);
|
||||
});
|
||||
assert.equal(semutaAdvisorRelocationPlayer(shipment.before, atreides).spice - semutaAdvisorRelocationPlayer(game, atreides).spice, 2);
|
||||
assert.equal(semutaAdvisorRelocationPlayer(game, atreides).forces[from], 1);
|
||||
assert.equal(isAdvisor(semutaAdvisorRelocationPlayer(game, owner), territory), true);
|
||||
reach(state => state.turn === 1 && state.phase === 7 && semutaAdvisorRelocationClean(state));
|
||||
assert.ok(viewGame(game, atreides).discoveries!.canInspect.includes(physicalToken.id));
|
||||
revelation = transition(() => {
|
||||
act(atreides, { type: 'discovery', token, reveal: false });
|
||||
act(atreides, { type: 'discovery', token, reveal: true });
|
||||
});
|
||||
reach(state => state.turn === 2 && state.phase === 0 && !state.response && !state.phaseOpening &&
|
||||
state.decision?.kind === 'discoveryEntry' && state.decision.player === atreides);
|
||||
const entryAction = discoveryEntryMoveAction(viewGame(game, atreides), [{ source: from, normal: 1, elite: 0 }]);
|
||||
assert.ok(entryAction); assert.ok(game.decision?.kind === 'discoveryEntry');
|
||||
event = game.decision.event; nativeAction = entryAction; destination = 'cistern'; sector = 0; amount = 1;
|
||||
}
|
||||
const beforeDeclaration = structuredClone(game);
|
||||
const relocation = transition(() => { act(mover, nativeAction); });
|
||||
const destinationLocation = `${destination}:${sector}`;
|
||||
assert.equal(semutaAdvisorRelocationPlayer(game, mover).forces[from], undefined);
|
||||
assert.equal(semutaAdvisorRelocationPlayer(game, mover).forces[destinationLocation],
|
||||
(semutaAdvisorRelocationPlayer(beforeDeclaration, mover).forces[destinationLocation] ?? 0) + amount);
|
||||
assert.deepEqual(game.players.map(player => [player.id, player.spice, player.reserves, player.tanks, player.shipped, player.moved]),
|
||||
beforeDeclaration.players.map(player => [player.id, player.spice, player.reserves, player.tanks, player.shipped, player.moved]));
|
||||
assert.equal(game.response?.kind, 'advisorFlip'); assert.equal(game.response?.owner, owner);
|
||||
assert.equal(game.response?.automaticAdvisorFlip?.producer, 'specialRelocation');
|
||||
assert.ok(game.pendingAutomaticAdvisorRelocation && game.pendingAutomaticAdvisorFlips?.length === 1);
|
||||
assert.equal(isAdvisor(semutaAdvisorRelocationPlayer(game, owner), territory), true);
|
||||
assert.ok(semutaAdvisorRelocationPlayer(game, owner).advisors?.[territory]?.aloneFlipHold);
|
||||
if (kind === 'ambassador') assert.equal(game.pendingAmbassador?.relocation?.next, 'flip');
|
||||
else assert.equal(game.discoveryEntry?.stage, 'arrival');
|
||||
assert.equal(game.phaseOpening ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingKarama ?? null, null);
|
||||
assert.ok(semutaAdvisorRelocationPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.ok(semutaAdvisorRelocationPlayer(game, base.spender).hand.some(card => card.id === base.cost));
|
||||
assert.deepEqual(semutaAdvisorRelocationInventory(game), base.originalInventory);
|
||||
const source = structuredClone(game), action: Action = { type: 'card', card: base.cost, mode: 'cancel' };
|
||||
return { ...base, game, source, beforeCost: source, kind, advanced: true as const, mover, actor: mover,
|
||||
territory, origin: territory, from, location: from, destination, destinationLocation, to: destinationLocation,
|
||||
sector, amount, event, token, action, nativeAction,
|
||||
originalResponse: structuredClone(game.response!),
|
||||
originalAutomaticSource: structuredClone(game.response!.automaticAdvisorFlip!),
|
||||
originalParent: structuredClone(game.pendingAutomaticAdvisorRelocation!),
|
||||
originalQueue: structuredClone(game.pendingAutomaticAdvisorFlips!),
|
||||
originalAmbassador: structuredClone(game.pendingAmbassador ?? null),
|
||||
originalDiscoveryEntry: structuredClone(game.discoveryEntry ?? null),
|
||||
originalHeldStance: structuredClone(semutaAdvisorRelocationPlayer(game, owner).advisors![territory]),
|
||||
originalPhysical: structuredClone(semutaAdvisorRelocationPhysical(game)), originalHistory: structuredClone(game.log),
|
||||
originalAmbassadorToken: structuredClone(game.ecazAmbassadors?.tokens.find(candidate => candidate.id === token) ?? null),
|
||||
originalDiscoveryToken: structuredClone(game.discoveries?.tokens.find(candidate => candidate.id === token) ?? null),
|
||||
acquired: paidSemuta.acquired, acquisition: paidSemuta.acquisition, beforeDeclaration, relocation,
|
||||
placement, shipment, offer, trigger, tokenPlacement, tokenScalar, intrusion, revelation,
|
||||
sourceActions: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
kind === 'ambassador'
|
||||
? 'Original narrow Advanced Ecaz Semuta initializer on ready undealt R/C/Ecaz/BG/A, exact CHOAM+Ecaz and no optional modules. The actual final Traitor choice consumes nine selected original packet scalars to earn Fremen supply; no token is installed.'
|
||||
: 'Actual lobby Discovery moduleOption, then original Advanced Semuta Discovery initializer on ready undealt R/C/BG/A/E, exact CHOAM and only Discoveries. No fresh profile/start gate is added.',
|
||||
'Select original unopened Treachery positions so the stopper is really dealt printed Karama. CHOAM actually buys original cache Semuta for one spice; no played hand, wallet or physical card is assigned.',
|
||||
kind === 'ambassador'
|
||||
? 'Actual Ecaz setup six in Basin10 and BG advisorSetup one beside them. Native first Storm, dual Spice, Charity, auction and Revival run. Ecaz actually pays one to place Fremen at Carthag; A really ships one there and Ecaz triggers the original token for itself.'
|
||||
: 'Actual BG advisorSetup at empty Gara Kulon8 is initially a fighter. The original Hagga Basin Discovery Spice Card and a real selected supply-token draw place Cistern there. A later pays two to ship one beside BG; actual accepted Intrusion and allowed original flip make BG an advisor, and accompaniment is declined.',
|
||||
kind === 'ambassador'
|
||||
? 'Actually relocate all six original Basin counters to empty Tueks5, leaving BG alone. The consumed token, original paid A shipment and Ambassador arrival child remain native while conversion is pending.'
|
||||
: 'At actual Collection A inspects then reveals Cistern. At the original next-turn free-entry chooser A really moves its one non-advisor parent counter into Cistern0, leaving BG alone. No fake reveal, free-entry frame or played clock is installed.',
|
||||
'Stop at the original specialRelocation automatic advisorFlip before spending the original printed Karama. Finish only that Ambassador/Discovery parent once and stop before unrelated future movement or Storm readiness.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type SemutaAdvisorRelocationFixture = ReturnType<typeof createSemutaAdvisorRelocationFixture>;
|
||||
@@ -0,0 +1,178 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, initializeSemutaGameForAudit, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
import {
|
||||
actSemutaCombatCapabilities, nextSemutaCombatCapabilities,
|
||||
semutaCombatCapabilitiesPlayer, semutaCombatCapabilitiesInventory, semutaCombatCapabilitiesClean,
|
||||
passSemutaCombatCapabilities, claimSemutaCombatCapabilities,
|
||||
} from './fixture-semuta-combat-capabilities';
|
||||
|
||||
export type SemutaSpiritualArrivalStep = { actor: string; action: Action };
|
||||
export const actSemutaSpiritualArrival = actSemutaCombatCapabilities;
|
||||
export const semutaSpiritualArrivalPlayer = semutaCombatCapabilitiesPlayer;
|
||||
export const semutaSpiritualArrivalInventory = semutaCombatCapabilitiesInventory;
|
||||
export const semutaSpiritualArrivalClean = semutaCombatCapabilitiesClean;
|
||||
export const passSemutaSpiritualArrival = passSemutaCombatCapabilities;
|
||||
export const claimSemutaSpiritualArrival = claimSemutaCombatCapabilities;
|
||||
export const semutaSpiritualArrivalPhysical = (game: Game) => ({
|
||||
turn: game.turn, phase: game.phase, storm: game.storm, active: game.active, order: game.order,
|
||||
ready: game.ready, movementRemaining: game.movementRemaining, spice: game.spice,
|
||||
players: game.players.map(p => ({ id: p.id, spice: p.spice, forces: p.forces, reserves: p.reserves,
|
||||
tanks: p.tanks, elites: p.elites, leaders: p.leaders, ally: p.ally, shipped: p.shipped, moved: p.moved })),
|
||||
});
|
||||
|
||||
/** Native quiet controls only. The free packet chooser, distinct shipping
|
||||
* counter and mandatory conversion remain explicit source/caller actions. */
|
||||
export function nextSemutaSpiritualArrival(game: Game): SemutaSpiritualArrivalStep | null {
|
||||
if (game.response?.automaticAdvisorFlip) return null;
|
||||
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
|
||||
if (game.status === 'setup' && game.setupStage === 'prediction')
|
||||
return { actor: 'beneGesserit', action: { type: 'predict', faction: 'atreides', turn: 10 } };
|
||||
const bg = game.players.find(p => p.faction === 'beneGesserit');
|
||||
if (game.status === 'setup' && game.setupStage === 'forces' && bg && !bg.advisorSetup)
|
||||
return { actor: bg.id, action: { type: 'advisorSetup', territory: 'arrakeen', sector: 10 } };
|
||||
const d = game.decision;
|
||||
if (d?.kind === 'advisor' || d?.kind === 'intrusion') return null;
|
||||
if (d?.kind === 'advisorBattle') return { actor: d.player, action: { type: 'decision', accept: false } };
|
||||
}
|
||||
return nextSemutaCombatCapabilities(game);
|
||||
}
|
||||
export const nextSemutaSpiritualArrivalStep = nextSemutaSpiritualArrival;
|
||||
export function advanceSemutaSpiritualArrival(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaSpiritualArrivalStep[] = []): Game {
|
||||
for (let count = 0; count < 2400; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const next = nextSemutaSpiritualArrival(game);
|
||||
assert.ok(next, `Supply the real Spiritual choice at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actSemutaSpiritualArrival(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The native Spiritual arrival programme did not reach its finite boundary');
|
||||
}
|
||||
export function allowSemutaSpiritualArrival(game: Game, actions: SemutaSpiritualArrivalStep[] = []): Game {
|
||||
assert.ok(game.response?.automaticAdvisorFlip?.producer === 'spiritualArrival');
|
||||
for (let count = 0; game.response?.automaticAdvisorFlip?.producer === 'spiritualArrival' && count < game.players.length; count++) {
|
||||
const p = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(p);
|
||||
game = actSemutaSpiritualArrival(game, p.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.notEqual(game.response?.automaticAdvisorFlip?.producer, 'spiritualArrival'); return game;
|
||||
}
|
||||
/** Finish only the free arrival and original paid queued entry. Leave the
|
||||
* actual Atreides combined turn and later endMovement to the caller. */
|
||||
export function finishSemutaSpiritualArrival(game: Game, actions: SemutaSpiritualArrivalStep[] = []): Game {
|
||||
assert.ok(!game.pendingTreacheryDiscard);
|
||||
return advanceSemutaSpiritualArrival(game, state => semutaSpiritualArrivalClean(state) &&
|
||||
!state.pendingSpiritualAdvisor && !state.pendingAutomaticAdvisorFlips?.length && !state.pendingArrivalReaction?.length, actions);
|
||||
}
|
||||
|
||||
/** Real paid ordinary Atreides entry and declared BG Polar packet. No played
|
||||
* stock, advisor stance, source frame, queue, lock or counter is installed. */
|
||||
export function createSemutaSpiritualArrivalFixture() {
|
||||
const richese = 'richese', holder = 'choam', owner = 'beneGesserit', shipper = 'atreides', spender = 'emperor';
|
||||
const territory = 'polar_sink', sector = 0, location = 'polar_sink:0';
|
||||
const paidTerritory = 'tueks_sietch', paidSector = 5, paidLocation = 'tueks_sietch:5';
|
||||
let game = createGame('SEMUTASPIRITUALARRIVAL', newPlayer(richese, 'Richese', 'richese'), true, ['choam']);
|
||||
for (const faction of ['choam', 'beneGesserit', 'atreides', 'emperor'] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaSpiritualArrivalStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaSpiritualArrival(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaSpiritualArrival(game, until, actions); };
|
||||
const transition = (run: () => void) => {
|
||||
const before = structuredClone(game), at = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) };
|
||||
};
|
||||
for (const p of game.players) act(p.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
|
||||
const setup = structuredClone(game);
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.equal(game.semutaPreview, true);
|
||||
assert.ok(game.players.every(p => p.hand.length === 0));
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
const originalInventory = semutaSpiritualArrivalInventory(game); assert.equal(originalInventory.length, 45);
|
||||
const printed = game.deck.find(card => card.effect === 'karama'); assert.ok(printed);
|
||||
const ordinary = game.deck.filter(card => card.kind === 'projectile' || card.kind === 'poison').slice(0, 4);
|
||||
assert.equal(ordinary.length, 4); const selected = [...ordinary, printed];
|
||||
const dealOrder = selected.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { card: card.id, position, from };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game), cost = printed.id;
|
||||
const setupChoices = transition(() => { reach(state => state.status === 'playing'); });
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.equal(semutaSpiritualArrivalPlayer(game, owner).forces['arrakeen:10'], 1);
|
||||
assert.ok(semutaSpiritualArrivalPlayer(game, owner).advisors?.arrakeen);
|
||||
assert.equal(semutaSpiritualArrivalPlayer(game, shipper).forces['arrakeen:10'], 10);
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder = [0, 1].map(position => {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
|
||||
return { position, from, card: structuredClone(card) };
|
||||
});
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
let beforeBid: Game | undefined, bid: SemutaSpiritualArrivalStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 1000 && !semutaSpiritualArrivalPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const next = nextSemutaSpiritualArrival(game); assert.ok(next);
|
||||
if (next.actor === holder && next.action.type === 'richeseBid' && next.action.amount === 1) {
|
||||
beforeBid = structuredClone(game); bid = structuredClone(next); bidAt = actions.length;
|
||||
}
|
||||
act(next.actor, next.action);
|
||||
}
|
||||
assert.ok(beforeBid && bid && bidAt >= 0);
|
||||
const acquired = structuredClone(game), acquisition = { before: beforeBid, bid,
|
||||
steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 };
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === shipper && semutaSpiritualArrivalClean(state));
|
||||
assert.ok(game.players.every(p => (p.forces[location] ?? 0) === 0));
|
||||
const shipmentAction: Action = { type: 'ship', territory: paidTerritory, sector: paidSector, amount: 1, elite: 0, allyPayment: 0 };
|
||||
const shipment = transition(() => {
|
||||
act(shipper, shipmentAction);
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'advisor');
|
||||
});
|
||||
assert.ok(game.decision?.kind === 'advisor'); assert.equal(game.decision.shipment, shipper);
|
||||
assert.equal(game.decision.destination, paidLocation);
|
||||
assert.equal(semutaSpiritualArrivalPlayer(shipment.before, shipper).spice - semutaSpiritualArrivalPlayer(game, shipper).spice, 1);
|
||||
const chooseAction: Action = { type: 'decision', accept: true, accompany: false, amount: 1 };
|
||||
const declaration = transition(() => { act(owner, chooseAction); });
|
||||
assert.ok(game.response?.kind === 'advisor' && game.response.spiritualAdvisorSource);
|
||||
const shippingResponse = structuredClone(game.response), shippingSource = structuredClone(game.response.spiritualAdvisorSource);
|
||||
const shippingFrame = structuredClone(game.pendingSpiritualAdvisor!);
|
||||
assert.equal(game.pendingSpiritualAdvisor?.stage, 'shipping');
|
||||
assert.equal(semutaSpiritualArrivalPlayer(game, owner).forces[location] ?? 0, 0);
|
||||
const placed = transition(() => {
|
||||
for (let count = 0; game.response?.kind === 'advisor' && count < game.players.length; count++) {
|
||||
const p = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(p);
|
||||
act(p.id, { type: 'passResponse' });
|
||||
}
|
||||
});
|
||||
assert.ok(game.response?.automaticAdvisorFlip?.producer === 'spiritualArrival');
|
||||
const automatic = game.response.automaticAdvisorFlip;
|
||||
assert.deepEqual(automatic.spiritual, shippingSource); assert.deepEqual(automatic.forces, { [location]: 1 });
|
||||
assert.equal(game.pendingSpiritualAdvisor?.stage, 'arrival');
|
||||
assert.equal(semutaSpiritualArrivalPlayer(game, owner).forces[location], 1);
|
||||
assert.equal(semutaSpiritualArrivalPlayer(game, owner).reserves, semutaSpiritualArrivalPlayer(declaration.before, owner).reserves - 1);
|
||||
assert.equal(semutaSpiritualArrivalPlayer(game, owner).advisors?.[territory]?.lockedTurn, game.turn);
|
||||
assert.equal(semutaSpiritualArrivalPlayer(game, owner).advisors?.[territory]?.aloneFlipHold, automatic.event);
|
||||
assert.deepEqual(semutaSpiritualArrivalInventory(game), originalInventory);
|
||||
return { game, kind: 'advisorFlip' as const, advanced: true as const, richese, holder, owner, shipper, spender,
|
||||
cost, karama: cost, semuta: SEMUTA_DRUG_ID, action: { type: 'card', card: cost, mode: 'cancel' } as Action,
|
||||
originalResponse: structuredClone(game.response), originalAutomaticSource: structuredClone(automatic),
|
||||
originalFrame: structuredClone(game.pendingSpiritualAdvisor!), originalQueue: structuredClone(game.pendingAutomaticAdvisorFlips!),
|
||||
originalPaidParent: structuredClone(game.pendingArrivalReaction!), originalPhysical: structuredClone(semutaSpiritualArrivalPhysical(game)),
|
||||
originalStance: structuredClone(semutaSpiritualArrivalPlayer(game, owner).advisors!),
|
||||
originalLocks: structuredClone(semutaSpiritualArrivalPlayer(game, owner).advisors?.[territory]),
|
||||
originalDeck: structuredClone(game.deck), territory, sector, location, paidTerritory, paidSector, paidLocation,
|
||||
shipmentAction, chooseAction, shippingResponse, shippingSource, shippingFrame,
|
||||
initial, lobby, setup, setupChoices, beforeStartingDeal, afterSetup, dealOrder, deal: selected.map(card => card.id),
|
||||
originalInventory, originalSpice, spiceOrder, acquisition, acquired, cache: acquisition, shipment,
|
||||
declaration, placed, actions, steps: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Original undealt Advanced R/C/BG/A/E Semuta initializer with CHOAM only and no optional module. Actual prediction/private Traitors/advisorSetup place BG1 beside original A10 at Arrakeen. Only unopened card/Spice positions and native scalar lotteries are selected; C buys cache1 Semuta and E receives printed Karama.',
|
||||
'Native BG preparation declines and unused turns end through actual controls. A really pays1 for one ordinary reserve into empty Tuek’s5, committing its force, payment and shipment allowance before the independent Spiritual choice. The original paid arrival queue remains native.',
|
||||
'BG actually chooses its free one-counter Polar Sink packet. Every distinct shipping-counter response is really passed. Empty Polar has no residents to match; the engine actually withdraws BG1, places it advisor-side, locks it for this turn and opens mandatory spiritualArrival conversion with the exact new hold.',
|
||||
'Stop before the separate printed conversion cost. No reserve, stance, lock, clock, card, source/frame, original arrival or Semuta window is installed; the original paid entry waits while its actual free child completes.',
|
||||
] };
|
||||
}
|
||||
export type SemutaSpiritualArrivalFixture = ReturnType<typeof createSemutaSpiritualArrivalFixture>;
|
||||
@@ -0,0 +1,196 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, initializeSemutaGameForAudit, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import { isAdvisor } from '../game/advisors';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
import {
|
||||
actSemutaAdvisorMatching, nextSemutaAdvisorMatching, passSemutaAdvisorMatching, claimSemutaAdvisorMatching,
|
||||
semutaAdvisorMatchingPlayer, semutaAdvisorMatchingInventory,
|
||||
} from './fixture-semuta-advisor-matching';
|
||||
|
||||
export type SemutaSpiritualCostStage = 'shipping' | 'matching';
|
||||
export type SemutaSpiritualCostStep = { actor: string; action: Action };
|
||||
export const actSemutaSpiritualCost = actSemutaAdvisorMatching;
|
||||
export const passSemutaSpiritualCost = passSemutaAdvisorMatching;
|
||||
export const claimSemutaSpiritualCost = claimSemutaAdvisorMatching;
|
||||
export const semutaSpiritualCostPlayer = semutaAdvisorMatchingPlayer;
|
||||
export const semutaSpiritualCostInventory = semutaAdvisorMatchingInventory;
|
||||
export const semutaSpiritualCostClean = (game: Game) => !game.response && !game.phaseOpening && !game.decision &&
|
||||
!game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingSpiritualAdvisor && !game.pendingArrivalReaction?.length;
|
||||
export const semutaSpiritualCostPhysical = (game: Game) => game.players.map(p => ({ id: p.id, spice: p.spice,
|
||||
forces: p.forces, elites: p.elites, reserves: p.reserves, tanks: p.tanks, advisors: p.advisors,
|
||||
shipped: p.shipped, moved: p.moved, revived: p.revived, freeForcesRevived: p.freeForcesRevived }));
|
||||
|
||||
/** Original Spiritual chooser, shipping/matching responses and fresh costs
|
||||
* remain explicit choices. No native opportunity is silently declined. */
|
||||
export function nextSemutaSpiritualCost(game: Game): SemutaSpiritualCostStep | null {
|
||||
if (game.pendingTreacheryDiscard || game.truthtrance || game.pendingSpiritualAdvisor ||
|
||||
game.response?.spiritualAdvisorSource || game.response?.automaticAdvisorFlip) return null;
|
||||
if (!game.response && !game.phaseOpening &&
|
||||
(game.decision?.kind === 'advisor' || game.decision?.kind === 'intrusion')) return null;
|
||||
return nextSemutaAdvisorMatching(game);
|
||||
}
|
||||
export const nextSemutaSpiritualCostStep = nextSemutaSpiritualCost;
|
||||
export function advanceSemutaSpiritualCost(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaSpiritualCostStep[] = []): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextSemutaSpiritualCost(game);
|
||||
assert.ok(step, `Choose the original Spiritual/fresh control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actSemutaSpiritualCost(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The native Semuta/Spiritual programme did not reach its finite boundary');
|
||||
}
|
||||
function responseStage(game: Game) {
|
||||
const response = game.response;
|
||||
if (response?.spiritualAdvisorSource) return JSON.stringify([response.kind, response.spiritualAdvisorSource]);
|
||||
if (response?.automaticAdvisorFlip?.producer === 'spiritualArrival') return JSON.stringify(response.automaticAdvisorFlip);
|
||||
return null;
|
||||
}
|
||||
/** Explicitly allow just this original layer; a later matching or automatic
|
||||
* conversion stays owned until another explicit call. */
|
||||
export function allowSemutaSpiritualCost(game: Game, actions: SemutaSpiritualCostStep[] = []): Game {
|
||||
const stage = responseStage(game); assert.ok(stage);
|
||||
for (let count = 0; responseStage(game) === stage && count < game.players.length; count++) {
|
||||
const response: NonNullable<Game['response']> = game.response!;
|
||||
const actor = game.players.find(p => !response.passed.includes(p.id)); assert.ok(actor);
|
||||
game = actSemutaSpiritualCost(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.notEqual(responseStage(game), stage); return game;
|
||||
}
|
||||
/** Stop at the restored matching chooser, or at the same active foreign turn
|
||||
* after this original arrival queue has drained. No turn end is invented. */
|
||||
export function finishSemutaSpiritualCost(game: Game, actions: SemutaSpiritualCostStep[] = []): Game {
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null, 'Choose the exact fresh cost first');
|
||||
return advanceSemutaSpiritualCost(game, state => semutaSpiritualCostClean(state) ||
|
||||
(!state.response && !state.phaseOpening && state.decision?.kind === 'advisor' &&
|
||||
state.pendingSpiritualAdvisor?.stage === 'offer'), actions);
|
||||
}
|
||||
|
||||
/** Genuine fresh Advanced R/C/BG/A/E. Only unopened physical card/Spice
|
||||
* positions and original setup lotteries are selected. */
|
||||
export function createSemutaSpiritualCostFixture(options: { stage?: SemutaSpiritualCostStage } = {}) {
|
||||
const stage = options.stage ?? 'shipping', seller = 'richese', holder = 'choam', owner = 'beneGesserit';
|
||||
const shipper = 'atreides', spender = 'emperor';
|
||||
const setupTerritory = stage === 'shipping' ? 'arrakeen' : 'polar_sink', setupSector = stage === 'shipping' ? 10 : 0;
|
||||
const territory = stage === 'shipping' ? 'arrakeen' : 'polar_sink', sector = stage === 'shipping' ? 10 : 0;
|
||||
const location = `${territory}:${sector}`, foreignDestination = stage === 'shipping' ? 'arrakeen' : 'tueks_sietch';
|
||||
const foreignSector = stage === 'shipping' ? 10 : 5, foreignLocation = `${foreignDestination}:${foreignSector}`;
|
||||
let game = createGame('SEMUTASPIRITUALCOST', newPlayer(seller, 'Richese', 'richese'), true, ['choam']);
|
||||
for (const faction of ['choam', 'beneGesserit', 'atreides', 'emperor'] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaSpiritualCostStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaSpiritualCost(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaSpiritualCost(game, until, actions); };
|
||||
const transition = (run: () => void) => {
|
||||
const before = structuredClone(game), at = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) };
|
||||
};
|
||||
for (const p of game.players) act(p.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
|
||||
const setup = structuredClone(game), initialization = { before: lobby, after: setup,
|
||||
entry: 'original-undealt-advanced-semuta-preview' };
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.equal(game.semutaPreview, true);
|
||||
assert.ok(game.players.every(p => !p.hand.length));
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
const originalInventory = semutaSpiritualCostInventory(game); assert.equal(originalInventory.length, 45);
|
||||
const printed = game.deck.find(card => card.effect === 'karama'); assert.ok(printed);
|
||||
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 4);
|
||||
assert.equal(ordinary.length, 4);
|
||||
const selected = [...ordinary, printed], dealOrder = selected.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: card.id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
let advisorSetup: { before: Game; steps: SemutaSpiritualCostStep[]; after: Game } | null = null;
|
||||
for (let count = 0; game.status === 'setup' && count < 100; count++) {
|
||||
const step = nextSemutaSpiritualCost(game); assert.ok(step);
|
||||
if (step.action.type === 'advisorSetup') step.action = { type: 'advisorSetup', territory: setupTerritory, sector: setupSector };
|
||||
const before = step.action.type === 'advisorSetup' ? structuredClone(game) : null;
|
||||
act(step.actor, step.action);
|
||||
if (before) advisorSetup = { before, steps: [structuredClone(step)], after: structuredClone(game) };
|
||||
}
|
||||
assert.equal(game.status, 'playing'); assert.ok(advisorSetup);
|
||||
const afterSetup = structuredClone(game), cost = printed.id;
|
||||
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.equal(semutaSpiritualCostPlayer(game, owner).forces[location], 1);
|
||||
assert.equal(isAdvisor(semutaSpiritualCostPlayer(game, owner), territory), stage === 'shipping');
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
|
||||
for (const [position, face] of ['funeral_plain', 'hagga_basin'].entries()) {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && card.territory === face && !card.discovery);
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
|
||||
spiceOrder.push({ position, from, card: structuredClone(card) });
|
||||
}
|
||||
let beforeBid: Game | undefined, bid: SemutaSpiritualCostStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 900 && !semutaSpiritualCostPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const step = nextSemutaSpiritualCost(game); assert.ok(step);
|
||||
if (step.actor === holder && step.action.type === 'richeseBid' && step.action.amount === 1) {
|
||||
beforeBid = structuredClone(game); bid = structuredClone(step); bidAt = actions.length;
|
||||
}
|
||||
act(step.actor, step.action);
|
||||
}
|
||||
assert.ok(beforeBid && bid && bidAt >= 0);
|
||||
const acquired = structuredClone(game), acquisition = { before: beforeBid, bid,
|
||||
steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 };
|
||||
assert.equal(semutaSpiritualCostPlayer(beforeBid, holder).spice - semutaSpiritualCostPlayer(game, holder).spice, 1);
|
||||
const bgTurn = transition(() => {
|
||||
reach(state => state.phase === 5 && state.active === owner && semutaSpiritualCostClean(state));
|
||||
act(owner, { type: 'endMovement' });
|
||||
reach(state => state.phase === 5 && state.active === shipper && semutaSpiritualCostClean(state));
|
||||
});
|
||||
const paidShipmentAction: Action = { type: 'ship', territory: foreignDestination, sector: foreignSector,
|
||||
amount: 1, elite: 0, allyPayment: 0 };
|
||||
const paidParent = transition(() => {
|
||||
act(shipper, paidShipmentAction);
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'advisor');
|
||||
});
|
||||
assert.equal(semutaSpiritualCostPlayer(paidParent.before, shipper).spice - semutaSpiritualCostPlayer(game, shipper).spice, 1);
|
||||
assert.equal(semutaSpiritualCostPlayer(game, shipper).forces[foreignLocation], stage === 'shipping' ? 11 : 1);
|
||||
assert.ok(game.decision?.kind === 'advisor'); assert.equal(game.decision.player, owner);
|
||||
assert.equal(game.decision.shipment, shipper); assert.equal(game.decision.destination, foreignLocation);
|
||||
const beforeDeclaration = structuredClone(game), originalChooser = structuredClone(game.decision);
|
||||
const accompany: Action = { type: 'decision', accept: true, accompany: true, amount: 1 };
|
||||
const sink: Action = { type: 'decision', accept: true, accompany: false, amount: 1 };
|
||||
const decline: Action = { type: 'decision', accept: false }, nativeAction = stage === 'shipping' ? accompany : sink;
|
||||
const declaration = transition(() => { act(owner, nativeAction); });
|
||||
assert.equal(game.response?.kind, 'advisor'); assert.ok(game.response?.spiritualAdvisorSource);
|
||||
assert.equal(game.pendingSpiritualAdvisor?.stage, 'shipping');
|
||||
const shipping = structuredClone(game);
|
||||
const shippingAllowance = stage === 'matching'
|
||||
? transition(() => { game = allowSemutaSpiritualCost(game, actions); }) : null;
|
||||
assert.equal(game.response?.kind, stage === 'shipping' ? 'advisor' : 'advisorFlip');
|
||||
assert.equal(game.pendingSpiritualAdvisor?.stage, stage); assert.ok(game.response?.spiritualAdvisorSource && game.pendingSpiritualAdvisor);
|
||||
assert.deepEqual(semutaSpiritualCostPhysical(game), semutaSpiritualCostPhysical(beforeDeclaration));
|
||||
assert.ok(semutaSpiritualCostPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.ok(semutaSpiritualCostPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
const source = structuredClone(game), originalResponse = structuredClone(game.response);
|
||||
const originalFrame = structuredClone(game.pendingSpiritualAdvisor), originalSource = structuredClone(game.response.spiritualAdvisorSource);
|
||||
const originalParent = structuredClone(game.pendingArrivalReaction ?? null), originalArrival = structuredClone(originalParent?.find(arrival => arrival.entrant === shipper));
|
||||
assert.ok(originalParent?.length && originalArrival, 'Keep the actual paid foreign arrival queued behind the Spiritual choice');
|
||||
const action: Action = { type: 'card', card: cost, mode: 'cancel' };
|
||||
assert.deepEqual(semutaSpiritualCostInventory(game), originalInventory);
|
||||
return { game, source, beforeCost: source, stage, kind: originalResponse.kind, advanced: true as const,
|
||||
owner, bg: owner, recipient: owner, holder, spender, seller, richese: seller, shipper, atreides: shipper, emperor: spender,
|
||||
cost, karama: cost, semuta: SEMUTA_DRUG_ID, action, nativeAction, accompany, sink, decline,
|
||||
replacementAction: stage === 'matching' ? accompany : null, territory, sector, location,
|
||||
foreignDestination, foreignSector, foreignLocation, paidShipmentAction, paidParent, foreignCost: 1,
|
||||
originalResponse, originalFrame, originalSource, originalChooser, originalParent, originalArrival,
|
||||
originalPhysical: structuredClone(semutaSpiritualCostPhysical(game)),
|
||||
initial, lobby, setup, initialization, beforeStartingDeal, afterSetup, advisorSetup,
|
||||
deal: selected.map(card => card.id), dealOrder, originalInventory, originalSpice, spiceOrder,
|
||||
acquisition, acquired, bgTurn, beforeDeclaration, declaration, shipping, shippingAllowance,
|
||||
actions, steps: actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Original Advanced Semuta initializer on the ready undealt R/C/BG/A/E lobby, exact CHOAM/main35/cache10 and no optional modules. Only unopened original starting-card/Spice positions and original scalar lotteries are selected. Emperor actually receives the printed Karama; CHOAM really purchases cache1 Semuta for one.',
|
||||
stage === 'shipping'
|
||||
? 'BG actually places its original advisor at Arrakeen10 beside native A10, declines preparation and ends movement. Atreides really pays one to ship one reserve there, then BG really declares accompaniment. Stop at the original shipping response before BG withdraws a reserve.'
|
||||
: 'BG actually places its original counter in empty Polar0, which native setup makes a fighter, then ends its real movement turn. Atreides really pays one to ship one reserve to empty Tueks5. BG really declares the Polar fallback and passes its original shipping counter; stop at the separate native matching response before withdrawal or placement.',
|
||||
'The paid foreign shipment, payment, shipped flag and queued arrival are original completed effects. No played hand, force, stance, wallet, clock, receipt or parent is installed. Shipping prevention ends the free packet; matching prevention restores the original chooser and blocks this exact Polar packet. Its real Tueks accompaniment or decline remains available; without Homeworlds no two-counter replacement is claimed.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type SemutaSpiritualCostFixture = ReturnType<typeof createSemutaSpiritualCostFixture>;
|
||||
@@ -0,0 +1,189 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { isAdvisor } from '../game/advisors';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
createSemutaAdvisorRelocationFixture as fixture, actSemutaAdvisorRelocation as act,
|
||||
passSemutaAdvisorRelocation as pass, claimSemutaAdvisorRelocation as claim,
|
||||
allowSemutaAdvisorRelocation as allow, finishSemutaAdvisorRelocation as finish,
|
||||
semutaAdvisorRelocationPlayer as player, semutaAdvisorRelocationInventory as inventory,
|
||||
semutaAdvisorRelocationPhysical as physical, type SemutaAdvisorRelocationFixture,
|
||||
} from './fixture-semuta-advisor-relocation';
|
||||
|
||||
const relocationLogs = (game: Game, f: SemutaAdvisorRelocationFixture) => game.log.filter(row => f.kind === 'ambassador'
|
||||
? row.automatic?.name === 'Fremen Ambassador relocation'
|
||||
: row.text.includes('This one-time Discovery entry spends no spice or normal movement.')).length;
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function nativeSource(f: SemutaAdvisorRelocationFixture) {
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0));
|
||||
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
|
||||
assert.ok(f.advisorSetup); assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'predict'));
|
||||
assert.equal(f.acquisition.price, 1);
|
||||
assert.ok(f.acquisition.steps.some(step => step.actor === f.holder && step.action.type === 'richeseBid' && step.action.amount === 1));
|
||||
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquisition.after, f.holder).spice, 1);
|
||||
assert.equal(player(f.acquisition.after, f.richese).spice - player(f.acquisition.before, f.richese).spice, 1);
|
||||
if (f.kind === 'ambassador') {
|
||||
assert.deepEqual(f.game.players.map(player => player.faction), ['richese', 'choam', 'ecaz', 'beneGesserit', 'atreides']);
|
||||
assert.deepEqual(f.setup.expansions, ['choam', 'ecaz']); assert.ok(!f.setup.discoveryEnabled);
|
||||
assert.ok(f.packet && f.packetCreation && f.placement && f.trigger && f.originalAmbassador && f.originalAmbassadorToken);
|
||||
assert.equal(f.packet.scalars.length, 9);
|
||||
assert.deepEqual(f.packetCreation.after.ecazAmbassadors!.cohort.map(id =>
|
||||
f.packetCreation!.after.ecazAmbassadors!.tokens.find(token => token.id === id)!.effect), f.packet.supply);
|
||||
assert.equal(player(f.afterSetup, f.mover).forces[f.from], 6);
|
||||
assert.equal(isAdvisor(player(f.afterSetup, f.owner), f.origin), true);
|
||||
assert.equal(player(f.placement.before, f.mover).spice - player(f.placement.after, f.mover).spice, 1);
|
||||
assert.equal(f.originalAmbassador.effect, 'fremen'); assert.equal(f.originalAmbassador.beneficiary, f.mover);
|
||||
assert.equal(f.originalAmbassador.relocation?.next, 'flip');
|
||||
assert.equal(f.originalAmbassadorToken.effect, 'fremen'); assert.equal(f.originalAmbassadorToken.zone, 'used');
|
||||
assert.equal(f.originalDiscoveryEntry, null);
|
||||
} else {
|
||||
assert.deepEqual(f.game.players.map(player => player.faction), ['richese', 'choam', 'beneGesserit', 'atreides', 'emperor']);
|
||||
assert.deepEqual(f.setup.expansions, ['choam']); assert.equal(f.setup.discoveryEnabled, true);
|
||||
assert.ok(f.configured.discoveryEnabled && f.tokenPlacement && f.intrusion && f.revelation && f.originalDiscoveryEntry);
|
||||
assert.equal(isAdvisor(player(f.afterSetup, f.owner), f.origin), false);
|
||||
assert.equal(player(f.shipment.before, f.atreides).spice - player(f.intrusion.after, f.atreides).spice, 2);
|
||||
assert.equal(isAdvisor(player(f.intrusion.after, f.owner), f.origin), true);
|
||||
assert.deepEqual(f.revelation.steps.map(step => step.action), [
|
||||
{ type: 'discovery', token: f.token, reveal: false }, { type: 'discovery', token: f.token, reveal: true },
|
||||
]);
|
||||
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 0);
|
||||
assert.equal(f.originalDiscoveryEntry.stage, 'arrival');
|
||||
assert.equal(f.originalDiscoveryEntry.arrival?.owner, f.mover);
|
||||
assert.deepEqual(f.originalDiscoveryEntry.arrival?.groups, [{ source: f.from, normal: 1, elite: 0 }]);
|
||||
assert.equal(f.originalAmbassador, null); assert.equal(f.originalDiscoveryToken?.status, 'placed');
|
||||
assert.equal(f.originalDiscoveryToken?.revealedTurn, f.revelation.after.turn);
|
||||
}
|
||||
assert.deepEqual(f.relocation.steps, [{ actor: f.mover, action: f.nativeAction }]);
|
||||
assert.equal(player(f.game, f.mover).forces[f.from], undefined);
|
||||
assert.equal(player(f.game, f.mover).forces[f.to], (player(f.beforeDeclaration, f.mover).forces[f.to] ?? 0) + f.amount);
|
||||
assert.deepEqual(f.game.response, f.originalResponse);
|
||||
assert.ok(f.originalAutomaticSource.producer === 'specialRelocation');
|
||||
assert.equal(f.originalAutomaticSource.relocation.kind, f.kind);
|
||||
assert.equal(f.originalAutomaticSource.relocation.player, f.mover);
|
||||
assert.equal(f.originalAutomaticSource.relocation.origin, f.origin);
|
||||
assert.equal(f.originalAutomaticSource.relocation.destination, f.destination);
|
||||
assert.deepEqual(f.originalQueue, [f.originalAutomaticSource]);
|
||||
assert.equal(f.originalHeldStance.aloneFlipHold, f.originalAutomaticSource.event);
|
||||
assert.equal(isAdvisor(player(f.game, f.owner), f.origin), true);
|
||||
assert.equal(relocationLogs(f.game, f) - relocationLogs(f.beforeDeclaration, f), 1);
|
||||
assert.deepEqual(physical(f.game), f.originalPhysical);
|
||||
assert.equal(f.sourceActionCount, f.actions.length); assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
}
|
||||
function open(f: SemutaAdvisorRelocationFixture) {
|
||||
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
|
||||
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaAdvisorFlipDiscard');
|
||||
assert.equal(pending.reaction?.stage, 'offer'); assert.equal(pending.batch.cause, 'karama:advisorFlip');
|
||||
assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, face: entry.publicFace })),
|
||||
[{ card: f.cost, by: f.spender, face: true }]);
|
||||
if (pending.continuation.kind !== 'karamaAdvisorFlipDiscard') throw Error('Use the original native advisor-flip cost');
|
||||
assert.deepEqual(pending.continuation.use.response, f.originalResponse);
|
||||
assert.deepEqual(pending.continuation.resume.response, f.originalResponse);
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.deepEqual(game.pendingAutomaticAdvisorRelocation, f.originalParent);
|
||||
assert.deepEqual(game.pendingAutomaticAdvisorFlips, f.originalQueue);
|
||||
assert.deepEqual(game.pendingAmbassador ?? null, f.originalAmbassador);
|
||||
assert.deepEqual(game.discoveryEntry ?? null, f.originalDiscoveryEntry);
|
||||
assert.deepEqual(player(game, f.owner).advisors?.[f.origin], f.originalHeldStance);
|
||||
assert.deepEqual(physical(game), f.originalPhysical);
|
||||
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
|
||||
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
function settled(game: Game, f: SemutaAdvisorRelocationFixture, canceled: boolean) {
|
||||
game = finish(game);
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingKarama ?? null, null);
|
||||
assert.equal(game.pendingAutomaticAdvisorRelocation, undefined); assert.equal(game.pendingAutomaticAdvisorFlips, undefined);
|
||||
assert.equal(game.pendingAmbassador ?? null, null); assert.equal(game.discoveryEntry, undefined);
|
||||
assert.equal(game.response?.automaticAdvisorFlip, undefined);
|
||||
assert.deepEqual(physical(game), f.originalPhysical);
|
||||
assert.equal(isAdvisor(player(game, f.owner), f.origin), canceled);
|
||||
if (canceled) assert.deepEqual(player(game, f.owner).advisors?.[f.origin], f.originalHeldStance);
|
||||
else assert.equal(player(game, f.owner).advisors?.[f.origin], undefined);
|
||||
if (f.kind === 'ambassador')
|
||||
assert.deepEqual(game.ecazAmbassadors!.tokens.find(token => token.id === f.token), f.originalAmbassadorToken);
|
||||
else {
|
||||
assert.deepEqual(game.discoveries!.tokens.find(token => token.id === f.token), f.originalDiscoveryToken);
|
||||
assert.deepEqual(game.discoveries!.newlyRevealed, []);
|
||||
}
|
||||
assert.equal(relocationLogs(game, f), relocationLogs(f.game, f));
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
function prevented(game: Game, f: SemutaAdvisorRelocationFixture, claimed: boolean) {
|
||||
game = settled(game, f, true);
|
||||
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed)); return game;
|
||||
}
|
||||
|
||||
for (const kind of ['ambassador', 'discovery'] as const) for (const claimed of [false, true]) void test(
|
||||
`Advanced native ${kind} relocation advisor conversion used cancellation cost ${claimed ? 'claim' : 'pass'} retains the completed transfer and resumes its original child once`, () => {
|
||||
const f = fixture({ kind }); nativeSource(f);
|
||||
const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
const game = prevented(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
reject(game, f.holder, { type: 'semutaCommit', event }); reject(game, f.holder, { type: 'semutaPass', event });
|
||||
const before = structuredClone(game); assert.equal(finish(game), game); assert.deepEqual(game, before);
|
||||
});
|
||||
|
||||
for (const kind of ['ambassador', 'discovery'] as const) void test(
|
||||
`Advanced native ${kind} relocation advisor conversion allowance flips only the stranded group after the original transfer`, () => {
|
||||
const f = fixture({ kind }); nativeSource(f);
|
||||
const game = settled(allow(structuredClone(f.game)), f, false);
|
||||
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
|
||||
});
|
||||
|
||||
for (const kind of ['ambassador', 'discovery'] as const) void test(
|
||||
`fresh ${kind} relocation conversion costs reject stale and underlying controls while the original child stays parked`, () => {
|
||||
const f = fixture({ kind }), game = open(f), event = game.pendingTreacheryDiscard!.batch.event;
|
||||
reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
|
||||
reject(game, f.spender, { type: 'semutaCommit', event });
|
||||
reject(game, f.spender, f.action); reject(game, f.owner, { type: 'passResponse' });
|
||||
reject(game, f.mover, f.nativeAction);
|
||||
assert.deepEqual(game.pendingAutomaticAdvisorRelocation, f.originalParent);
|
||||
assert.deepEqual(game.pendingAmbassador ?? null, f.originalAmbassador);
|
||||
assert.deepEqual(game.discoveryEntry ?? null, f.originalDiscoveryEntry);
|
||||
assert.deepEqual(physical(game), f.originalPhysical); prevented(pass(game), f, false);
|
||||
});
|
||||
|
||||
void test('changed original relocation evidence rejects before cost and before a fresh Semuta answer', () => {
|
||||
for (const kind of ['ambassador', 'discovery'] as const) {
|
||||
const f = fixture({ kind }), beforeCost = structuredClone(f.game);
|
||||
assert.ok(beforeCost.pendingAutomaticAdvisorRelocation);
|
||||
beforeCost.pendingAutomaticAdvisorRelocation.signature = 'different-original-relocation';
|
||||
reject(beforeCost, f.spender, f.action);
|
||||
assert.ok(player(beforeCost, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.equal(beforeCost.discard.some(card => card.id === f.cost), false);
|
||||
const afterCost = open(f); assert.ok(afterCost.pendingAutomaticAdvisorRelocation);
|
||||
afterCost.pendingAutomaticAdvisorRelocation.signature = 'different-original-relocation';
|
||||
reject(afterCost, f.holder, { type: 'semutaCommit', event: afterCost.pendingTreacheryDiscard!.batch.event });
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal policies answer both actual native relocation conversion costs legally', () => {
|
||||
for (const kind of ['ambassador', 'discovery'] as const) {
|
||||
const f = fixture({ kind }), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const own = viewGame(offered, f.holder); own.players.find(player => player.id === f.holder)!.bot = difficulty;
|
||||
const before = structuredClone(own), action = botActions(own)[0]; assert.ok(action, difficulty);
|
||||
assert.deepEqual(own, before);
|
||||
assert.ok(action.type === 'semutaCommit' || action.type === 'semutaPass'); assert.equal(action.event, event);
|
||||
let game = act(structuredClone(offered), f.holder, action);
|
||||
const claimed = action.type === 'semutaCommit';
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
|
||||
const choosing = viewGame(game, f.holder); choosing.players.find(player => player.id === f.holder)!.bot = difficulty;
|
||||
const selected = botActions(choosing)[0]; assert.ok(selected);
|
||||
assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost);
|
||||
game = act(game, f.holder, selected);
|
||||
} else if (game.pendingTreacheryDiscard) game = pass(game);
|
||||
prevented(game, f, claimed);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { fighterCount, isAdvisor } from '../game/advisors';
|
||||
import { quoteAutomaticAdvisorFlips } from '../game/advisor-automatic-flip';
|
||||
import {
|
||||
createSemutaSpiritualArrivalFixture as fixture, actSemutaSpiritualArrival as act,
|
||||
passSemutaSpiritualArrival as pass, claimSemutaSpiritualArrival as claim,
|
||||
allowSemutaSpiritualArrival as allow, finishSemutaSpiritualArrival as finish,
|
||||
advanceSemutaSpiritualArrival as advance, semutaSpiritualArrivalClean as clean,
|
||||
semutaSpiritualArrivalPlayer as player, semutaSpiritualArrivalInventory as inventory,
|
||||
semutaSpiritualArrivalPhysical as physical, type SemutaSpiritualArrivalFixture,
|
||||
} from './fixture-semuta-spiritual-arrival';
|
||||
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
const placementLogs = (game: Game, owner: string) => game.log.filter(row => row.text.startsWith(`${player(game, owner).name} sent 1 free`));
|
||||
function nativeSource(f: SemutaSpiritualArrivalFixture) {
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.deepEqual(f.setup.expansions, ['choam']); assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
|
||||
assert.deepEqual(f.game.players.map(p => p.faction), ['richese', 'choam', 'beneGesserit', 'atreides', 'emperor']);
|
||||
assert.deepEqual(f.afterSetup.players.flatMap(p => p.hand.map(card => card.id)), f.deal);
|
||||
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
|
||||
assert.ok(f.setupChoices.steps.some(step => step.actor === f.owner && step.action.type === 'advisorSetup' &&
|
||||
step.action.territory === 'arrakeen' && step.action.sector === 10));
|
||||
assert.equal(player(f.afterSetup, f.owner).forces['arrakeen:10'], 1);
|
||||
assert.equal(player(f.afterSetup, f.shipper).forces['arrakeen:10'], 10);
|
||||
assert.equal(f.acquisition.bid.actor, f.holder); assert.equal(f.acquisition.bid.action.amount, 1);
|
||||
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquisition.after, f.holder).spice, 1);
|
||||
assert.equal(player(f.acquisition.after, f.richese).spice - player(f.acquisition.before, f.richese).spice, 1);
|
||||
assert.deepEqual(f.shipment.steps[0], { actor: f.shipper, action: f.shipmentAction });
|
||||
assert.equal(player(f.shipment.before, f.shipper).spice - player(f.shipment.after, f.shipper).spice, 1);
|
||||
assert.equal(player(f.shipment.before, f.shipper).reserves - player(f.shipment.after, f.shipper).reserves, 1);
|
||||
assert.equal(player(f.game, f.shipper).forces[f.paidLocation], 1);
|
||||
assert.equal(player(f.game, f.shipper).shipped, true); assert.equal(player(f.game, f.shipper).moved, 0);
|
||||
assert.equal(f.declaration.before.decision?.kind, 'advisor');
|
||||
assert.deepEqual(f.declaration.steps, [{ actor: f.owner, action: f.chooseAction }]);
|
||||
assert.equal(f.shippingResponse.kind, 'advisor'); assert.deepEqual(f.shippingResponse.spiritualAdvisorSource, f.shippingSource);
|
||||
assert.equal(f.shippingFrame.stage, 'shipping'); assert.equal(player(f.declaration.after, f.owner).forces[f.location] ?? 0, 0);
|
||||
assert.equal(player(f.declaration.after, f.owner).reserves, player(f.declaration.before, f.owner).reserves);
|
||||
assert.ok(f.placed.steps.some(step => step.action.type === 'passResponse'));
|
||||
assert.ok(f.placed.steps.every(step => step.action.type === 'passResponse'), 'Only the actual distinct shipping counter is allowed');
|
||||
assert.equal(f.originalFrame.stage, 'arrival'); assert.equal(f.originalFrame.event, f.shippingSource.event);
|
||||
assert.deepEqual(f.originalFrame.source, f.shippingSource);
|
||||
assert.deepEqual(f.originalFrame.deposit, { key: f.location, before: 0 });
|
||||
assert.deepEqual(f.originalFrame.order, { player: f.owner, territory: f.territory, sector: 0, amount: 1, elite: 0, advisors: true });
|
||||
assert.deepEqual(Object.keys(f.shippingSource).sort(), ['amount', 'event', 'owner', 'phase', 'sector', 'territory', 'turn']);
|
||||
assert.equal(f.shippingSource.territory, 'polar_sink'); assert.equal(f.shippingSource.sector, 0);
|
||||
assert.equal(f.originalAutomaticSource.producer, 'spiritualArrival');
|
||||
assert.deepEqual(f.originalAutomaticSource.spiritual, f.shippingSource);
|
||||
assert.deepEqual(f.originalAutomaticSource.forces, { [f.location]: 1 });
|
||||
assert.deepEqual(f.originalQueue, [f.originalAutomaticSource]);
|
||||
assert.deepEqual(f.originalPaidParent, [{ entrant: f.shipper, territory: f.paidTerritory, sector: f.paidSector,
|
||||
amount: 1, elite: 0, cause: 'shipment', resume: 'none', turn: 1, phase: 5 }]);
|
||||
assert.equal(player(f.game, f.owner).reserves, player(f.declaration.before, f.owner).reserves - 1);
|
||||
assert.equal(player(f.game, f.owner).forces[f.location], 1);
|
||||
assert.equal(player(f.game, f.owner).advisors?.[f.territory]?.lockedTurn, 1);
|
||||
assert.equal(player(f.game, f.owner).advisors?.[f.territory]?.aloneFlipHold, f.originalAutomaticSource.event);
|
||||
assert.equal(isAdvisor(player(f.game, f.owner), f.territory), true);
|
||||
assert.equal(fighterCount(player(f.game, f.owner), f.territory), 0);
|
||||
assert.equal(placementLogs(f.game, f.owner).length, 1);
|
||||
assert.equal(f.game.phase, 5); assert.equal(f.game.turn, 1); assert.equal(f.game.active, f.shipper);
|
||||
assert.equal(f.game.pendingTreacheryDiscard ?? null, null); assert.equal(f.originalInventory.length, 45);
|
||||
assert.deepEqual(inventory(f.game), f.originalInventory); assert.equal(f.sourceActionCount, f.actions.length);
|
||||
}
|
||||
function open(f: SemutaSpiritualArrivalFixture) {
|
||||
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
|
||||
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaAdvisorFlipDiscard');
|
||||
if (pending.continuation.kind !== 'karamaAdvisorFlipDiscard') throw Error('Use the original committed Spiritual conversion cost');
|
||||
assert.equal(pending.batch.cause, 'karama:advisorFlip'); assert.equal(pending.reaction?.stage, 'offer');
|
||||
assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, face: entry.publicFace })),
|
||||
[{ card: f.cost, by: f.spender, face: true }]);
|
||||
assert.deepEqual(pending.continuation.use.response, f.originalResponse);
|
||||
assert.deepEqual(pending.continuation.resume.response, f.originalResponse);
|
||||
assert.deepEqual(game.pendingSpiritualAdvisor, f.originalFrame);
|
||||
assert.deepEqual(game.pendingAutomaticAdvisorFlips, f.originalQueue);
|
||||
assert.deepEqual(game.pendingArrivalReaction, f.originalPaidParent);
|
||||
assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(player(game, f.owner).advisors, f.originalStance);
|
||||
assert.deepEqual(player(game, f.owner).advisors?.[f.territory], f.originalLocks);
|
||||
assert.deepEqual(game.deck, f.originalDeck); assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
|
||||
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
function settled(state: Game, f: SemutaSpiritualArrivalFixture, prevented: boolean) {
|
||||
const steps: { actor: string; action: Action }[] = [], game = finish(state, steps);
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingSpiritualAdvisor, undefined);
|
||||
assert.equal(game.pendingAutomaticAdvisorFlips, undefined); assert.equal(game.pendingArrivalReaction?.length ?? 0, 0);
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.deepEqual(physical(game), f.originalPhysical, 'Neither the real free grant nor the paid arrival is transferred or paid again');
|
||||
assert.equal(player(game, f.owner).reserves, player(f.declaration.before, f.owner).reserves - 1);
|
||||
assert.equal(player(game, f.owner).forces[f.location], 1);
|
||||
assert.equal(player(game, f.shipper).forces[f.paidLocation], 1);
|
||||
assert.equal(player(game, f.owner).forces['arrakeen:10'], 1); assert.equal(player(game, f.shipper).forces['arrakeen:10'], 10);
|
||||
assert.equal(isAdvisor(player(game, f.owner), 'arrakeen'), true);
|
||||
assert.equal(isAdvisor(player(game, f.owner), f.territory), prevented);
|
||||
assert.equal(fighterCount(player(game, f.owner), f.territory), prevented ? 0 : 1);
|
||||
if (prevented) {
|
||||
assert.deepEqual(player(game, f.owner).advisors, f.originalStance);
|
||||
assert.equal(player(game, f.owner).advisors?.[f.territory]?.lockedTurn, 1);
|
||||
assert.equal(player(game, f.owner).advisors?.[f.territory]?.aloneFlipHold, f.originalAutomaticSource.event);
|
||||
} else assert.equal(player(game, f.owner).advisors?.[f.territory], undefined);
|
||||
assert.deepEqual(quoteAutomaticAdvisorFlips(game), []); assert.equal(placementLogs(game, f.owner).length, 1);
|
||||
assert.ok(steps.every(step => !['card', 'ship', 'move', 'endMovement'].includes(step.action.type)));
|
||||
assert.deepEqual(game.deck, f.originalDeck); assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
function canceled(state: Game, f: SemutaSpiritualArrivalFixture, claimed: boolean) {
|
||||
const game = settled(state, f, true);
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
|
||||
reject(game, f.spender, f.action); reject(game, f.owner, f.chooseAction); reject(game, f.shipper, f.shipmentAction); return game;
|
||||
}
|
||||
function continueMovement(state: Game, f: SemutaSpiritualArrivalFixture, prevented: boolean) {
|
||||
const steps: { actor: string; action: Action }[] = [];
|
||||
let game = act(state, f.shipper, { type: 'endMovement' }, steps);
|
||||
game = advance(game, current => current.phase >= 6 && current.phase <= 7 && clean(current), steps);
|
||||
assert.equal(steps.filter(step => step.actor === f.shipper && step.action.type === 'endMovement').length, 1);
|
||||
assert.ok(steps.every(step => !['ship', 'move', 'card'].includes(step.action.type)));
|
||||
assert.equal(game.pendingSpiritualAdvisor, undefined); assert.equal(game.pendingAutomaticAdvisorFlips, undefined);
|
||||
assert.equal(game.pendingArrivalReaction?.length ?? 0, 0); assert.equal(placementLogs(game, f.owner).length, 1);
|
||||
assert.equal(player(game, f.owner).forces[f.location], 1); assert.equal(player(game, f.shipper).forces[f.paidLocation], 1);
|
||||
assert.equal(isAdvisor(player(game, f.owner), f.territory), prevented);
|
||||
if (prevented) assert.equal(player(game, f.owner).advisors?.[f.territory]?.aloneFlipHold, f.originalAutomaticSource.event);
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
|
||||
for (const claimed of [false, true]) void test(
|
||||
`Advanced native empty-Polar Spiritual arrival conversion cost Semuta ${claimed ? 'claim' : 'pass'} preserves the committed grant and paid parent then resumes once`, () => {
|
||||
const f = fixture(); nativeSource(f); const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
const game = canceled(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
reject(game, f.holder, { type: 'semutaCommit', event }); reject(game, f.holder, { type: 'semutaPass', event });
|
||||
continueMovement(game, f, true);
|
||||
},
|
||||
);
|
||||
|
||||
void test('native Spiritual arrival allowance converts the really placed Polar advisor before resuming the original paid entry', () => {
|
||||
const f = fixture(); nativeSource(f); const game = settled(allow(structuredClone(f.game)), f, false);
|
||||
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
|
||||
continueMovement(game, f, false);
|
||||
});
|
||||
|
||||
void test('fresh Spiritual arrival cost rejects stale and underlying declarations while its exact source deposit and paid queue remain held', () => {
|
||||
const f = fixture(), game = open(f), event = game.pendingTreacheryDiscard!.batch.event;
|
||||
reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
|
||||
reject(game, f.spender, { type: 'semutaCommit', event }); reject(game, f.spender, f.action);
|
||||
reject(game, f.owner, { type: 'passResponse' }); reject(game, f.owner, f.chooseAction);
|
||||
reject(game, f.owner, { type: 'decision', accept: false }); reject(game, f.shipper, f.shipmentAction);
|
||||
reject(game, f.shipper, { type: 'endMovement' }); reject(game, f.owner, { type: 'prepareAdvisors', territory: f.territory });
|
||||
assert.deepEqual(game.pendingSpiritualAdvisor, f.originalFrame); assert.deepEqual(game.pendingArrivalReaction, f.originalPaidParent);
|
||||
assert.deepEqual(player(game, f.owner).advisors?.[f.territory], f.originalLocks); assert.deepEqual(physical(game), f.originalPhysical);
|
||||
canceled(pass(game), f, false);
|
||||
});
|
||||
|
||||
void test('controlled Spiritual source deposit and paid-parent mismatches reject before cost or fresh Semuta appropriation', () => {
|
||||
const f = fixture();
|
||||
// These deliberately changed copies test source validation only. They are
|
||||
// separate negative controls, never the genuine fixture's native history.
|
||||
const changes: { name: string; change: (game: Game) => void }[] = [
|
||||
{ name: 'source', change(game) {
|
||||
const frame = game.pendingSpiritualAdvisor!;
|
||||
frame.source = { ...frame.source!, event: `${frame.source!.event}:stale` };
|
||||
} },
|
||||
{ name: 'deposit', change(game) {
|
||||
const frame = game.pendingSpiritualAdvisor!;
|
||||
frame.deposit = { ...frame.deposit!, before: frame.deposit!.before + 1 };
|
||||
} },
|
||||
{ name: 'paid parent', change(game) {
|
||||
game.pendingArrivalReaction = game.pendingArrivalReaction!.map((entry, index) =>
|
||||
index === 0 ? { ...entry, amount: entry.amount + 1 } : entry);
|
||||
} },
|
||||
];
|
||||
for (const { name, change } of changes) {
|
||||
const beforeCost = structuredClone(f.game); change(beforeCost);
|
||||
reject(beforeCost, f.spender, f.action);
|
||||
assert.ok(player(beforeCost, f.spender).hand.some(card => card.id === f.cost), name);
|
||||
assert.ok(player(beforeCost, f.holder).hand.some(card => card.id === f.semuta), name);
|
||||
assert.deepEqual(inventory(beforeCost), f.originalInventory);
|
||||
const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event; change(offered);
|
||||
reject(offered, f.holder, { type: 'semutaCommit', event });
|
||||
assert.ok(player(offered, f.holder).hand.some(card => card.id === f.semuta), name);
|
||||
assert.equal(offered.discard.filter(card => card.id === f.cost).length, 1, name);
|
||||
assert.deepEqual(inventory(offered), f.originalInventory);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal profiles legally answer the real placed Spiritual arrival conversion cost', () => {
|
||||
const f = fixture(), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const own = viewGame(offered, f.holder); own.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||
const before = structuredClone(own), action = botActions(own)[0]; assert.ok(action, difficulty);
|
||||
assert.deepEqual(own, before); assert.ok(action.type === 'semutaPass' || action.type === 'semutaCommit'); assert.equal(action.event, event);
|
||||
let game = act(structuredClone(offered), f.holder, action); const claimed = action.type === 'semutaCommit';
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
|
||||
const selecting = viewGame(game, f.holder); selecting.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||
const selected = botActions(selecting)[0]; assert.ok(selected);
|
||||
assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost); game = act(game, f.holder, selected);
|
||||
} else if (game.pendingTreacheryDiscard) game = pass(game);
|
||||
canceled(game, f, claimed);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { isAdvisor } from '../game/advisors';
|
||||
import { spiritualAdvisorMatchingKey } from '../game/spiritual-advisor';
|
||||
import {
|
||||
createSemutaSpiritualCostFixture as fixture, actSemutaSpiritualCost as act,
|
||||
passSemutaSpiritualCost as pass, claimSemutaSpiritualCost as claim, allowSemutaSpiritualCost as allow,
|
||||
finishSemutaSpiritualCost as finish, semutaSpiritualCostPlayer as player,
|
||||
semutaSpiritualCostInventory as inventory, semutaSpiritualCostPhysical as physical,
|
||||
semutaSpiritualCostClean as clean, type SemutaSpiritualCostFixture,
|
||||
} from './fixture-semuta-spiritual-cost';
|
||||
|
||||
const clock = (game: Game) => ({ turn: game.turn, phase: game.phase, active: game.active, ready: game.ready,
|
||||
order: game.order, movementRemaining: game.movementRemaining, storm: game.storm,
|
||||
spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard });
|
||||
const grantLogs = (game: Game, owner: string) => game.log.filter(entry =>
|
||||
entry.text.startsWith(`${player(game, owner).name} sent 1 free`));
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function nativeSource(f: SemutaSpiritualCostFixture) {
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
|
||||
assert.deepEqual(f.afterSetup.players.flatMap(p => p.hand.map(card => card.id)), f.deal);
|
||||
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
|
||||
assert.deepEqual(f.advisorSetup.steps[0].action, { type: 'advisorSetup', territory: f.territory, sector: f.sector });
|
||||
assert.equal(player(f.afterSetup, f.owner).forces[f.location], 1);
|
||||
assert.equal(isAdvisor(player(f.afterSetup, f.owner), f.territory), f.stage === 'shipping');
|
||||
assert.equal(f.acquisition.bid.actor, f.holder); assert.equal(f.acquisition.bid.action.amount, 1);
|
||||
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquired, f.holder).spice, 1);
|
||||
assert.equal(player(f.acquired, f.seller).spice - player(f.acquisition.before, f.seller).spice, 1);
|
||||
assert.ok(f.bgTurn.steps.some(step => step.actor === f.owner && step.action.type === 'endMovement'));
|
||||
assert.deepEqual(f.paidParent.steps[0], { actor: f.shipper, action: f.paidShipmentAction });
|
||||
assert.equal(player(f.paidParent.before, f.shipper).spice - player(f.game, f.shipper).spice, 1);
|
||||
assert.equal(player(f.game, f.shipper).reserves, player(f.paidParent.before, f.shipper).reserves - 1);
|
||||
assert.equal(player(f.game, f.shipper).forces[f.foreignLocation], f.stage === 'shipping' ? 11 : 1);
|
||||
assert.equal(player(f.game, f.shipper).shipped, true); assert.equal(player(f.game, f.shipper).moved, 0);
|
||||
assert.deepEqual(f.beforeDeclaration.decision, f.originalChooser);
|
||||
assert.equal(f.originalChooser.shipment, f.shipper); assert.equal(f.originalChooser.destination, f.foreignLocation);
|
||||
assert.equal(f.originalChooser.ambassadorEvent ?? null, null); assert.equal(f.originalChooser.revivalEvent ?? null, null);
|
||||
assert.deepEqual(f.declaration.steps, [{ actor: f.owner, action: f.nativeAction }]);
|
||||
assert.equal(f.shipping.response?.kind, 'advisor'); assert.equal(f.shipping.pendingSpiritualAdvisor?.stage, 'shipping');
|
||||
assert.deepEqual(physical(f.shipping), physical(f.beforeDeclaration));
|
||||
if (f.stage === 'matching') {
|
||||
assert.ok(f.shippingAllowance); assert.ok(f.shippingAllowance.steps.length > 0);
|
||||
assert.ok(f.shippingAllowance.steps.every(step => step.action.type === 'passResponse'));
|
||||
assert.equal(f.game.response?.advisors, false); assert.equal(f.game.response?.automaticAdvisorFlip ?? null, null);
|
||||
} else assert.equal(f.shippingAllowance, null);
|
||||
assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 5); assert.equal(f.game.active, f.shipper);
|
||||
assert.equal(f.game.phaseOpening ?? null, null); assert.equal(f.game.decision ?? null, null);
|
||||
assert.equal(f.game.response?.kind, f.stage === 'shipping' ? 'advisor' : 'advisorFlip');
|
||||
assert.deepEqual(f.game.response, f.originalResponse); assert.deepEqual(f.game.pendingSpiritualAdvisor, f.originalFrame);
|
||||
assert.equal(f.originalFrame.stage, f.stage); assert.deepEqual(f.originalFrame.decision, f.originalChooser);
|
||||
assert.deepEqual(f.originalFrame.source, f.originalSource); assert.deepEqual(f.originalFrame.denied, []);
|
||||
assert.equal(f.originalFrame.selection, JSON.stringify([f.originalSource, f.originalFrame.order]));
|
||||
assert.deepEqual(f.game.pendingArrivalReaction, f.originalParent); assert.ok(f.originalArrival);
|
||||
assert.equal(f.originalArrival.entrant, f.shipper); assert.equal(f.originalArrival.territory, f.foreignDestination);
|
||||
assert.equal(f.originalArrival.sector, f.foreignSector); assert.equal(f.originalArrival.amount, 1);
|
||||
assert.equal(f.game.pendingShipment ?? null, null); assert.equal(f.game.pendingAmbassador ?? null, null);
|
||||
assert.deepEqual(physical(f.game), physical(f.beforeDeclaration)); assert.equal(grantLogs(f.game, f.owner).length, 0);
|
||||
assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.semuta));
|
||||
assert.ok(player(f.game, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.equal(f.originalInventory.length, 45); assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
assert.equal(f.sourceActionCount, f.actions.length);
|
||||
}
|
||||
function open(f: SemutaSpiritualCostFixture) {
|
||||
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
|
||||
assert.ok(pending);
|
||||
assert.equal(pending.continuation.kind, f.stage === 'shipping' ? 'karamaTerminalDiscard' : 'karamaAdvisorFlipDiscard');
|
||||
if (pending.continuation.kind !== 'karamaTerminalDiscard' && pending.continuation.kind !== 'karamaAdvisorFlipDiscard')
|
||||
throw Error('Expected this exact native Spiritual cancellation cost');
|
||||
assert.deepEqual(pending.batch.entries.map(entry => ({ id: entry.card.id, by: entry.discardedBy, face: entry.publicFace })),
|
||||
[{ id: f.cost, by: f.spender, face: true }]);
|
||||
assert.equal(pending.batch.cause, `karama:${f.originalResponse.kind}`);
|
||||
assert.deepEqual(pending.continuation.use.response, f.originalResponse);
|
||||
assert.deepEqual(pending.continuation.resume.response, f.originalResponse);
|
||||
assert.deepEqual(game.pendingSpiritualAdvisor, f.originalFrame); assert.deepEqual(game.pendingArrivalReaction, f.originalParent);
|
||||
assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(clock(game), clock(f.game));
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
|
||||
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
function settled(game: Game, f: SemutaSpiritualCostFixture, placement: boolean, target = f.territory) {
|
||||
const done = finish(game); assert.equal(clean(done), true); assert.equal(done.pendingSpiritualAdvisor ?? null, null);
|
||||
assert.equal(done.pendingArrivalReaction?.length ?? 0, 0); assert.equal(done.pendingShipment ?? null, null);
|
||||
assert.deepEqual(clock(done), clock(f.game)); assert.deepEqual(inventory(done), f.originalInventory);
|
||||
assert.equal(grantLogs(done, f.owner).length, Number(placement));
|
||||
const bg = player(done, f.owner), original = player(f.game, f.owner);
|
||||
assert.equal(bg.reserves, original.reserves - Number(placement)); assert.equal(bg.spice, original.spice);
|
||||
assert.equal(bg.shipped, original.shipped); assert.equal(bg.moved, original.moved); assert.equal(bg.tanks, original.tanks);
|
||||
assert.equal(bg.forces[f.location], original.forces[f.location] + Number(placement && target === f.territory));
|
||||
if (placement && target === f.foreignDestination && f.stage === 'matching') assert.equal(bg.forces[f.foreignLocation], 1);
|
||||
for (const p of done.players.filter(p => p.id !== f.owner))
|
||||
assert.deepEqual(physical(done).find(seat => seat.id === p.id), f.originalPhysical.find(seat => seat.id === p.id));
|
||||
if (!placement) assert.deepEqual(physical(done), f.originalPhysical);
|
||||
else assert.equal(isAdvisor(bg, target), f.stage === 'shipping' || target === f.foreignDestination);
|
||||
assert.equal(player(done, f.shipper).forces[f.foreignLocation], f.stage === 'shipping' ? 11 : 1);
|
||||
reject(done, f.owner, f.nativeAction); return done;
|
||||
}
|
||||
function denied(state: Game, f: SemutaSpiritualCostFixture, claimed: boolean) {
|
||||
const game = finish(state); assert.equal(game.response ?? null, null); assert.equal(game.pendingTreacheryDiscard ?? null, null);
|
||||
assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(clock(game), clock(f.game));
|
||||
assert.equal(grantLogs(game, f.owner).length, 0);
|
||||
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
|
||||
if (f.stage === 'matching') {
|
||||
assert.deepEqual(game.decision, f.originalChooser); assert.equal(game.pendingSpiritualAdvisor?.stage, 'offer');
|
||||
assert.deepEqual(game.pendingArrivalReaction, f.originalParent);
|
||||
assert.deepEqual(game.pendingSpiritualAdvisor?.denied, [spiritualAdvisorMatchingKey(f.originalSource)]);
|
||||
assert.equal(game.pendingSpiritualAdvisor?.source ?? null, null); assert.equal(game.pendingSpiritualAdvisor?.order ?? null, null);
|
||||
reject(game, f.owner, f.sink);
|
||||
const choices = viewGame(game, f.owner).spiritualAdvisorChoices; assert.ok(choices);
|
||||
assert.ok(choices.choices.filter(choice => !choice.accompany).every(choice => choice.blocked));
|
||||
assert.ok(choices.choices.some(choice => choice.accompany && !choice.blocked));
|
||||
} else {
|
||||
assert.equal(game.pendingSpiritualAdvisor ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.equal(clean(game), true);
|
||||
}
|
||||
assert.deepEqual(inventory(game), f.originalInventory); reject(game, f.spender, f.action); return game;
|
||||
}
|
||||
|
||||
for (const stage of ['shipping', 'matching'] as const) for (const claimed of [false, true]) void test(
|
||||
`native Spiritual ${stage} Karama cost Semuta ${claimed ? 'claim' : 'pass'} preserves the paid parent and applies original denial once`, () => {
|
||||
const f = fixture({ stage }); nativeSource(f); const offered = open(f);
|
||||
let game = denied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
if (stage === 'matching') game = act(game, f.owner, f.decline);
|
||||
settled(game, f, false);
|
||||
},
|
||||
);
|
||||
|
||||
for (const stage of ['shipping', 'matching'] as const) void test(
|
||||
`native Spiritual ${stage} allowance withdraws and places the original free packet once without another foreign payment`, () => {
|
||||
const f = fixture({ stage }); nativeSource(f); const game = settled(allow(structuredClone(f.game)), f, true);
|
||||
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
|
||||
},
|
||||
);
|
||||
|
||||
void test('native Spiritual matching denial retains the legal actual accompaniment replacement for claim and pass', () => {
|
||||
for (const claimed of [false, true]) {
|
||||
const f = fixture({ stage: 'matching' }), offered = open(f);
|
||||
let game = denied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
assert.ok(f.replacementAction); game = act(game, f.owner, f.replacementAction);
|
||||
if (game.response?.spiritualAdvisorSource) game = allow(game);
|
||||
settled(game, f, true, f.foreignDestination);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native Spiritual cost rejects stale foreign and underlying original choices while the exact cost is offered', () => {
|
||||
for (const stage of ['shipping', 'matching'] as const) {
|
||||
const f = fixture({ stage }), game = open(f), event = game.pendingTreacheryDiscard!.batch.event;
|
||||
reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
|
||||
reject(game, f.spender, { type: 'semutaCommit', event }); reject(game, f.spender, f.action);
|
||||
reject(game, f.owner, f.nativeAction); reject(game, f.owner, f.decline);
|
||||
reject(game, f.owner, { type: 'passResponse' }); reject(game, f.shipper, { type: 'endMovement' });
|
||||
const done = denied(pass(game), f, false); reject(done, f.holder, { type: 'semutaCommit', event });
|
||||
}
|
||||
});
|
||||
|
||||
void test('controlled Spiritual parent or exact source signature change rejects the original cost without spending it', () => {
|
||||
for (const stage of ['shipping', 'matching'] as const) for (const changed of ['parent', 'source'] as const) {
|
||||
const f = fixture({ stage }), game = structuredClone(f.game);
|
||||
// Explicit malformed-source supplement; primary source remains genuinely
|
||||
// earned. No played custody, physical group, wallet or clock is installed.
|
||||
if (changed === 'parent') game.pendingSpiritualAdvisor!.signature = 'changed-original-parent';
|
||||
else game.response!.spiritualAdvisorSource = { ...f.originalSource, amount: 2 };
|
||||
reject(game, f.spender, f.action); assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal profiles legally claim or pass actual Spiritual shipping and matching costs', () => {
|
||||
for (const stage of ['shipping', 'matching'] as const) {
|
||||
const f = fixture({ stage }), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const own = viewGame(offered, f.holder); own.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||
const proposed = botActions(own)[0]; assert.ok(proposed);
|
||||
assert.ok(proposed.type === 'semutaCommit' || proposed.type === 'semutaPass'); assert.equal(proposed.event, event);
|
||||
let game = act(structuredClone(offered), f.holder, proposed);
|
||||
const claimed = proposed.type === 'semutaCommit';
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
|
||||
const selecting = viewGame(game, f.holder); selecting.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||
const selected = botActions(selecting)[0]; assert.ok(selected);
|
||||
assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost);
|
||||
game = act(game, f.holder, selected);
|
||||
} else if (game.pendingTreacheryDiscard) game = pass(game);
|
||||
game = denied(game, f, claimed);
|
||||
if (stage === 'matching') game = act(game, f.owner, f.decline);
|
||||
settled(game, f, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user