Files
Dune/tests/fixture-semuta-spiritual-ambassador-arrival.ts

243 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>;