Offer Semuta on Guild and Revival Spiritual grants
This commit is contained in:
1 parent
617a6593a9
commit
99d442fc68
11 files changed
+1645
-17
No files matched your search
@@ -0,0 +1,242 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import { AMBASSADOR_EFFECTS, type AmbassadorEffect } from '../game/ecaz-ambassadors';
|
||||
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 SemutaSpiritualAmbassadorArrivalStep = { actor: string; action: Action };
|
||||
export const actSemutaSpiritualAmbassadorArrival = actSemutaCombatCapabilities;
|
||||
export const semutaSpiritualAmbassadorArrivalPlayer = semutaCombatCapabilitiesPlayer;
|
||||
export const semutaSpiritualAmbassadorArrivalInventory = semutaCombatCapabilitiesInventory;
|
||||
export const semutaSpiritualAmbassadorArrivalClean = semutaCombatCapabilitiesClean;
|
||||
export const passSemutaSpiritualAmbassadorArrival = passSemutaCombatCapabilities;
|
||||
export const claimSemutaSpiritualAmbassadorArrival = claimSemutaCombatCapabilities;
|
||||
export const semutaSpiritualAmbassadorArrivalPhysical = (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, moved: p.moved, shipped: p.shipped })),
|
||||
});
|
||||
|
||||
/** Placements, the Guild benefit and free Spiritual choice remain explicit
|
||||
* native selections. No mandatory conversion is silently answered here. */
|
||||
export function nextSemutaSpiritualAmbassadorArrival(game: Game): SemutaSpiritualAmbassadorArrivalStep | 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 ecaz = game.players.find(p => p.faction === 'ecaz');
|
||||
if (game.status === 'setup' && game.setupStage === 'forces' && ecaz?.reserves === 20)
|
||||
return { actor: ecaz.id, action: { type: 'ecazSetup', placements: { 'imperial_basin:10': 6 } } };
|
||||
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 === 'ecazPlacement' || d?.kind === 'ecazAmbassador' || 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 nextSemutaSpiritualAmbassadorArrivalStep = nextSemutaSpiritualAmbassadorArrival;
|
||||
export function advanceSemutaSpiritualAmbassadorArrival(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaSpiritualAmbassadorArrivalStep[] = []): Game {
|
||||
for (let count = 0; count < 2800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const next = nextSemutaSpiritualAmbassadorArrival(game);
|
||||
assert.ok(next, `Supply the real Guild/Spiritual choice at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actSemutaSpiritualAmbassadorArrival(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The native Guild Spiritual-arrival source did not reach its finite boundary');
|
||||
}
|
||||
export function allowSemutaSpiritualAmbassadorArrival(game: Game, actions: SemutaSpiritualAmbassadorArrivalStep[] = []): 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 = actSemutaSpiritualAmbassadorArrival(game, p.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.notEqual(game.response?.automaticAdvisorFlip?.producer, 'spiritualArrival'); return game;
|
||||
}
|
||||
/** Finish only this free child's Guild/outer suffix. The original Atreides
|
||||
* combined turn and actual endMovement remain caller-owned. */
|
||||
export function finishSemutaSpiritualAmbassadorArrival(game: Game, actions: SemutaSpiritualAmbassadorArrivalStep[] = []): Game {
|
||||
assert.ok(!game.pendingTreacheryDiscard);
|
||||
return advanceSemutaSpiritualAmbassadorArrival(game, state => !state.pendingSpiritualAdvisor &&
|
||||
!state.pendingAmbassador && !state.pendingAutomaticAdvisorFlips?.length && !state.pendingArrivalReaction?.length &&
|
||||
semutaSpiritualAmbassadorArrivalClean(state), actions);
|
||||
}
|
||||
|
||||
function originalGuildPacket() {
|
||||
const original = AMBASSADOR_EFFECTS.filter(effect => effect !== 'ecaz');
|
||||
const supply: Exclude<AmbassadorEffect, 'ecaz'>[] = ['guild', ...original.filter(effect => effect !== 'guild').slice(0, 4)];
|
||||
const order = [...supply, ...original.filter(effect => !supply.includes(effect))], 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); return { supply, order, scalars };
|
||||
}
|
||||
function createOriginalPacket(game: Game, step: SemutaSpiritualAmbassadorArrivalStep,
|
||||
actions: SemutaSpiritualAmbassadorArrivalStep[], scalars: number[]) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const native = 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(native, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try {
|
||||
const next = applyAction(game, step.actor, step.action); assert.equal(cursor, scalars.length);
|
||||
actions.push(structuredClone(step)); return next;
|
||||
} finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
|
||||
/** Native original Guild packet, paid entrant, real independent Ecaz reserve
|
||||
* and actually placed BG Polar advisor. No played parent or state is installed. */
|
||||
export function createSemutaSpiritualAmbassadorArrivalFixture() {
|
||||
const richese = 'richese', holder = 'choam', ecaz = 'ecaz', owner = 'beneGesserit', entrant = 'atreides', spender = 'emperor';
|
||||
const territory = 'polar_sink', sector = 0, location = 'polar_sink:0', guildTerritory = 'tueks_sietch', guildSector = 5, guildLocation = 'tueks_sietch:5';
|
||||
let game = createGame('SEMUTASPIRITUALAMBASSADORARRIVAL', newPlayer(richese, 'Richese', 'richese'), true, ['choam', 'ecaz']);
|
||||
for (const faction of ['choam', 'ecaz', 'beneGesserit', 'atreides', 'emperor'] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaSpiritualAmbassadorArrivalStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaSpiritualAmbassadorArrival(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaSpiritualAmbassadorArrival(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.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 = semutaSpiritualAmbassadorArrivalInventory(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, 5);
|
||||
assert.equal(ordinary.length, 5); const selected = [...ordinary, printed];
|
||||
const dealOrder = selected.map((card, position) => {
|
||||
const from = game.deck.findIndex(c => c.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, packet = originalGuildPacket();
|
||||
let packetCreation: { before: Game; steps: SemutaSpiritualAmbassadorArrivalStep[]; after: Game } | null = null;
|
||||
let advisorSetup: { before: Game; steps: SemutaSpiritualAmbassadorArrivalStep[]; after: Game } | null = null;
|
||||
for (let count = 0; game.status === 'setup' && count < 180; count++) {
|
||||
const next = nextSemutaSpiritualAmbassadorArrival(game); assert.ok(next);
|
||||
const before = structuredClone(game), at = actions.length;
|
||||
const creates = game.setupStage === 'traitors' && next.action.type === 'traitor' &&
|
||||
game.players.filter(p => p.traitorChoices.length).length === 1;
|
||||
if (creates) game = createOriginalPacket(game, next, actions, packet.scalars);
|
||||
else act(next.actor, next.action);
|
||||
const record = { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) };
|
||||
if (creates) packetCreation = record;
|
||||
if (next.action.type === 'advisorSetup') advisorSetup = record;
|
||||
}
|
||||
assert.equal(game.status, 'playing'); assert.ok(packetCreation && advisorSetup);
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(p => p.hand.map(c => c.id)), selected.map(c => c.id));
|
||||
assert.deepEqual(game.ecazAmbassadors!.cohort.map(id => game.ecazAmbassadors!.tokens.find(t => t.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 && !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: SemutaSpiritualAmbassadorArrivalStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 1000 && !semutaSpiritualAmbassadorArrivalPlayer(game, holder).hand.some(c => c.id === SEMUTA_DRUG_ID); count++) {
|
||||
const next = nextSemutaSpiritualAmbassadorArrival(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 === 4 && !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
|
||||
const token = game.ecazAmbassadors!.tokens.find(t => t.effect === 'guild' && t.zone === 'supply'); assert.ok(token);
|
||||
const placement = transition(() => {
|
||||
act(ecaz, { type: 'decision', token: token.id, territory: 'carthag' });
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
|
||||
act(ecaz, { type: 'decision', decline: true });
|
||||
});
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === entrant && semutaSpiritualAmbassadorArrivalClean(state));
|
||||
let ordinaryDecline: { before: Game; steps: SemutaSpiritualAmbassadorArrivalStep[]; after: Game } | null = null;
|
||||
const paidAction: Action = { type: 'ship', territory: 'carthag', sector: 11, amount: 1, elite: 0, allyPayment: 0 };
|
||||
const beforeEntrantShipment = structuredClone(game), entrantAt = actions.length;
|
||||
act(entrant, paidAction);
|
||||
for (let count = 0; !(game.pendingAmbassador?.stage === 'offer' && !game.response && !game.phaseOpening) && count < 100; count++) {
|
||||
if (!game.response && !game.phaseOpening && game.decision?.kind === 'advisor' && !game.decision.ambassadorEvent)
|
||||
ordinaryDecline = transition(() => { act(owner, { type: 'decision', accept: false }); });
|
||||
else { const next = nextSemutaSpiritualAmbassadorArrival(game); assert.ok(next); act(next.actor, next.action); }
|
||||
}
|
||||
const entrantShipment = { before: beforeEntrantShipment, steps: structuredClone(actions.slice(entrantAt)), after: structuredClone(game) };
|
||||
assert.ok(game.pendingAmbassador?.stage === 'offer'); assert.ok(ordinaryDecline);
|
||||
const event = game.pendingAmbassador.event;
|
||||
const triggerAction: Action = { type: 'decision', event, trigger: true, beneficiary: ecaz };
|
||||
const trigger = transition(() => {
|
||||
act(ecaz, triggerAction); reach(state => !state.response && !state.phaseOpening && state.pendingAmbassador?.stage === 'ship');
|
||||
});
|
||||
const grantAction: Action = { type: 'decision', event, territory: guildTerritory, sector: guildSector, amount: 1, elite: 0 };
|
||||
const grant = transition(() => {
|
||||
act(ecaz, grantAction);
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'advisor' && state.decision.ambassadorEvent === event);
|
||||
});
|
||||
assert.equal(semutaSpiritualAmbassadorArrivalPlayer(game, ecaz).forces[guildLocation], 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), spiritualSource = structuredClone(game.response.spiritualAdvisorSource);
|
||||
const shippingFrame = structuredClone(game.pendingSpiritualAdvisor!);
|
||||
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.ok(game.pendingSpiritualAdvisor?.stage === 'arrival' && game.pendingAmbassador?.shipmentReceipt);
|
||||
assert.equal(game.pendingAmbassador.shipmentReceipt.next, 'advisor');
|
||||
assert.equal(game.pendingSpiritualAdvisor.decision.ambassadorEvent, event);
|
||||
assert.equal(semutaSpiritualAmbassadorArrivalPlayer(game, owner).forces[location], 1);
|
||||
assert.deepEqual(semutaSpiritualAmbassadorArrivalInventory(game), originalInventory);
|
||||
return { game, kind: 'advisorFlip' as const, advanced: true as const, richese, holder, ecaz, owner, entrant, spender,
|
||||
cost, karama: cost, semuta: SEMUTA_DRUG_ID, action: { type: 'card', card: cost, mode: 'cancel' } as Action,
|
||||
originalResponse: structuredClone(game.response), originalSource: spiritualSource, originalSpiritualSource: spiritualSource,
|
||||
originalAutomaticSource: structuredClone(automatic), originalFrame: structuredClone(game.pendingSpiritualAdvisor),
|
||||
originalQueue: structuredClone(game.pendingAutomaticAdvisorFlips!), originalParent: structuredClone(game.pendingAmbassador),
|
||||
originalAmbassador: structuredClone(game.pendingAmbassador), originalReceipt: structuredClone(game.pendingAmbassador.shipmentReceipt),
|
||||
originalDeposit: structuredClone(game.pendingSpiritualAdvisor.deposit!),
|
||||
originalLocks: structuredClone(semutaSpiritualAmbassadorArrivalPlayer(game, owner).advisors?.[territory]),
|
||||
originalStance: structuredClone(semutaSpiritualAmbassadorArrivalPlayer(game, owner).advisors!),
|
||||
originalPhysical: structuredClone(semutaSpiritualAmbassadorArrivalPhysical(game)), originalTokens: structuredClone(game.ecazAmbassadors!),
|
||||
originalPaidQueue: structuredClone(game.pendingArrivalReaction ?? null), originalDeck: structuredClone(game.deck),
|
||||
token: token.id, event, territory, sector, location, guildTerritory, guildSector, guildLocation,
|
||||
paidAction, triggerAction, grantAction, chooseAction, shippingResponse, shippingFrame,
|
||||
initial, lobby, setup, beforeStartingDeal, afterSetup, packet, packetCreation, advisorSetup,
|
||||
dealOrder, deal: selected.map(c => c.id), originalInventory, originalSpice, spiceOrder,
|
||||
acquisition, acquired, cache: acquisition, placement, entrantShipment, ordinaryDecline,
|
||||
trigger, grant, declaration, placed, actions, steps: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Original narrow Advanced R/C/Ecaz/BG/A/E Semuta initializer, exact CHOAM+Ecaz and no modules. Original packet draws earn Guild; actual Ecaz Basin6 and BG Arrakeen1 beside A10 setup remain native. C actually buys cache1 Semuta and E is dealt original printed Karama.',
|
||||
'Ecaz actually pays1 to place Guild in Carthag. A actually pays1 to enter; BG explicitly declines that earlier ordinary Spiritual opportunity. Ecaz really triggers Guild for itself, consuming the original token before choosing its independent free reserve1 shipment to empty Tuek’s5.',
|
||||
'BG then actually chooses free Polar1 from the Guild shipment’s native advisor decision. Real shipping-counter passes withdraw and place an advisor, lock it and open mandatory spiritualArrival. The original Guild receipt remains at advisor with its exact committed order while the free child completes.',
|
||||
'No paid hand, wallet, army, stance, token, parent, source/frame, lock, deposit, clock or Semuta window is installed. Stop before the separate printed conversion cost; original native child and outer callbacks remain unchanged.',
|
||||
] };
|
||||
}
|
||||
export type SemutaSpiritualAmbassadorArrivalFixture = ReturnType<typeof createSemutaSpiritualAmbassadorArrivalFixture>;
|
||||
@@ -0,0 +1,285 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { isAdvisor } from '../game/advisors';
|
||||
import { AMBASSADOR_EFFECTS, type AmbassadorEffect } from '../game/ecaz-ambassadors';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
import {
|
||||
actSemutaSpiritualCost, passSemutaSpiritualCost, claimSemutaSpiritualCost, allowSemutaSpiritualCost,
|
||||
semutaSpiritualCostPlayer, semutaSpiritualCostInventory, semutaSpiritualCostPhysical,
|
||||
} from './fixture-semuta-spiritual-cost';
|
||||
import { nextSemutaRicheseCacheKarama } from './fixture-semuta-richese-cache-karama';
|
||||
|
||||
export type SemutaSpiritualAmbassadorCostStage = 'shipping' | 'matching';
|
||||
export type SemutaSpiritualAmbassadorCostStep = { actor: string; action: Action };
|
||||
export const actSemutaSpiritualAmbassadorCost = actSemutaSpiritualCost;
|
||||
export const passSemutaSpiritualAmbassadorCost = passSemutaSpiritualCost;
|
||||
export const claimSemutaSpiritualAmbassadorCost = claimSemutaSpiritualCost;
|
||||
export const allowSemutaSpiritualAmbassadorCost = allowSemutaSpiritualCost;
|
||||
export const semutaSpiritualAmbassadorCostPlayer = semutaSpiritualCostPlayer;
|
||||
export const semutaSpiritualAmbassadorCostInventory = semutaSpiritualCostInventory;
|
||||
export const semutaSpiritualAmbassadorCostPhysical = semutaSpiritualCostPhysical;
|
||||
export const semutaSpiritualAmbassadorCostClean = (game: Game) => !game.response && !game.phaseOpening &&
|
||||
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingSpiritualAdvisor &&
|
||||
!game.pendingAmbassador && !game.pendingArrivalReaction?.length;
|
||||
|
||||
/** Keep actual Ambassador grants and Spiritual choices explicit. The earlier
|
||||
* ordinary Carthag accompaniment is separately declined by its owned source. */
|
||||
export function nextSemutaSpiritualAmbassadorCost(game: Game): SemutaSpiritualAmbassadorCostStep | null {
|
||||
if (game.pendingTreacheryDiscard || game.truthtrance || game.pendingSpiritualAdvisor ||
|
||||
game.response?.spiritualAdvisorSource || game.response?.automaticAdvisorFlip) return null;
|
||||
if (!game.response && !game.phaseOpening) {
|
||||
const bg = game.players.find(player => player.faction === 'beneGesserit'); assert.ok(bg);
|
||||
const ecaz = game.players.find(player => player.faction === 'ecaz'); assert.ok(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' && ecaz.reserves === 20)
|
||||
return { actor: ecaz.id, action: { type: 'ecazSetup', placements: { 'imperial_basin:10': 6 } } };
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'advisor' || decision?.kind === 'intrusion' ||
|
||||
decision?.kind === 'ecazPlacement' || decision?.kind === 'ecazAmbassador') return null;
|
||||
if (decision?.kind === 'advisorBattle')
|
||||
return { actor: decision.player, action: { type: 'decision', accept: false } };
|
||||
if (game.status === 'setup' && game.setupStage === 'forces' && !bg.advisorSetup)
|
||||
return { actor: bg.id, action: { type: 'advisorSetup', territory: 'arrakeen', sector: 10 } };
|
||||
}
|
||||
return nextSemutaRicheseCacheKarama(game);
|
||||
}
|
||||
export function advanceSemutaSpiritualAmbassadorCost(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaSpiritualAmbassadorCostStep[] = []): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextSemutaSpiritualAmbassadorCost(game);
|
||||
assert.ok(step, 'Choose the original Ambassador, Spiritual or fresh-cost control before continuing');
|
||||
game = actSemutaSpiritualAmbassadorCost(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original Guild Ambassador Spiritual programme did not reach its finite boundary');
|
||||
}
|
||||
/** Matching prevention restores the real chooser. Otherwise finish only the
|
||||
* original Guild parent, leaving the same native entrant turn active. */
|
||||
export function finishSemutaSpiritualAmbassadorCost(game: Game,
|
||||
actions: SemutaSpiritualAmbassadorCostStep[] = []): Game {
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null, 'Choose the original fresh cost first');
|
||||
return advanceSemutaSpiritualAmbassadorCost(game, state => semutaSpiritualAmbassadorCostClean(state) ||
|
||||
(!state.response && !state.phaseOpening && state.decision?.kind === 'advisor' &&
|
||||
state.pendingSpiritualAdvisor?.stage === 'offer'), actions);
|
||||
}
|
||||
|
||||
function originalGuildPacket() {
|
||||
const original = AMBASSADOR_EFFECTS.filter(effect => effect !== 'ecaz');
|
||||
const supply: Exclude<AmbassadorEffect, 'ecaz'>[] = ['guild', ...original.filter(effect => effect !== 'guild').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 };
|
||||
}
|
||||
function createOriginalPacket(game: Game, step: SemutaSpiritualAmbassadorCostStep,
|
||||
actions: SemutaSpiritualAmbassadorCostStep[], 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 after = applyAction(game, step.actor, step.action);
|
||||
assert.equal(cursor, scalars.length, 'The original Ecaz packet consumes its nine selected native draws');
|
||||
actions.push(structuredClone(step)); return after;
|
||||
} finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
|
||||
/** Real Advanced narrow Ecaz Semuta setup, paid token and entrant, then an
|
||||
* actual free Guild child shipment. No played card, counter, stance, wallet,
|
||||
* clock, token, grant, receipt or control is assigned. */
|
||||
export function createSemutaSpiritualAmbassadorCostFixture(options: {
|
||||
stage?: SemutaSpiritualAmbassadorCostStage;
|
||||
} = {}) {
|
||||
const stage = options.stage ?? 'shipping', richese = 'richese', holder = 'choam', ecaz = 'ecaz',
|
||||
owner = 'beneGesserit', spender = 'atreides', entrant = spender, shipper = ecaz;
|
||||
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('SEMUTASPIRITUALAMBASSADORCOST', newPlayer(richese, 'Richese', 'richese'), true, ['choam', 'ecaz']);
|
||||
for (const faction of [holder, ecaz, owner, spender] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaSpiritualAmbassadorCostStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaSpiritualAmbassadorCost(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaSpiritualAmbassadorCost(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) };
|
||||
};
|
||||
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: 'original-undealt-bare-advanced-ecaz-semuta' };
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.equal(game.semutaPreview, true);
|
||||
assert.deepEqual(game.expansions, ['choam', 'ecaz']); assert.ok(game.players.every(player => !player.hand.length));
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
const originalInventory = semutaSpiritualAmbassadorCostInventory(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 = originalGuildPacket();
|
||||
let packetCreation: ReturnType<typeof transition> | null = null, advisorSetup: ReturnType<typeof transition> | null = null;
|
||||
for (let count = 0; game.status === 'setup' && count < 120; count++) {
|
||||
const step = nextSemutaSpiritualAmbassadorCost(game); assert.ok(step);
|
||||
if (step.action.type === 'advisorSetup') step.action = { type: 'advisorSetup', territory, sector };
|
||||
const before = structuredClone(game), start = actions.length;
|
||||
const createsPacket = game.setupStage === 'traitors' && step.action.type === 'traitor' &&
|
||||
game.players.filter(player => player.traitorChoices.length).length === 1;
|
||||
game = createsPacket ? createOriginalPacket(game, step, actions, packet.scalars)
|
||||
: actSemutaSpiritualAmbassadorCost(game, step.actor, step.action, actions);
|
||||
const record = { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
|
||||
if (createsPacket) packetCreation = record;
|
||||
if (step.action.type === 'advisorSetup') advisorSetup = record;
|
||||
}
|
||||
assert.equal(game.status, 'playing'); assert.ok(packetCreation && advisorSetup);
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.equal(semutaSpiritualAmbassadorCostPlayer(game, owner).forces[location], 1);
|
||||
assert.equal(isAdvisor(semutaSpiritualAmbassadorCostPlayer(game, owner), territory), stage === 'shipping');
|
||||
assert.ok(semutaSpiritualAmbassadorCostPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
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 && !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: SemutaSpiritualAmbassadorCostStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 900 && !semutaSpiritualAmbassadorCostPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const step = nextSemutaSpiritualAmbassadorCost(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, 'CHOAM really buys 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(semutaSpiritualAmbassadorCostPlayer(beforeBid, holder).spice - semutaSpiritualAmbassadorCostPlayer(game, holder).spice, 1);
|
||||
assert.equal(semutaSpiritualAmbassadorCostPlayer(game, richese).spice - semutaSpiritualAmbassadorCostPlayer(beforeBid, richese).spice, 1);
|
||||
reach(state => state.phase === 4 && !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
|
||||
const guild = game.ecazAmbassadors!.tokens.find(token => token.effect === 'guild'); assert.ok(guild);
|
||||
assert.equal(guild.zone, 'supply'); const token = guild.id;
|
||||
const placement = transition(() => {
|
||||
act(ecaz, { type: 'decision', token, territory: 'carthag' });
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
|
||||
});
|
||||
assert.equal(semutaSpiritualAmbassadorCostPlayer(placement.before, ecaz).spice - semutaSpiritualAmbassadorCostPlayer(game, ecaz).spice, 1);
|
||||
act(ecaz, { type: 'decision', decline: true });
|
||||
reach(state => state.phase === 5 && state.active === owner && semutaSpiritualAmbassadorCostClean(state));
|
||||
const bgTurn = transition(() => {
|
||||
act(owner, { type: 'endMovement' });
|
||||
reach(state => state.phase === 5 && state.active === entrant && semutaSpiritualAmbassadorCostClean(state));
|
||||
});
|
||||
const paidShipmentAction: Action = { type: 'ship', territory: 'carthag', sector: 11, amount: 1, elite: 0, allyPayment: 0 };
|
||||
const earlierAccompaniment: SemutaSpiritualAmbassadorCostStep[] = [];
|
||||
const paidEntry = transition(() => {
|
||||
act(entrant, paidShipmentAction);
|
||||
for (let count = 0; !(game.pendingAmbassador?.stage === 'offer' && !game.response && !game.phaseOpening) && count < 150; count++) {
|
||||
if (!game.response && !game.phaseOpening && game.decision?.kind === 'advisor') {
|
||||
const step: SemutaSpiritualAmbassadorCostStep = { actor: owner, action: { type: 'decision', accept: false } };
|
||||
earlierAccompaniment.push(structuredClone(step)); act(step.actor, step.action);
|
||||
} else {
|
||||
const step = nextSemutaSpiritualAmbassadorCost(game); assert.ok(step, 'Complete only the original paid Carthag entry');
|
||||
act(step.actor, step.action);
|
||||
}
|
||||
}
|
||||
assert.equal(game.pendingAmbassador?.stage, 'offer');
|
||||
});
|
||||
assert.equal(semutaSpiritualAmbassadorCostPlayer(paidEntry.before, entrant).spice - semutaSpiritualAmbassadorCostPlayer(game, entrant).spice, 1);
|
||||
const offer = structuredClone(game), event = game.pendingAmbassador!.event;
|
||||
const trigger = transition(() => {
|
||||
act(ecaz, { type: 'decision', event, trigger: true, beneficiary: ecaz });
|
||||
reach(state => !state.response && !state.phaseOpening && state.pendingAmbassador?.stage === 'ship');
|
||||
});
|
||||
const foreignShipmentAction: Action = { type: 'decision', event, territory: foreignDestination,
|
||||
sector: foreignSector, amount: 1, elite: 0 };
|
||||
const foreignShipment = transition(() => {
|
||||
act(shipper, foreignShipmentAction);
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'advisor');
|
||||
});
|
||||
assert.equal(semutaSpiritualAmbassadorCostPlayer(foreignShipment.before, shipper).spice,
|
||||
semutaSpiritualAmbassadorCostPlayer(game, shipper).spice);
|
||||
assert.equal(semutaSpiritualAmbassadorCostPlayer(foreignShipment.before, shipper).reserves -
|
||||
semutaSpiritualAmbassadorCostPlayer(game, shipper).reserves, 1);
|
||||
assert.equal(semutaSpiritualAmbassadorCostPlayer(game, shipper).forces[foreignLocation], 1);
|
||||
assert.equal(semutaSpiritualAmbassadorCostPlayer(game, shipper).shipped,
|
||||
semutaSpiritualAmbassadorCostPlayer(foreignShipment.before, shipper).shipped);
|
||||
assert.ok(game.decision?.kind === 'advisor'); assert.equal(game.decision.player, owner);
|
||||
assert.equal(game.decision.shipment, shipper); assert.equal(game.decision.destination, foreignLocation);
|
||||
assert.equal(game.decision.ambassadorEvent, event);
|
||||
assert.equal(game.pendingAmbassador?.shipmentReceipt?.next, 'advisor');
|
||||
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), shippingAllowance = stage === 'matching'
|
||||
? transition(() => { game = allowSemutaSpiritualAmbassadorCost(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 && game.pendingAmbassador);
|
||||
assert.deepEqual(semutaSpiritualAmbassadorCostPhysical(game), semutaSpiritualAmbassadorCostPhysical(beforeDeclaration));
|
||||
const source = structuredClone(game), originalResponse = structuredClone(game.response),
|
||||
originalFrame = structuredClone(game.pendingSpiritualAdvisor), originalSource = structuredClone(game.response.spiritualAdvisorSource),
|
||||
originalAmbassador = structuredClone(game.pendingAmbassador);
|
||||
const action: Action = { type: 'card', card: cost, mode: 'cancel' };
|
||||
assert.ok(semutaSpiritualAmbassadorCostPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.ok(semutaSpiritualAmbassadorCostPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
assert.deepEqual(semutaSpiritualAmbassadorCostInventory(game), originalInventory);
|
||||
return { game, source, beforeCost: source, stage, kind: originalResponse.kind, advanced: true as const,
|
||||
richese, seller: richese, holder, ecaz, owner, bg: owner, spender, atreides: spender, entrant, shipper, actor: owner,
|
||||
cost, karama: cost, semuta: SEMUTA_DRUG_ID, action, nativeAction, accompany, sink, decline,
|
||||
replacementAction: stage === 'matching' ? accompany : null,
|
||||
territory, sector, location, foreignDestination, foreignSector, foreignLocation,
|
||||
event, parentEvent: event, token, paidShipmentAction, foreignShipmentAction,
|
||||
originalFrame, originalParent: originalFrame, originalSource, originalResponse, originalChooser, originalAmbassador,
|
||||
originalQueue: structuredClone(game.pendingArrivalReaction ?? null),
|
||||
originalForeignReceipt: structuredClone(originalAmbassador.shipmentReceipt!),
|
||||
originalToken: structuredClone(game.ecazAmbassadors!.tokens.find(candidate => candidate.id === token)!),
|
||||
originalPhysical: structuredClone(semutaSpiritualAmbassadorCostPhysical(game)), originalHistory: structuredClone(game.log),
|
||||
initial, lobby, setup, initialization, beforeStartingDeal, afterSetup, advisorSetup, packet, packetCreation,
|
||||
deal: selected.map(card => card.id), dealOrder, spiceOrder, originalSpice, originalInventory,
|
||||
acquired, acquisition, placement, bgTurn, paidEntry, earlierAccompaniment, offer, trigger, foreignShipment,
|
||||
entryCost: 1, placementCost: 1, foreignCost: 0, beforeDeclaration, declaration, shipping, shippingAllowance,
|
||||
actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Original narrow Advanced Ecaz Semuta initializer on the ready undealt R/C/Ecaz/BG/A exact-CHOAM+Ecaz lobby, without optional modules or a new profile gate. Only unopened card/Spice order and actual scalar lotteries are selected.',
|
||||
'The final real Traitor choice consumes nine original packet draws earning Guild supply. Actual Ecaz setup keeps six in Basin10; BG really places its advisor at occupied Arrakeen10 for shipping or its fighter at empty Polar0 for matching. A is originally dealt printed Karama; C really buys original cache Semuta for one.',
|
||||
'Native first Storm, dual Spice, Charity, Bidding and Revival execute. Ecaz pays one to place Guild at Carthag; BG ends its real turn; A pays one to enter Carthag and any earlier ordinary Spiritual accompaniment is actually declined.',
|
||||
'Ecaz triggers the actual token for itself, then really ships one native reserve free through Guild to Arrakeen10 for shipping or empty Tueks5 for matching. The physical free entrant, token use, original A payment and Guild receipt are already committed before BG declares its independent packet.',
|
||||
stage === 'shipping'
|
||||
? 'BG selects actual accompaniment beside its original Arrakeen advisor. Stop at the new explicit Spiritual shipping response before withdrawal; no matching or alone conversion is needed beside Ecazs committed foreign force.'
|
||||
: 'BG selects the actual Polar fallback beside its original fighter and really allows shipping. Stop at the separate matching counter before withdrawal. Prevention restores the original chooser; real accompaniment at Tueks remains legal because that is a different destination beside Ecazs committed foreign force.',
|
||||
'No played hand, force, stance, wallet, clock, token, parent, grant or own Semuta window is installed. Finish only the exact Guild Ambassador child and retain the original native entrant turn; no future battle, shipment or movement is invented.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type SemutaSpiritualAmbassadorCostFixture = ReturnType<typeof createSemutaSpiritualAmbassadorCostFixture>;
|
||||
@@ -0,0 +1,238 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { isAdvisor } from '../game/advisors';
|
||||
import { homeworldGameIntegrity, homeworldTable } from '../game/homeworld-game';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { homeworldRevivalDeploymentChoice } from '../game/homeworld-revival-deployment-options';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
import {
|
||||
actSemutaSpiritualCost, passSemutaSpiritualCost, claimSemutaSpiritualCost, allowSemutaSpiritualCost,
|
||||
semutaSpiritualCostPlayer, semutaSpiritualCostInventory,
|
||||
} from './fixture-semuta-spiritual-cost';
|
||||
import { nextSemutaAdvisorMatching } from './fixture-semuta-advisor-matching';
|
||||
|
||||
export type SemutaSpiritualRevivalStage = 'shipping' | 'matching' | 'arrival';
|
||||
export type SemutaSpiritualRevivalStep = { actor: string; action: Action };
|
||||
export const actSemutaSpiritualRevival = actSemutaSpiritualCost;
|
||||
export const passSemutaSpiritualRevival = passSemutaSpiritualCost;
|
||||
export const claimSemutaSpiritualRevival = claimSemutaSpiritualCost;
|
||||
export const allowSemutaSpiritualRevival = allowSemutaSpiritualCost;
|
||||
export const semutaSpiritualRevivalPlayer = semutaSpiritualCostPlayer;
|
||||
export const semutaSpiritualRevivalInventory = semutaSpiritualCostInventory;
|
||||
export const semutaSpiritualRevivalClean = (game: Game) => !game.response && !game.phaseOpening && !game.decision &&
|
||||
!game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRevival &&
|
||||
!game.pendingSpiritualAdvisor && !game.pendingArrivalReaction?.length && !game.pendingAutomaticAdvisorFlips?.length;
|
||||
/** Balances and advisor stance are asserted separately: the retained native
|
||||
* free-revival income may settle only after the original SEND completes. */
|
||||
export const semutaSpiritualRevivalPhysical = (game: Game) => ({ homeworlds: game.homeworlds,
|
||||
players: game.players.map(p => ({ id: p.id, forces: p.forces, elites: p.elites, reserves: p.reserves,
|
||||
tanks: p.tanks, shipped: p.shipped, moved: p.moved, revived: p.revived,
|
||||
freeForcesRevived: p.freeForcesRevived, leaders: p.leaders, ally: p.ally })) });
|
||||
|
||||
/** Original setup, exact native Storm controls and ordinary revival choices.
|
||||
* SEND, Spiritual choices/counters and fresh groups stay caller-owned. */
|
||||
export function nextSemutaSpiritualRevival(game: Game): SemutaSpiritualRevivalStep | null {
|
||||
if (game.pendingTreacheryDiscard || game.truthtrance || game.pendingSpiritualAdvisor ||
|
||||
game.response?.spiritualAdvisorSource || game.response?.automaticAdvisorFlip) return null;
|
||||
if (!game.response && !game.phaseOpening) {
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'advisor' || decision?.kind === 'intrusion' || decision?.kind === 'homeworldRevivalDeployment') return null;
|
||||
if (decision?.kind === 'revivalStop' || decision?.kind === 'choamFreeRevival')
|
||||
return { actor: decision.player, action: { type: 'decision', decline: true } };
|
||||
if (game.status === 'setup' && game.setupStage === 'prediction')
|
||||
return { actor: 'beneGesserit', action: { type: 'predict', faction: 'tleilaxu', turn: 10 } };
|
||||
if (game.status === 'playing' && game.phase === 0 && !decision && game.stormPending === null) {
|
||||
const actor = game.stormDialers.find(id => game.stormDials[id] === undefined);
|
||||
if (actor) return { actor, action: { type: 'stormDial',
|
||||
amount: game.turn === 1 ? actor === game.stormDialers[0] ? 11 : 0 : 1 } };
|
||||
}
|
||||
}
|
||||
return nextSemutaAdvisorMatching(game);
|
||||
}
|
||||
export const nextSemutaSpiritualRevivalStep = nextSemutaSpiritualRevival;
|
||||
export function advanceSemutaSpiritualRevival(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaSpiritualRevivalStep[] = []): Game {
|
||||
for (let count = 0; count < 2400; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextSemutaSpiritualRevival(game);
|
||||
assert.ok(step, `Choose the original SEND/Spiritual/fresh control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actSemutaSpiritualRevival(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original Semuta/Revival SEND chronology did not reach its finite boundary');
|
||||
}
|
||||
/** Keep a restored matching chooser explicit. Otherwise finish the original
|
||||
* SEND queue and retained income, without another request or phase action. */
|
||||
export function finishSemutaSpiritualRevival(game: Game, actions: SemutaSpiritualRevivalStep[] = []): Game {
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null, 'Choose the original fresh cost first');
|
||||
return advanceSemutaSpiritualRevival(game, state =>
|
||||
(state.homeworldRevivalReturn?.stage === 'complete' && semutaSpiritualRevivalClean(state)) ||
|
||||
(!state.response && !state.phaseOpening && state.decision?.kind === 'advisor' &&
|
||||
state.pendingSpiritualAdvisor?.stage === 'offer'), actions);
|
||||
}
|
||||
|
||||
/** Fresh Advanced R/C/BG/A/T, actual cache purchase and ordinary printed
|
||||
* Karama, real next-Storm loss, free revival and owned Homeworld SEND. */
|
||||
export function createSemutaSpiritualRevivalFixture(options: { stage?: SemutaSpiritualRevivalStage } = {}) {
|
||||
const stage = options.stage ?? 'shipping', seller = 'richese', holder = 'choam', owner = 'beneGesserit';
|
||||
const spender = 'atreides', reviver = 'tleilaxu', destination = 'arrakeen:10';
|
||||
const territory = stage === 'shipping' ? 'arrakeen' : 'polar_sink', sector = stage === 'shipping' ? 10 : 0;
|
||||
const location = `${territory}:${sector}`, casualtyLocation = 'hagga_basin:13';
|
||||
let game = createGame('SEMUTASPIRITUALREVIVAL', newPlayer(seller, 'Richese', 'richese'), true, ['choam', 'ix']);
|
||||
for (const faction of [holder, owner, spender, reviver] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaSpiritualRevivalStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaSpiritualRevival(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaSpiritualRevival(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) };
|
||||
};
|
||||
const enableHomeworlds = transition(() => { act(game.host, { type: 'homeworlds', enabled: true }); });
|
||||
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-tleilax-homeworld-preview' };
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.equal(game.semutaPreview, true);
|
||||
assert.equal(game.homeworlds?.custody, null); assert.ok(game.players.every(p => !p.hand.length));
|
||||
assert.ok(!game.nexusCards && !game.leaderSkills && !game.techTokens && !game.strongholdCards &&
|
||||
!game.discoveryEnabled && !game.ecazTreachery);
|
||||
const originalInventory = semutaSpiritualRevivalInventory(game); assert.equal(originalInventory.length, 57);
|
||||
const printed = game.deck.find(card => card.effect === 'karama' && !card.id.startsWith('ix-')); assert.ok(printed);
|
||||
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind) && !card.effect).slice(0, 4);
|
||||
assert.equal(ordinary.length, 4);
|
||||
const selected = [ordinary[0], ordinary[1], ordinary[2], printed, ordinary[3]], 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);
|
||||
let advisorSetup: { before: Game; steps: SemutaSpiritualRevivalStep[]; after: Game } | null = null;
|
||||
for (let count = 0; game.status === 'setup' && count < 150; count++) {
|
||||
const step = nextSemutaSpiritualRevival(game); assert.ok(step);
|
||||
if (step.action.type === 'advisorSetup') step.action = { type: 'advisorSetup',
|
||||
territory: stage === 'matching' ? 'polar_sink' : 'arrakeen', sector: stage === 'matching' ? 0 : 10 };
|
||||
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 && game.homeworlds?.custody);
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
const setupTerritory = stage === 'matching' ? 'polar_sink' : 'arrakeen';
|
||||
assert.equal(semutaSpiritualRevivalPlayer(game, owner).forces[stage === 'matching' ? 'polar_sink:0' : 'arrakeen:10'], 1);
|
||||
assert.equal(isAdvisor(semutaSpiritualRevivalPlayer(game, owner), setupTerritory), stage !== 'matching');
|
||||
assert.equal(semutaSpiritualRevivalPlayer(game, reviver).reserves, 20);
|
||||
assert.equal(semutaSpiritualRevivalPlayer(game, reviver).faceDancers?.length, 3);
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
|
||||
for (let position = 0; position < 4; position++) {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery &&
|
||||
!['hagga_basin', 'arrakeen', 'polar_sink'].includes(card.territory));
|
||||
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) });
|
||||
}
|
||||
const firstStorm = transition(() => { reach(state => state.turn === 1 && state.phase === 1); });
|
||||
assert.equal(game.storm, 12);
|
||||
let beforeBid: Game | undefined, bid: SemutaSpiritualRevivalStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 900 && !semutaSpiritualRevivalPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const step = nextSemutaSpiritualRevival(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(semutaSpiritualRevivalPlayer(beforeBid, holder).spice - semutaSpiritualRevivalPlayer(game, holder).spice, 1);
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === reviver && semutaSpiritualRevivalClean(state));
|
||||
const homeworldSources = nativeShipmentSources(viewGame(game, reviver), 1, 0); assert.ok(homeworldSources);
|
||||
const shipmentAction: Action = { type: 'ship', territory: 'hagga_basin', sector: 13,
|
||||
amount: 1, elite: 0, allyPayment: 0, homeworldSources };
|
||||
const shipment = transition(() => {
|
||||
act(reviver, shipmentAction);
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'advisor');
|
||||
});
|
||||
const firstSpiritualDecline = transition(() => { act(owner, { type: 'decision', accept: false }); });
|
||||
assert.equal(semutaSpiritualRevivalPlayer(shipment.before, reviver).spice - semutaSpiritualRevivalPlayer(game, reviver).spice, 2);
|
||||
assert.equal(semutaSpiritualRevivalPlayer(game, reviver).forces[casualtyLocation], 1);
|
||||
reach(state => state.turn === 2 && state.phase === 0 && !state.response && !state.phaseOpening && state.stormPending === 1);
|
||||
const beforeStormMovement = structuredClone(game), stormSource = structuredClone(game.stormMovementSource);
|
||||
const stormLoss = transition(() => { reach(state => state.turn === 2 && state.phase === 1); });
|
||||
assert.equal(game.storm, 13); assert.equal(semutaSpiritualRevivalPlayer(game, reviver).tanks, 1);
|
||||
assert.equal(semutaSpiritualRevivalPlayer(game, reviver).forces[casualtyLocation] ?? 0, 0);
|
||||
reach(state => state.turn === 2 && state.phase === 4 && semutaSpiritualRevivalClean(state));
|
||||
const beforeRequest = structuredClone(game), revivalAction: Action = { type: 'revive', amount: 1, elite: 0, freeElite: 0 };
|
||||
assert.equal(semutaSpiritualRevivalPlayer(game, reviver).reserves, 19);
|
||||
assert.equal(homeworldTable(game)!.find(world => world.native === reviver)!.side, 'high');
|
||||
const freeRevive = transition(() => {
|
||||
act(reviver, revivalAction);
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'homeworldRevivalDeployment');
|
||||
});
|
||||
const returned = structuredClone(game), choice = homeworldRevivalDeploymentChoice(viewGame(game, reviver), destination);
|
||||
assert.equal(choice.blocked, null); assert.ok(choice.action);
|
||||
const sendAction = choice.action, send = transition(() => {
|
||||
act(reviver, sendAction);
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'advisor');
|
||||
});
|
||||
assert.ok(game.decision?.kind === 'advisor'); assert.equal(game.decision.revivalEvent, game.homeworldRevivalReturn?.event);
|
||||
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);
|
||||
const shippingResponse = structuredClone(game.response), shipping = structuredClone(game);
|
||||
const shippingAllowance = stage === 'shipping' ? null : transition(() => { game = allowSemutaSpiritualRevival(game, actions); });
|
||||
assert.equal(game.pendingSpiritualAdvisor?.stage, stage);
|
||||
assert.ok(game.response && game.pendingSpiritualAdvisor && game.homeworldRevivalReturn && game.homeworldRevivalProgress);
|
||||
const automatic = game.response.automaticAdvisorFlip;
|
||||
if (stage === 'arrival') assert.ok(automatic?.producer === 'spiritualArrival');
|
||||
else assert.ok(game.response.spiritualAdvisorSource);
|
||||
assert.equal(game.homeworldRevivalReturn.stage, 'arrival'); assert.equal(game.pendingRevival ?? null, null);
|
||||
const source = structuredClone(game), originalResponse = structuredClone(game.response), originalFrame = structuredClone(game.pendingSpiritualAdvisor);
|
||||
const originalSource = structuredClone(game.response.spiritualAdvisorSource ??
|
||||
(automatic?.producer === 'spiritualArrival' ? automatic.spiritual : undefined));
|
||||
assert.ok(originalSource);
|
||||
const originalReturn = structuredClone(game.homeworldRevivalReturn), originalProgress = structuredClone(game.homeworldRevivalProgress);
|
||||
const originalQueue = structuredClone(game.pendingAutomaticAdvisorFlips ?? []), originalArrival = structuredClone(game.pendingArrivalReaction ?? []);
|
||||
assert.ok(originalArrival.some(arrival => arrival.entrant === reviver && arrival.revivalArrival?.event === originalReturn.event));
|
||||
assert.equal(originalReturn.resumeResponse?.kind, 'revivalIncome'); assert.equal(originalReturn.resumeResponse?.amount, 1);
|
||||
assert.ok(semutaSpiritualRevivalPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.ok(semutaSpiritualRevivalPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
homeworldGameIntegrity(game); assert.deepEqual(semutaSpiritualRevivalInventory(game), originalInventory);
|
||||
return { game, source, beforeCost: source, stage, advanced: true as const, kind: originalResponse.kind,
|
||||
owner, bg: owner, recipient: owner, holder, spender, atreides: spender, seller, richese: seller,
|
||||
reviver, tleilaxu: reviver, shipper: reviver, incomeOwner: reviver,
|
||||
cost, karama: cost, semuta: SEMUTA_DRUG_ID, action: { type: 'card', card: cost, mode: 'cancel' } as Action,
|
||||
nativeAction, accompany, sink, decline, replacementAction: stage === 'matching' ? accompany : null,
|
||||
originalResponse, originalFrame, originalSource, originalChooser, originalReturn, originalProgress,
|
||||
originalWorldParent: originalReturn, originalParent: originalReturn, originalRetainedIncome: structuredClone(originalReturn.resumeResponse),
|
||||
originalPendingRevival: structuredClone(game.pendingRevival ?? null), originalDeposit: structuredClone(originalFrame.deposit ?? null),
|
||||
originalAutomaticSource: structuredClone(automatic ?? null), originalQueue, originalArrival,
|
||||
originalPhysical: structuredClone(semutaSpiritualRevivalPhysical(game)), originalCustody: structuredClone(game.homeworlds!.custody),
|
||||
originalBalances: Object.fromEntries(game.players.map(p => [p.id, p.spice])),
|
||||
originalStance: structuredClone(semutaSpiritualRevivalPlayer(game, owner).advisors),
|
||||
territory, sector, location, destination, casualtyLocation, revivalAction, sendAction,
|
||||
initial, enableHomeworlds, lobby, setup, initialization, beforeStartingDeal, afterSetup, advisorSetup,
|
||||
deal: selected.map(card => card.id), dealOrder, originalInventory, originalSpice, spiceOrder,
|
||||
firstStorm, acquisition, acquired, shipmentAction, shipment, firstSpiritualDecline,
|
||||
beforeStormMovement, stormSource, stormLoss, beforeRequest, freeRevive, returned, send,
|
||||
beforeDeclaration, declaration, shipping, shippingResponse, shippingAllowance,
|
||||
actions, steps: actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Actual original Advanced R/C/BG/A/T exact CHOAM+Ix fresh Semuta initializer after the real Homeworlds lobby option. Only unopened original card/Spice positions and scalar lotteries are selected. Atreides really receives printed base Karama, Tleilaxu its original Face Dancers, and CHOAM actually buys cache1 Semuta.',
|
||||
'Actual first Storm dials11+0 end at12. Tleilaxu really pays two to ship one original typed native reserve to Hagga13; BG actually declines that earlier Spiritual choice. Actual next original Storm Card1 ends at13 and destroys it. Real native phases reach turn2 Revival with High19/Tanks1; no played stock, wallet, hand, stance, clock, return, casualty or custody is installed.',
|
||||
'Tleilaxu actually revives one ordinary force free, earning original High SEND and retained free-revival income1. The owned original deployment quote sends exactly that counter to Arrakeen10. The native return/progress stays at arrival, queued entry carries the original revival event, and pendingRevival is already cleared by its real completion.',
|
||||
stage === 'matching'
|
||||
? 'BG’s original setup placed a native fighter in empty Polar. After SEND, BG actually declares fallback1 and passes the original shipping counter; stop before the separate matching placement. Denial restores the original chooser, with actual accompaniment1 into the SEND territory or decline still legal.'
|
||||
: stage === 'arrival'
|
||||
? 'BG’s original advisorSetup is beside A10 Arrakeen. After SEND, BG actually declares free Polar1 and passes shipping. The real native reserve withdrawal/deposit opens its independent spiritualArrival alone-conversion counter; stop with the whole return/income parent retained.'
|
||||
: 'BG’s original advisorSetup is beside A10 Arrakeen. After SEND, BG actually declares accompaniment1 and stops at shipping before withdrawing any BG reserve. The revived T counter and original return/income remain committed.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type SemutaSpiritualRevivalFixture = ReturnType<typeof createSemutaSpiritualRevivalFixture>;
|
||||
@@ -0,0 +1,200 @@
|
||||
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 {
|
||||
createSemutaSpiritualAmbassadorArrivalFixture as fixture, actSemutaSpiritualAmbassadorArrival as act,
|
||||
passSemutaSpiritualAmbassadorArrival as pass, claimSemutaSpiritualAmbassadorArrival as claim,
|
||||
allowSemutaSpiritualAmbassadorArrival as allow, finishSemutaSpiritualAmbassadorArrival as finish,
|
||||
advanceSemutaSpiritualAmbassadorArrival as advance, semutaSpiritualAmbassadorArrivalClean as clean,
|
||||
semutaSpiritualAmbassadorArrivalPlayer as player, semutaSpiritualAmbassadorArrivalInventory as inventory,
|
||||
semutaSpiritualAmbassadorArrivalPhysical as physical, type SemutaSpiritualAmbassadorArrivalFixture,
|
||||
} from './fixture-semuta-spiritual-ambassador-arrival';
|
||||
|
||||
const spiritualLogs = (game: Game, owner: string) => game.log.filter(row => row.text.startsWith(`${player(game, owner).name} sent 1 free`));
|
||||
const guildLogs = (game: Game) => game.log.filter(row => row.text.includes('for free through the Guild Ambassador. Their ordinary shipment'));
|
||||
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: SemutaSpiritualAmbassadorArrivalFixture) {
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.deepEqual(f.setup.expansions, ['choam', 'ecaz']); assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
|
||||
assert.deepEqual(f.game.players.map(p => p.faction), ['richese', 'choam', 'ecaz', 'beneGesserit', 'atreides', 'emperor']);
|
||||
assert.deepEqual(f.afterSetup.players.flatMap(p => p.hand.map(c => c.id)), f.deal);
|
||||
assert.ok(player(f.afterSetup, f.spender).hand.some(c => c.id === f.cost && c.effect === 'karama'));
|
||||
assert.equal(f.packet.scalars.length, 9); assert.equal(f.packetCreation.steps.length, 1);
|
||||
assert.equal(f.advisorSetup.steps[0].action.type, 'advisorSetup');
|
||||
assert.equal(player(f.afterSetup, f.owner).forces['arrakeen:10'], 1); assert.equal(player(f.afterSetup, f.entrant).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.equal(player(f.placement.before, f.ecaz).spice - player(f.placement.after, f.ecaz).spice, 1);
|
||||
assert.ok(f.placement.steps.some(step => step.action.token === f.token));
|
||||
assert.deepEqual(f.entrantShipment.steps[0], { actor: f.entrant, action: f.paidAction });
|
||||
assert.equal(player(f.entrantShipment.before, f.entrant).spice - player(f.entrantShipment.after, f.entrant).spice, 1);
|
||||
assert.equal(player(f.game, f.entrant).forces['carthag:11'], 1); assert.equal(player(f.game, f.entrant).shipped, true);
|
||||
assert.deepEqual(f.ordinaryDecline.steps, [{ actor: f.owner, action: { type: 'decision', accept: false } }]);
|
||||
assert.equal(f.trigger.before.pendingAmbassador?.stage, 'offer'); assert.equal(f.trigger.after.pendingAmbassador?.stage, 'ship');
|
||||
assert.deepEqual(f.trigger.steps[0], { actor: f.ecaz, action: f.triggerAction });
|
||||
assert.equal(f.originalAmbassador.event, f.event); assert.equal(f.originalAmbassador.effect, 'guild');
|
||||
assert.equal(f.originalAmbassador.beneficiary, f.ecaz); assert.equal(f.originalAmbassador.entrant, f.entrant);
|
||||
assert.equal(f.game.ecazAmbassadors!.tokens.find(t => t.id === f.token)?.zone, 'used');
|
||||
assert.deepEqual(f.grant.steps[0], { actor: f.ecaz, action: f.grantAction });
|
||||
assert.equal(player(f.grant.before, f.ecaz).spice, player(f.grant.after, f.ecaz).spice);
|
||||
assert.equal(player(f.grant.before, f.ecaz).reserves - player(f.grant.after, f.ecaz).reserves, 1);
|
||||
assert.equal(player(f.grant.before, f.ecaz).shipped, player(f.grant.after, f.ecaz).shipped);
|
||||
assert.equal(player(f.grant.before, f.ecaz).moved, player(f.grant.after, f.ecaz).moved);
|
||||
assert.equal(player(f.game, f.ecaz).forces[f.guildLocation], 1);
|
||||
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.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'));
|
||||
assert.equal(f.originalFrame.stage, 'arrival'); assert.equal(f.originalFrame.decision.ambassadorEvent, f.event);
|
||||
assert.equal(f.originalFrame.decision.shipment, f.ecaz); assert.equal(f.originalFrame.decision.destination, f.guildLocation);
|
||||
assert.deepEqual(f.originalFrame.source, f.originalSpiritualSource);
|
||||
assert.deepEqual(f.originalDeposit, { key: f.location, before: 0 });
|
||||
assert.equal(f.originalAmbassador.stage, 'arrival'); assert.equal(f.originalReceipt.next, 'advisor');
|
||||
assert.equal(f.originalReceipt.advisorArrival, undefined);
|
||||
assert.equal(f.originalReceipt.order.player, f.ecaz); assert.equal(f.originalReceipt.order.amount, 1);
|
||||
assert.equal(f.originalReceipt.order.territory, f.guildTerritory); assert.equal(f.originalReceipt.order.sector, f.guildSector);
|
||||
assert.equal(f.originalAutomaticSource.producer, 'spiritualArrival');
|
||||
assert.deepEqual(f.originalAutomaticSource.spiritual, f.originalSpiritualSource);
|
||||
assert.deepEqual(f.originalAutomaticSource.forces, { [f.location]: 1 });
|
||||
assert.deepEqual(f.originalQueue, [f.originalAutomaticSource]);
|
||||
assert.equal(player(f.game, f.owner).reserves, player(f.declaration.before, f.owner).reserves - 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(spiritualLogs(f.game, f.owner).length, 1); assert.equal(guildLogs(f.game).length, 1);
|
||||
assert.equal(f.game.active, f.entrant); assert.equal(f.game.phase, 5); assert.equal(f.game.turn, 1);
|
||||
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: SemutaSpiritualAmbassadorArrivalFixture) {
|
||||
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 real Guild Spiritual-arrival 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.pendingAmbassador, f.originalParent);
|
||||
assert.deepEqual(game.pendingAmbassador?.shipmentReceipt, f.originalReceipt);
|
||||
assert.deepEqual(game.pendingAutomaticAdvisorFlips, f.originalQueue); assert.deepEqual(game.pendingArrivalReaction ?? null, f.originalPaidQueue);
|
||||
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.ecazAmbassadors, f.originalTokens); 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(c => c.id === f.cost), false);
|
||||
assert.equal(game.discard.filter(c => c.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: SemutaSpiritualAmbassadorArrivalFixture, 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.pendingAmbassador ?? null, null); 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, 'Original paid entry, Guild reserve and Spiritual deposit are never replayed');
|
||||
assert.equal(game.active, f.entrant); assert.equal(player(game, f.owner).forces[f.location], 1);
|
||||
assert.equal(player(game, f.ecaz).forces[f.guildLocation], 1); assert.equal(player(game, f.entrant).forces['carthag:11'], 1);
|
||||
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.deepEqual(player(game, f.owner).advisors?.[f.territory], f.originalLocks);
|
||||
} else assert.equal(player(game, f.owner).advisors?.[f.territory], undefined);
|
||||
assert.deepEqual(quoteAutomaticAdvisorFlips(game), []);
|
||||
assert.deepEqual(game.ecazAmbassadors, f.originalTokens); assert.deepEqual(game.deck, f.originalDeck);
|
||||
assert.equal(spiritualLogs(game, f.owner).length, 1); assert.equal(guildLogs(game).length, 1);
|
||||
assert.ok(steps.every(step => !['ship', 'move', 'card', 'endMovement'].includes(step.action.type)));
|
||||
reject(game, f.ecaz, f.triggerAction); reject(game, f.ecaz, f.grantAction); reject(game, f.owner, f.chooseAction);
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
function canceled(state: Game, f: SemutaSpiritualAmbassadorArrivalFixture, claimed: boolean) {
|
||||
const game = settled(state, f, true);
|
||||
assert.equal(player(game, f.holder).hand.filter(c => c.id === f.cost).length, Number(claimed));
|
||||
assert.equal(game.discard.filter(c => c.id === f.cost).length, Number(!claimed));
|
||||
assert.equal(player(game, f.holder).hand.filter(c => c.id === f.semuta).length, Number(!claimed));
|
||||
assert.equal(game.discard.filter(c => c.id === f.semuta).length, Number(claimed));
|
||||
reject(game, f.spender, f.action); return game;
|
||||
}
|
||||
function continueMovement(state: Game, f: SemutaSpiritualAmbassadorArrivalFixture, prevented: boolean) {
|
||||
const steps: { actor: string; action: Action }[] = [];
|
||||
let game = act(state, f.entrant, { type: 'endMovement' }, steps);
|
||||
game = advance(game, g => g.phase >= 6 && g.phase <= 7 && clean(g), steps);
|
||||
assert.equal(steps.filter(step => step.actor === f.entrant && step.action.type === 'endMovement').length, 1);
|
||||
assert.ok(steps.every(step => !['ship', 'move', 'card'].includes(step.action.type)));
|
||||
assert.equal(game.pendingAmbassador ?? null, null); assert.equal(game.pendingSpiritualAdvisor, undefined);
|
||||
assert.equal(game.pendingAutomaticAdvisorFlips, undefined); assert.equal(spiritualLogs(game, f.owner).length, 1); assert.equal(guildLogs(game).length, 1);
|
||||
assert.equal(player(game, f.owner).forces[f.location], 1); assert.equal(player(game, f.ecaz).forces[f.guildLocation], 1);
|
||||
assert.equal(player(game, f.entrant).forces['carthag:11'], 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);
|
||||
}
|
||||
|
||||
for (const claimed of [false, true]) void test(
|
||||
`Advanced native Guild Ambassador Spiritual arrival cost Semuta ${claimed ? 'claim' : 'pass'} preserves committed child and outer 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 Guild Ambassador Spiritual allowance converts the placed Polar advisor before the original child and outer suffix finish', () => {
|
||||
const f = fixture(); nativeSource(f); const game = settled(allow(structuredClone(f.game)), f, false);
|
||||
assert.ok(player(game, f.spender).hand.some(c => c.id === f.cost)); assert.ok(player(game, f.holder).hand.some(c => c.id === f.semuta));
|
||||
continueMovement(game, f, false);
|
||||
});
|
||||
|
||||
void test('fresh Guild Spiritual arrival cost rejects stale and underlying declarations while both original parents and deposit stay 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.ecaz, f.grantAction); reject(game, f.ecaz, f.triggerAction); reject(game, f.entrant, f.paidAction);
|
||||
reject(game, f.entrant, { type: 'endMovement' });
|
||||
assert.deepEqual(game.pendingSpiritualAdvisor, f.originalFrame); assert.deepEqual(game.pendingAmbassador, f.originalAmbassador);
|
||||
assert.deepEqual(physical(game), f.originalPhysical); canceled(pass(game), f, false);
|
||||
});
|
||||
|
||||
void test('controlled Guild Spiritual source deposit and Ambassador suffix mismatches reject before cost and fresh appropriation', () => {
|
||||
const f = fixture();
|
||||
// Deliberately invalid negative copies only; the genuine primary history
|
||||
// never installs a frame, source, token, force or parent continuation.
|
||||
const changes: ((game: Game) => void)[] = [
|
||||
game => { game.pendingSpiritualAdvisor!.signature += ':changed'; },
|
||||
game => { game.pendingSpiritualAdvisor!.source = { ...game.pendingSpiritualAdvisor!.source!, event: 'different-original-packet' }; },
|
||||
game => { game.pendingSpiritualAdvisor!.deposit = { ...game.pendingSpiritualAdvisor!.deposit!, before: 1 }; },
|
||||
game => { game.pendingAmbassador!.shipmentReceipt!.next = 'finish'; },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const beforeCost = structuredClone(f.game); change(beforeCost); reject(beforeCost, f.spender, f.action);
|
||||
assert.ok(player(beforeCost, f.spender).hand.some(c => c.id === f.cost));
|
||||
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(c => c.id === f.semuta));
|
||||
assert.equal(offered.discard.filter(c => c.id === f.cost).length, 1); assert.deepEqual(inventory(offered), f.originalInventory);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal profiles legally answer the actual Guild Ambassador 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,225 @@
|
||||
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 { spiritualAdvisorMatchingKey } from '../game/spiritual-advisor';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
createSemutaSpiritualAmbassadorCostFixture as fixture, actSemutaSpiritualAmbassadorCost as act,
|
||||
passSemutaSpiritualAmbassadorCost as pass, claimSemutaSpiritualAmbassadorCost as claim,
|
||||
allowSemutaSpiritualAmbassadorCost as allow, finishSemutaSpiritualAmbassadorCost as finish,
|
||||
semutaSpiritualAmbassadorCostPlayer as player, semutaSpiritualAmbassadorCostInventory as inventory,
|
||||
semutaSpiritualAmbassadorCostPhysical as physical, semutaSpiritualAmbassadorCostClean as clean,
|
||||
type SemutaSpiritualAmbassadorCostFixture,
|
||||
} from './fixture-semuta-spiritual-ambassador-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: SemutaSpiritualAmbassadorCostFixture) {
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.deepEqual(f.setup.expansions, ['choam', 'ecaz']);
|
||||
assert.deepEqual(f.game.players.map(player => player.faction), ['richese', 'choam', 'ecaz', 'beneGesserit', 'atreides']);
|
||||
assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0));
|
||||
assert.deepEqual(f.afterSetup.players.flatMap(player => player.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.equal(f.packet.scalars.length, 9); assert.ok(f.packetCreation && f.advisorSetup);
|
||||
const packetAfter = f.packetCreation.after.ecazAmbassadors!;
|
||||
assert.deepEqual(packetAfter.cohort.map(id => packetAfter.tokens.find(token => token.id === id)!.effect), f.packet.supply);
|
||||
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.price, 1); assert.equal(f.acquisition.bid.actor, f.holder);
|
||||
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquired, f.holder).spice, 1);
|
||||
assert.equal(player(f.acquired, f.richese).spice - player(f.acquisition.before, f.richese).spice, 1);
|
||||
assert.equal(player(f.placement.before, f.ecaz).spice - player(f.placement.after, f.ecaz).spice, 1);
|
||||
assert.ok(f.bgTurn.steps.some(step => step.actor === f.owner && step.action.type === 'endMovement'));
|
||||
assert.deepEqual(f.paidEntry.steps[0], { actor: f.entrant, action: f.paidShipmentAction });
|
||||
assert.equal(player(f.paidEntry.before, f.entrant).spice - player(f.paidEntry.after, f.entrant).spice, 1);
|
||||
assert.equal(player(f.game, f.entrant).forces['carthag:11'], 1); assert.equal(player(f.game, f.entrant).shipped, true);
|
||||
assert.equal(f.offer.pendingAmbassador?.stage, 'offer'); assert.equal(f.originalToken.effect, 'guild');
|
||||
assert.equal(f.originalToken.zone, 'used');
|
||||
assert.equal(f.trigger.after.pendingAmbassador?.beneficiary, f.shipper);
|
||||
assert.deepEqual(f.foreignShipment.steps[0], { actor: f.shipper, action: f.foreignShipmentAction });
|
||||
assert.equal(player(f.foreignShipment.before, f.shipper).spice, player(f.game, f.shipper).spice);
|
||||
assert.equal(player(f.foreignShipment.before, f.shipper).reserves - player(f.game, f.shipper).reserves, 1);
|
||||
assert.equal(player(f.game, f.shipper).forces[f.foreignLocation], 1);
|
||||
assert.equal(player(f.game, f.shipper).shipped, player(f.foreignShipment.before, f.shipper).shipped);
|
||||
assert.equal(player(f.game, f.shipper).moved, player(f.foreignShipment.before, f.shipper).moved);
|
||||
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, f.event);
|
||||
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 && f.shippingAllowance.steps.length > 0);
|
||||
assert.ok(f.shippingAllowance.steps.every(step => step.action.type === 'passResponse'));
|
||||
assert.equal(new Set(f.shippingAllowance.steps.map(step => step.actor)).size, f.shippingAllowance.steps.length);
|
||||
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.entrant);
|
||||
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.pendingAmbassador, f.originalAmbassador);
|
||||
assert.equal(f.originalAmbassador.shipmentReceipt?.next, 'advisor');
|
||||
assert.deepEqual(f.originalAmbassador.shipmentReceipt, f.originalForeignReceipt);
|
||||
assert.deepEqual(f.game.pendingArrivalReaction ?? null, f.originalQueue);
|
||||
assert.deepEqual(physical(f.game), physical(f.beforeDeclaration)); assert.equal(grantLogs(f.game, f.owner).length, 0);
|
||||
assert.equal(f.sourceActionCount, f.actions.length); assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
}
|
||||
function open(f: SemutaSpiritualAmbassadorCostFixture) {
|
||||
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('Use the original Guild-derived Spiritual cost');
|
||||
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.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.pendingAmbassador, f.originalAmbassador);
|
||||
assert.deepEqual(game.pendingArrivalReaction ?? null, f.originalQueue);
|
||||
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 denied(game: Game, f: SemutaSpiritualAmbassadorCostFixture, claimed: boolean) {
|
||||
game = finish(game); 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.pendingAmbassador, f.originalAmbassador);
|
||||
assert.deepEqual(game.pendingArrivalReaction ?? null, f.originalQueue);
|
||||
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.pendingAmbassador ?? 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;
|
||||
}
|
||||
function settled(game: Game, f: SemutaSpiritualAmbassadorCostFixture, placed: boolean, target = f.territory) {
|
||||
game = finish(game); assert.equal(clean(game), true);
|
||||
assert.equal(game.pendingSpiritualAdvisor ?? null, null); assert.equal(game.pendingAmbassador ?? null, null);
|
||||
assert.equal(game.pendingArrivalReaction?.length ?? 0, 0); assert.equal(game.pendingTreacheryDiscard ?? null, null);
|
||||
assert.deepEqual(clock(game), clock(f.game)); assert.deepEqual(inventory(game), f.originalInventory);
|
||||
assert.equal(grantLogs(game, f.owner).length, Number(placed));
|
||||
const bg = player(game, f.owner), original = player(f.game, f.owner);
|
||||
assert.equal(bg.reserves, original.reserves - Number(placed)); 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(placed && target === f.territory));
|
||||
if (placed && target === f.foreignDestination && f.stage === 'matching') assert.equal(bg.forces[f.foreignLocation], 1);
|
||||
for (const seat of game.players.filter(seat => seat.id !== f.owner))
|
||||
assert.deepEqual(physical(game).find(player => player.id === seat.id), f.originalPhysical.find(player => player.id === seat.id));
|
||||
if (!placed) assert.deepEqual(physical(game), f.originalPhysical);
|
||||
else assert.equal(isAdvisor(bg, target), f.stage === 'shipping' || target === f.foreignDestination);
|
||||
assert.equal(player(game, f.shipper).forces[f.foreignLocation], 1);
|
||||
assert.equal(player(game, f.entrant).forces['carthag:11'], 1);
|
||||
assert.deepEqual(game.ecazAmbassadors!.tokens.find(token => token.id === f.token), f.originalToken);
|
||||
reject(game, f.owner, f.nativeAction); return game;
|
||||
}
|
||||
|
||||
for (const stage of ['shipping', 'matching'] as const) for (const claimed of [false, true]) void test(
|
||||
`native Guild Ambassador Spiritual ${stage} Karama cost Semuta ${claimed ? 'claim' : 'pass'} preserves its committed grant and applies native denial once`, () => {
|
||||
const f = fixture({ stage }); nativeSource(f);
|
||||
const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
let game = denied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
reject(game, f.holder, { type: 'semutaCommit', event }); reject(game, f.holder, { type: 'semutaPass', event });
|
||||
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 Guild Ambassador Spiritual ${stage} allowance places the real free packet once without another token or foreign shipment`, () => {
|
||||
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 Guild Ambassador Spiritual matching denial preserves the real legal 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('Guild-derived Spiritual costs reject stale and underlying controls while their exact original child stays parked', () => {
|
||||
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.entrant, { type: 'endMovement' });
|
||||
reject(game, f.shipper, f.foreignShipmentAction);
|
||||
assert.deepEqual(game.pendingAmbassador, f.originalAmbassador); assert.deepEqual(physical(game), f.originalPhysical);
|
||||
denied(pass(game), f, false);
|
||||
}
|
||||
});
|
||||
|
||||
void test('changed Guild parent or Spiritual source rejects before cost and before a fresh Semuta answer', () => {
|
||||
for (const stage of ['shipping', 'matching'] as const) {
|
||||
const f = fixture({ stage });
|
||||
for (const changed of ['parent', 'source'] as const) {
|
||||
const game = structuredClone(f.game);
|
||||
if (changed === 'parent') game.pendingSpiritualAdvisor!.signature = 'different-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));
|
||||
}
|
||||
const game = open(f); assert.ok(game.pendingAmbassador);
|
||||
game.pendingAmbassador.event = 'different-original-ambassador';
|
||||
reject(game, f.holder, { type: 'semutaCommit', event: game.pendingTreacheryDiscard!.batch.event });
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal policies answer the actual Guild-derived Spiritual shipping and matching costs legally', () => {
|
||||
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(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);
|
||||
game = denied(game, f, claimed);
|
||||
if (stage === 'matching') game = act(game, f.owner, f.decline);
|
||||
settled(game, f, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, initializeSemutaGameForAudit, 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 { homeworldGameIntegrity, homeworldTable } from '../game/homeworld-game';
|
||||
import { spiritualAdvisorMatchingKey } from '../game/spiritual-advisor';
|
||||
import { quoteAutomaticAdvisorFlips } from '../game/advisor-automatic-flip';
|
||||
import {
|
||||
createSemutaSpiritualRevivalFixture as fixture, actSemutaSpiritualRevival as act,
|
||||
passSemutaSpiritualRevival as pass, claimSemutaSpiritualRevival as claim, allowSemutaSpiritualRevival as allow,
|
||||
finishSemutaSpiritualRevival as finish, semutaSpiritualRevivalPlayer as player,
|
||||
semutaSpiritualRevivalInventory as inventory, semutaSpiritualRevivalPhysical as physical,
|
||||
type SemutaSpiritualRevivalFixture,
|
||||
} from './fixture-semuta-spiritual-revival';
|
||||
|
||||
const stages = ['shipping', 'matching', 'arrival'] as const;
|
||||
const balances = (game: Game) => Object.fromEntries(game.players.map(p => [p.id, p.spice]));
|
||||
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 placements = (game: Game, bg: string) => game.log.filter(entry => entry.text.startsWith(`${player(game, bg).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: SemutaSpiritualRevivalFixture) {
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.deepEqual(f.setup.expansions, ['choam', 'ix']); assert.equal(f.setup.homeworlds?.custody, null);
|
||||
assert.ok(f.afterSetup.homeworlds?.custody); assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
|
||||
assert.deepEqual(f.enableHomeworlds.steps, [{ actor: f.game.host, action: { type: 'homeworlds', enabled: true } }]);
|
||||
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' && !card.id.startsWith('ix-')));
|
||||
assert.equal(player(f.afterSetup, f.reviver).faceDancers?.length, 3);
|
||||
assert.equal(player(f.afterSetup, f.reviver).reserves, 20); assert.equal(player(f.afterSetup, f.reviver).tanks, 0);
|
||||
assert.deepEqual(f.advisorSetup.steps[0].action, { type: 'advisorSetup',
|
||||
territory: f.stage === 'matching' ? 'polar_sink' : 'arrakeen', sector: f.stage === 'matching' ? 0 : 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.acquired, f.holder).spice, 1);
|
||||
assert.equal(player(f.acquired, f.seller).spice - player(f.acquisition.before, f.seller).spice, 1);
|
||||
assert.deepEqual(f.firstStorm.steps.filter(step => step.action.type === 'stormDial').map(step => step.action.amount), [11, 0]);
|
||||
assert.equal(f.firstStorm.after.storm, 12); assert.deepEqual(f.shipment.steps[0], { actor: f.reviver, action: f.shipmentAction });
|
||||
assert.equal(player(f.shipment.before, f.reviver).spice - player(f.shipment.after, f.reviver).spice, 2);
|
||||
assert.equal(player(f.shipment.after, f.reviver).forces[f.casualtyLocation], 1);
|
||||
assert.deepEqual(f.firstSpiritualDecline.steps, [{ actor: f.owner, action: { type: 'decision', accept: false } }]);
|
||||
assert.equal(f.beforeStormMovement.storm, 12); assert.equal(f.beforeStormMovement.stormPending, 1);
|
||||
assert.equal(f.stormLoss.after.storm, 13); assert.equal(player(f.stormLoss.after, f.reviver).tanks, 1);
|
||||
assert.equal(player(f.beforeRequest, f.reviver).reserves, 19); assert.equal(player(f.beforeRequest, f.reviver).tanks, 1);
|
||||
assert.equal(player(f.beforeRequest, f.reviver).revived, 0);
|
||||
assert.equal(homeworldTable(f.beforeRequest)!.find(world => world.native === f.reviver)!.side, 'high');
|
||||
assert.deepEqual(f.freeRevive.steps[0], { actor: f.reviver, action: f.revivalAction });
|
||||
assert.equal(f.returned.homeworldRevivalReturn?.stage, 'choice'); assert.equal(f.returned.decision?.kind, 'homeworldRevivalDeployment');
|
||||
assert.equal(player(f.returned, f.reviver).tanks, 0); assert.equal(player(f.returned, f.reviver).reserves, 20);
|
||||
assert.equal(player(f.returned, f.reviver).revived, 1); assert.equal(player(f.returned, f.reviver).freeForcesRevived, 1);
|
||||
assert.equal(player(f.returned, f.reviver).spice, player(f.beforeRequest, f.reviver).spice);
|
||||
assert.deepEqual(f.send.steps[0], { actor: f.reviver, action: f.sendAction });
|
||||
assert.equal(player(f.game, f.reviver).forces[f.destination], 1); assert.equal(player(f.game, f.reviver).reserves, 19);
|
||||
assert.equal(f.originalReturn.stage, 'arrival'); assert.equal(f.originalReturn.source, 'normal');
|
||||
assert.equal(f.originalReturn.quote.kind, 'tleilax'); assert.deepEqual(f.originalReturn.group, { amount: 1, elite: 0, free: 1 });
|
||||
assert.equal(f.originalReturn.destination, f.destination); assert.equal(f.originalReturn.quote.beforePopulation, 19);
|
||||
assert.equal(f.originalReturn.quote.afterPopulation, 20); assert.equal(f.originalRetainedIncome?.kind, 'revivalIncome');
|
||||
assert.equal(f.originalRetainedIncome?.amount, 1); assert.equal(f.originalRetainedIncome?.owner, f.reviver);
|
||||
assert.equal(f.originalPendingRevival, null); assert.equal(f.game.pendingRevival ?? null, null);
|
||||
assert.equal(f.originalProgress.event, f.originalReturn.event); assert.equal(f.originalProgress.stage, 'arrival');
|
||||
assert.deepEqual(f.game.homeworldRevivalReturn, f.originalReturn); assert.deepEqual(f.game.homeworldRevivalProgress, f.originalProgress);
|
||||
assert.equal(f.originalChooser.revivalEvent, f.originalReturn.event); assert.equal(f.originalChooser.shipment, f.reviver);
|
||||
assert.equal(f.originalChooser.destination, f.destination); assert.deepEqual(f.declaration.steps, [{ actor: f.owner, action: f.nativeAction }]);
|
||||
assert.equal(f.originalSource.phase, 4); assert.equal(f.originalSource.revivalEvent, f.originalReturn.event);
|
||||
assert.equal(f.originalSource.owner, f.owner); assert.equal(f.originalSource.amount, 1);
|
||||
assert.equal(f.originalFrame.stage, f.stage); assert.deepEqual(f.originalFrame.source, f.originalSource);
|
||||
assert.equal(f.game.phase, 4); assert.equal(f.game.turn, 2); assert.equal(f.game.decision ?? null, null);
|
||||
assert.equal(f.game.response?.kind, f.stage === 'shipping' ? 'advisor' : 'advisorFlip');
|
||||
assert.deepEqual(f.game.pendingArrivalReaction, f.originalArrival);
|
||||
assert.ok(f.originalArrival.some(arrival => arrival.entrant === f.reviver && arrival.cause === 'homeworldRevival' &&
|
||||
arrival.revivalArrival?.event === f.originalReturn.event));
|
||||
if (f.stage === 'arrival') {
|
||||
assert.ok(f.originalAutomaticSource?.producer === 'spiritualArrival');
|
||||
assert.deepEqual(f.originalAutomaticSource.spiritual, f.originalSource); assert.deepEqual(f.originalQueue, [f.originalAutomaticSource]);
|
||||
assert.deepEqual(f.originalDeposit, { key: f.location, before: 0 });
|
||||
assert.equal(player(f.game, f.owner).reserves, player(f.beforeDeclaration, f.owner).reserves - 1);
|
||||
assert.equal(player(f.game, f.owner).forces[f.location], 1); assert.equal(isAdvisor(player(f.game, f.owner), f.territory), true);
|
||||
assert.equal(player(f.game, f.owner).advisors?.[f.territory]?.aloneFlipHold, f.originalAutomaticSource.event);
|
||||
assert.equal(placements(f.game, f.owner).length, 1);
|
||||
} else {
|
||||
assert.equal(f.originalDeposit, null); assert.deepEqual(f.originalQueue, []);
|
||||
assert.deepEqual(physical(f.game), physical(f.beforeDeclaration)); assert.equal(placements(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, 57); assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
assert.equal(f.sourceActionCount, f.actions.length); homeworldGameIntegrity(f.game);
|
||||
}
|
||||
function open(f: SemutaSpiritualRevivalFixture) {
|
||||
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 the original Revival Spiritual cancellation cost');
|
||||
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.homeworldRevivalReturn, f.originalReturn); assert.deepEqual(game.homeworldRevivalProgress, f.originalProgress);
|
||||
assert.deepEqual(game.pendingSpiritualAdvisor, f.originalFrame); assert.deepEqual(game.pendingArrivalReaction, f.originalArrival);
|
||||
assert.deepEqual(game.pendingAutomaticAdvisorFlips ?? [], f.originalQueue); assert.equal(game.pendingRevival ?? null, null);
|
||||
assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(balances(game), f.originalBalances);
|
||||
assert.deepEqual(player(game, f.owner).advisors, f.originalStance); assert.deepEqual(clock(game), clock(f.game));
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
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: SemutaSpiritualRevivalFixture, granted: boolean, held = false, target = f.territory) {
|
||||
const game = finish(state); assert.equal(game.homeworldRevivalReturn?.stage, 'complete');
|
||||
assert.equal(game.homeworldRevivalProgress?.stage, 'complete'); assert.equal(game.homeworldRevivalReturn?.event, f.originalReturn.event);
|
||||
assert.equal(game.pendingSpiritualAdvisor ?? null, null); assert.equal(game.pendingRevival ?? null, null);
|
||||
assert.equal(game.pendingArrivalReaction?.length ?? 0, 0); assert.deepEqual(game.pendingAutomaticAdvisorFlips ?? [], []);
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.deepEqual(clock(game), clock(f.game)); assert.equal(game.phase, 4); assert.equal(game.turn, 2);
|
||||
for (const p of game.players) assert.equal(p.spice, f.originalBalances[p.id] + Number(p.id === f.reviver));
|
||||
const reviver = player(game, f.reviver), before = player(f.game, f.reviver), bg = player(game, f.owner);
|
||||
assert.equal(reviver.forces[f.destination], 1); assert.equal(reviver.reserves, 19); assert.equal(reviver.tanks, 0);
|
||||
assert.equal(reviver.revived, 1); assert.equal(reviver.freeForcesRevived, 1);
|
||||
assert.equal(reviver.shipped, before.shipped); assert.equal(reviver.moved, before.moved);
|
||||
assert.equal(bg.spice, player(f.game, f.owner).spice); assert.equal(bg.shipped, player(f.game, f.owner).shipped);
|
||||
assert.equal(bg.moved, player(f.game, f.owner).moved); assert.equal(bg.revived, player(f.game, f.owner).revived);
|
||||
const newlyWithdrawn = Number(granted && f.stage !== 'arrival');
|
||||
assert.equal(bg.reserves, player(f.game, f.owner).reserves - newlyWithdrawn);
|
||||
assert.equal(placements(game, f.owner).length, Number(granted));
|
||||
if (granted) {
|
||||
const key = target === 'polar_sink' ? 'polar_sink:0' : f.destination;
|
||||
assert.equal(bg.forces[key], (player(f.beforeDeclaration, f.owner).forces[key] ?? 0) + 1);
|
||||
assert.equal(isAdvisor(bg, target), f.stage === 'arrival' ? held : target !== 'polar_sink');
|
||||
}
|
||||
if (!newlyWithdrawn) assert.deepEqual(physical(game), f.originalPhysical);
|
||||
if (held) assert.deepEqual(bg.advisors, f.originalStance);
|
||||
assert.deepEqual(quoteAutomaticAdvisorFlips(game), []); homeworldGameIntegrity(game);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
reject(game, f.reviver, f.revivalAction); reject(game, f.reviver, f.sendAction); reject(game, f.owner, f.nativeAction);
|
||||
return game;
|
||||
}
|
||||
function denied(state: Game, f: SemutaSpiritualRevivalFixture, claimed: boolean) {
|
||||
let game = finish(state);
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.response ?? null, null);
|
||||
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(physical(game), f.originalPhysical); assert.deepEqual(balances(game), f.originalBalances);
|
||||
assert.deepEqual(game.homeworldRevivalReturn, f.originalReturn); assert.deepEqual(game.homeworldRevivalProgress, f.originalProgress);
|
||||
assert.deepEqual(game.decision, f.originalChooser); assert.equal(game.pendingSpiritualAdvisor?.stage, 'offer');
|
||||
assert.deepEqual(game.pendingSpiritualAdvisor?.denied, [spiritualAdvisorMatchingKey(f.originalSource)]);
|
||||
reject(game, f.owner, f.sink); assert.deepEqual(game.pendingArrivalReaction, f.originalArrival);
|
||||
game = act(game, f.owner, f.decline);
|
||||
}
|
||||
const done = settled(game, f, f.stage === 'arrival', f.stage === 'arrival'); reject(done, f.spender, f.action); return done;
|
||||
}
|
||||
|
||||
for (const stage of stages) for (const claimed of [false, true]) void test(
|
||||
`native Revival SEND Spiritual ${stage} Karama cost Semuta ${claimed ? 'claim' : 'pass'} preserves the real return and finishes retained income once`, () => {
|
||||
const f = fixture({ stage }); nativeSource(f); const offered = open(f);
|
||||
denied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
},
|
||||
);
|
||||
|
||||
for (const stage of stages) void test(`native Revival SEND Spiritual ${stage} allowance completes only the actual free grant and retained original income`, () => {
|
||||
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 Revival SEND matching cost denial retains real accompaniment as an independent legal replacement', () => {
|
||||
for (const claimed of [false, true]) {
|
||||
const f = fixture({ stage: 'matching' }), offered = open(f);
|
||||
let game = finish(claimed ? claim(offered, f.holder) : pass(offered));
|
||||
assert.deepEqual(game.decision, f.originalChooser); assert.ok(f.replacementAction);
|
||||
game = act(game, f.owner, f.replacementAction);
|
||||
if (game.response?.spiritualAdvisorSource) game = allow(game);
|
||||
settled(game, f, true, false, 'arrakeen');
|
||||
}
|
||||
});
|
||||
|
||||
void test('native Revival SEND Spiritual cost rejects stale foreign and underlying return or chooser actions', () => {
|
||||
for (const stage of stages) {
|
||||
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.reviver, f.revivalAction); reject(game, f.reviver, f.sendAction);
|
||||
reject(game, f.owner, f.nativeAction); reject(game, f.owner, f.decline); reject(game, f.owner, { type: 'passResponse' });
|
||||
const done = denied(pass(game), f, false); reject(done, f.holder, { type: 'semutaCommit', event });
|
||||
}
|
||||
});
|
||||
|
||||
void test('controlled changed original Revival SEND progress or Spiritual signature rejects before the native cost is spent', () => {
|
||||
for (const stage of stages) for (const changed of ['progress', 'spiritual'] as const) {
|
||||
const f = fixture({ stage }), game = structuredClone(f.game);
|
||||
// Explicit malformed-parent supplement; primary return, stock, casualty,
|
||||
// custody, hands and clocks are all earned through the original programme.
|
||||
if (changed === 'progress') game.homeworldRevivalProgress!.signature += ':changed';
|
||||
else game.pendingSpiritualAdvisor!.signature += ':changed';
|
||||
reject(game, f.spender, f.action); assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
|
||||
}
|
||||
});
|
||||
|
||||
void test('fresh undealt Tleilax Semuta World configuration negatives retain Basic and extra-deck admission gates without mutation', () => {
|
||||
const f = fixture();
|
||||
for (const variant of ['basic', 'extra-ecaz-deck'] as const) {
|
||||
// Configuration negatives on a captured genuinely ready undealt lobby,
|
||||
// not a played Basic-to-Advanced conversion or a claimed native programme.
|
||||
const config = structuredClone(f.lobby);
|
||||
assert.ok(config.players.every(p => p.hand.length === 0)); assert.notEqual(config.status, 'playing');
|
||||
if (variant === 'basic') config.advanced = false;
|
||||
else config.expansions.push('ecaz');
|
||||
const before = structuredClone(config);
|
||||
assert.throws(() => initializeSemutaGameForAudit(config)); assert.deepEqual(config, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal profiles legally claim or pass the three genuine Revival SEND Spiritual costs', () => {
|
||||
for (const stage of stages) {
|
||||
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 === 'semutaPass' || proposed.type === 'semutaCommit'); 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);
|
||||
denied(game, f, claimed);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user