Files
Dune/tests/fixture-semuta-advisor-face-dance.ts

265 lines
20 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, viewGame, type Action, type Game } from '../game/engine';
import { traitorDeck } from '../game/traitors';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
import { advisorFaceDanceFlipTraitors } from './fixture-advisor-face-dance-flip';
import {
actSemutaCombatCapabilities, nextSemutaCombatCapabilities,
semutaCombatCapabilitiesPlayer, semutaCombatCapabilitiesInventory, semutaCombatCapabilitiesClean,
passSemutaCombatCapabilities, claimSemutaCombatCapabilities,
} from './fixture-semuta-combat-capabilities';
export type SemutaAdvisorFaceDanceStep = { actor: string; action: Action };
export const actSemutaAdvisorFaceDance = actSemutaCombatCapabilities;
export const semutaAdvisorFaceDancePlayer = semutaCombatCapabilitiesPlayer;
export const semutaAdvisorFaceDanceInventory = semutaCombatCapabilitiesInventory;
export const semutaAdvisorFaceDanceClean = semutaCombatCapabilitiesClean;
export const semutaAdvisorFaceDanceTraitors = advisorFaceDanceFlipTraitors;
export const passSemutaAdvisorFaceDance = passSemutaCombatCapabilities;
export const claimSemutaAdvisorFaceDance = claimSemutaCombatCapabilities;
export const semutaAdvisorFaceDancePhysical = (game: Game) => game.players.map(p => ({
id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks, elites: p.elites,
leaders: p.leaders, battleLosses: p.battleLosses, faceDancers: p.faceDancers,
}));
/** Native controls only. Face Dance and a completed replacement's conversion
* remain explicit caller choices; actual earlier Semuta groups receive passes. */
export function nextSemutaAdvisorFaceDance(game: Game): SemutaAdvisorFaceDanceStep | 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 } };
if (game.status === 'setup' && game.setupStage === 'traitors') {
const p = game.players.find(p => p.traitorChoices.length); assert.ok(p);
return { actor: p.id, action: { type: 'traitor', leader: p.traitorChoices[0] } };
}
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 === 'faceDance') return null;
if (d?.kind === 'advisorBattle' || d?.kind === 'advisor' || d?.kind === 'intrusion')
return { actor: d.player, action: { type: 'decision', accept: false } };
if (d?.kind === 'revivalStop' || d?.kind === 'fullPlanOffer')
return { actor: d.player, action: { type: 'decision', decline: true } };
if (d?.kind === 'fullPlanRead') return { actor: d.player, action: { type: 'decision', continue: true } };
}
return nextSemutaCombatCapabilities(game);
}
export const nextSemutaAdvisorFaceDanceStep = nextSemutaAdvisorFaceDance;
export function advanceSemutaAdvisorFaceDance(game: Game, until: (state: Game) => boolean,
actions: SemutaAdvisorFaceDanceStep[] = []): Game {
for (let count = 0; count < 3600; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const next = nextSemutaAdvisorFaceDance(game);
assert.ok(next, `Supply the real Face Dance/private plan at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actSemutaAdvisorFaceDance(game, next.actor, next.action, actions);
}
throw Error('The native zero-stock Face Dance programme did not reach its finite boundary');
}
export function allowSemutaAdvisorFaceDance(game: Game, actions: SemutaAdvisorFaceDanceStep[] = []): Game {
assert.ok(game.response?.automaticAdvisorFlip?.producer === 'faceDance');
for (let count = 0; game.response?.automaticAdvisorFlip?.producer === 'faceDance' && count < game.players.length; count++) {
const p = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(p);
game = actSemutaAdvisorFaceDance(game, p.id, { type: 'passResponse' }, actions);
}
assert.notEqual(game.response?.automaticAdvisorFlip?.producer, 'faceDance'); return game;
}
/** Complete only original aftermath/Collection entry; no new return, Face
* Dance, shipment or battle declaration is made by this driver. */
export function finishSemutaAdvisorFaceDance(game: Game, actions: SemutaAdvisorFaceDanceStep[] = []): Game {
assert.ok(!game.pendingTreacheryDiscard);
return advanceSemutaAdvisorFaceDance(game, state => !state.battle && state.phase >= 7 && state.phase <= 8 &&
semutaAdvisorFaceDanceClean(state), actions);
}
/** Select only the original finishSetup Face Dancer Fisher-Yates shuffle on
* actual advisorSetup. The real engine deals the conserved physical cards. */
function dealOriginalFaceDancers(game: Game, step: SemutaAdvisorFaceDanceStep, actions: SemutaAdvisorFaceDanceStep[]) {
assert.equal(game.status, 'setup'); assert.equal(game.setupStage, 'forces'); assert.equal(step.action.type, 'advisorSetup');
assert.ok(game.players.every(p => !p.traitorChoices.length && !p.faceDancers));
const held = new Set(game.players.flatMap(p => p.traitors));
const original = traitorDeck(game.players, true).filter(id => !held.has(id));
const first = ['tleilaxu-3', 'tleilaxu-4', 'atreides-0']; assert.ok(first.every(id => original.includes(id)));
const order = [...first, ...original.filter(id => !first.includes(id))], 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);
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) {
assert.ok(cursor < scalars.length, 'Only the first actual Face Dancer shuffle consumes selected scalar entropy');
array[0] = scalars[cursor++];
} 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 { game: next, original, order, scalars };
} finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Real deferred bribes fund all twenty Tleilaxu reserves. The real losing
* battle then leaves no reserve or board counter available for replacement. */
export function createSemutaAdvisorFaceDanceFixture() {
const richese = 'richese', holder = 'choam', owner = 'beneGesserit', tleilaxu = 'tleilaxu', winner = 'atreides', spender = 'emperor';
const territory = 'arrakeen', sector = 10, location = `${territory}:${sector}`;
let game = createGame('SEMUTAADVISORFACEDANCE', newPlayer(richese, 'Richese', 'richese'), true, ['choam', 'ix']);
for (const faction of ['choam', 'beneGesserit', 'tleilaxu', 'atreides', 'emperor'] as const)
joinGame(game, newPlayer(faction, faction, faction));
const initial = structuredClone(game), actions: SemutaAdvisorFaceDanceStep[] = [];
const act = (actor: string, action: Action) => { game = actSemutaAdvisorFaceDance(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaAdvisorFaceDance(game, until, actions); };
const transition = (run: () => void) => {
const before = structuredClone(game), at = actions.length; run();
return { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) };
};
for (const p of game.players) act(p.id, { type: 'ready' });
const lobby = structuredClone(game);
game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.equal(game.semutaPreview, true);
assert.ok(game.players.every(p => p.hand.length === 0 && !p.faceDancers));
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
const originalInventory = semutaAdvisorFaceDanceInventory(game); assert.equal(originalInventory.length, 57);
const originalTraitors = traitorDeck(game.players, true).sort();
const printed = game.deck.find(card => card.effect === 'karama' && !card.id.startsWith('ix-')); 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(candidate => candidate.id === card.id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { card: card.id, position, from };
});
const beforeStartingDeal = structuredClone(game), cost = printed.id;
let faceDancerDeal: ReturnType<typeof dealOriginalFaceDancers> | null = null;
let advisorSetup: { before: Game; steps: SemutaAdvisorFaceDanceStep[]; after: Game } | null = null;
for (let count = 0; game.status === 'setup' && count < 150; count++) {
const next = nextSemutaAdvisorFaceDance(game); assert.ok(next);
if (next.action.type === 'advisorSetup') {
const before = structuredClone(game), at = actions.length;
faceDancerDeal = dealOriginalFaceDancers(game, next, actions); game = faceDancerDeal.game;
advisorSetup = { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) };
} else act(next.actor, next.action);
}
assert.equal(game.status, 'playing'); assert.ok(faceDancerDeal && advisorSetup);
const afterSetup = structuredClone(game);
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id));
assert.equal(semutaAdvisorFaceDancePlayer(game, owner).forces[location], 1);
assert.ok(semutaAdvisorFaceDancePlayer(game, owner).advisors?.[territory]);
assert.equal(semutaAdvisorFaceDancePlayer(game, winner).forces[location], 10);
const dancers = structuredClone(semutaAdvisorFaceDancePlayer(game, tleilaxu).faceDancers!);
const winningDisc = semutaAdvisorFaceDancePlayer(game, winner).leaders.filter(leader =>
dancers.some(card => card.leader === leader.id && !card.revealed)).toSorted((a, b) => b.strength - a.strength)[0];
const losingDisc = semutaAdvisorFaceDancePlayer(game, tleilaxu).leaders.toSorted((a, b) => a.strength - b.strength)[0];
assert.ok(winningDisc && losingDisc); const winnerLeader = winningDisc.id, loserLeader = losingDisc.id;
assert.deepEqual(semutaAdvisorFaceDanceTraitors(game), originalTraitors);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder = [0, 1, 2, 3].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: SemutaAdvisorFaceDanceStep | undefined, bidAt = -1;
for (let count = 0; count < 1000 && !semutaAdvisorFaceDancePlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
const next = nextSemutaAdvisorFaceDance(game); assert.ok(next);
if (next.actor === holder && next.action.type === 'richeseBid' && next.action.amount === 1) {
beforeBid = structuredClone(game); bid = structuredClone(next); bidAt = actions.length;
}
act(next.actor, next.action);
}
assert.ok(beforeBid && bid && bidAt >= 0);
const acquired = structuredClone(game), acquisition = { before: beforeBid, bid,
steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 };
reach(state => state.turn === 1 && state.phase === 5 && semutaAdvisorFaceDanceClean(state));
const funding = transition(() => {
act(winner, { type: 'bribe', target: tleilaxu, amount: 9 });
act(spender, { type: 'bribe', target: tleilaxu, amount: 10 });
});
assert.equal(semutaAdvisorFaceDancePlayer(game, tleilaxu).bribes, 19);
reach(state => state.turn === 1 && state.phase === 7 && semutaAdvisorFaceDanceClean(state));
const fundingCollection = transition(() => {
reach(state => state.turn === 1 && state.phase === 8 && semutaAdvisorFaceDanceClean(state));
});
assert.equal(semutaAdvisorFaceDancePlayer(game, tleilaxu).bribes, 0);
assert.equal(semutaAdvisorFaceDancePlayer(game, tleilaxu).spice - semutaAdvisorFaceDancePlayer(fundingCollection.before, tleilaxu).spice, 19);
reach(state => state.turn === 2 && state.phase === 5 && state.active === tleilaxu && semutaAdvisorFaceDanceClean(state));
assert.equal(semutaAdvisorFaceDancePlayer(game, tleilaxu).reserves, 20);
const shipment = transition(() => {
act(tleilaxu, { type: 'ship', territory, sector, amount: 20, elite: 0, allyPayment: 0 });
reach(semutaAdvisorFaceDanceClean);
});
assert.equal(semutaAdvisorFaceDancePlayer(shipment.before, tleilaxu).spice - semutaAdvisorFaceDancePlayer(game, tleilaxu).spice, 20);
assert.equal(semutaAdvisorFaceDancePlayer(game, tleilaxu).reserves, 0);
assert.equal(semutaAdvisorFaceDancePlayer(game, tleilaxu).forces[location], 20);
reach(state => state.turn === 2 && state.phase === 6 && semutaAdvisorFaceDanceClean(state) && !!state.active);
const beforeBattle = structuredClone(game), choice = viewGame(game, game.active!).battleChoices.find(row => row.territory === territory);
assert.ok(choice); const battleAction: Action = { type: 'chooseBattle', territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker };
act(choice.chooser, battleAction);
reach(state => !!state.battle && semutaAdvisorFaceDanceClean(state) && !state.battle.preparation &&
state.battle.preLeader?.closed !== false);
const beforePlans = structuredClone(game), planSteps: SemutaAdvisorFaceDanceStep[] = [
{ actor: winner, action: { type: 'battlePlan', leader: winnerLeader, dial: 1, support: 1, weapon: null, defense: null } },
{ actor: tleilaxu, action: { type: 'battlePlan', leader: loserLeader, dial: 0, support: 0, weapon: null, defense: null } },
];
for (const step of planSteps) act(step.actor, step.action);
const revealed = structuredClone(game), cleanupAt = actions.length;
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'faceDance');
assert.ok(game.decision?.kind === 'faceDance'); assert.equal(game.decision.player, tleilaxu);
assert.equal(game.decision.winner, winner); assert.equal(game.decision.identity, winnerLeader);
assert.equal(semutaAdvisorFaceDancePlayer(game, tleilaxu).reserves, 0);
assert.equal(Object.values(semutaAdvisorFaceDancePlayer(game, tleilaxu).forces).reduce((a, b) => a + b, 0), 0);
assert.equal(semutaAdvisorFaceDancePlayer(game, tleilaxu).tanks, 20);
assert.equal(semutaAdvisorFaceDancePlayer(game, winner).forces[location], 9);
const cleanup = { before: revealed, steps: structuredClone(actions.slice(cleanupAt)), after: structuredClone(game) };
const faceDanceAction: Action = { type: 'decision', reveal: true, sources: {}, sector };
const replacement = transition(() => { act(tleilaxu, faceDanceAction); });
assert.ok(game.response?.automaticAdvisorFlip?.producer === 'faceDance');
const automatic = game.response.automaticAdvisorFlip;
assert.equal(automatic.faceDance.amount, 0); assert.deepEqual(automatic.faceDance.sources, {});
assert.deepEqual(automatic.faceDance.returned, { [winner]: 9 });
assert.equal(semutaAdvisorFaceDancePlayer(game, winner).forces[location] ?? 0, 0);
assert.equal(semutaAdvisorFaceDancePlayer(game, winner).reserves, semutaAdvisorFaceDancePlayer(replacement.before, winner).reserves + 9);
assert.equal(semutaAdvisorFaceDancePlayer(game, tleilaxu).faceDancers!.find(card => card.leader === winnerLeader)?.revealed, true);
assert.equal(game.battle, null); assert.equal(game.pendingFaceDance, null);
assert.deepEqual(semutaAdvisorFaceDanceInventory(game), originalInventory);
assert.deepEqual(semutaAdvisorFaceDanceTraitors(game), originalTraitors);
return { game, kind: 'advisorFlip' as const, advanced: true as const, zeroReplacement: true as const,
richese, holder, owner, tleilaxu, winner, spender, cost, karama: cost, semuta: SEMUTA_DRUG_ID,
action: { type: 'card', card: cost, mode: 'cancel' } as Action,
originalResponse: structuredClone(game.response), originalAutomaticSource: structuredClone(automatic),
originalParent: structuredClone(game.pendingAutomaticAdvisorFaceDance!), originalQueue: structuredClone(game.pendingAutomaticAdvisorFlips!),
originalPhysical: structuredClone(semutaAdvisorFaceDancePhysical(game)), originalStance: structuredClone(semutaAdvisorFaceDancePlayer(game, owner).advisors!),
originalBattle: structuredClone(revealed.battle!), originalBattleContext: structuredClone(game.lastBattleContext!),
originalBalances: game.players.map(p => ({ id: p.id, spice: p.spice })), originalDeck: structuredClone(game.deck),
originalFaceDancers: structuredClone(semutaAdvisorFaceDancePlayer(game, tleilaxu).faceDancers!),
territory, location, sector, winnerLeader, loserLeader, faceDanceAction, battleAction, choice,
initial, lobby, setup, beforeStartingDeal, afterSetup, advisorSetup,
faceDancerDeal: { original: faceDancerDeal.original, order: faceDancerDeal.order, scalars: faceDancerDeal.scalars,
before: advisorSetup.before, steps: advisorSetup.steps, after: advisorSetup.after },
dancers, dealOrder, deal: selected.map(card => card.id), originalInventory, originalTraitors, originalSpice, spiceOrder,
acquisition, acquired, cache: acquisition, funding, fundingCollection, fuel: { funding, fundingCollection, shipment },
shipment, beforeBattle, beforePlans, planSteps, revealed, cleanup, replacement,
actions, steps: actions, sourceActionCount: actions.length,
staging: [
'Original undealt Advanced R/C/BG/T/A/E Semuta initializer with native CHOAM/Ix decks and no other module. Actual BG setup places one advisor beside original Atreides10. Only unopened main/Spice positions and native scalar lotteries are selected; C buys cache1 Semuta and E is dealt printed Karama.',
'The actual final setup advisor action consumes a selected conserved permutation of the original Face Dancer shuffle: T3/T4/A0 first. All actual private Traitor choices precede it; the engine physically deals all three unrevealed cards. The winning leader is selected from that actual matching living hand.',
'At clean first movement A really bribes T9 and E really bribes T10. The nineteen deferred spice becomes spendable only through real Mentat collection; T’s native five then funds the actual turn2 city shipment of all twenty reserves for twenty spice. No stock or wallet is installed.',
'The actual unarmed Arrakeen battle uses winning A leader5/dial1/support1 and weak T leader/dial0. All twenty T counters really enter Tanks; A retains nine. Native preparation, Traitor declines, final outcome and cleanup remain real actions; earlier Semuta offers are passed so C retains Drug.',
'T has no reserve or board counter left, then actually reveals its held matching Face Dancer with sources{}. Real replacement returns A9, kills its leader, spends the physical Face Dancer and leaves original BG alone. Stop at the resulting native mandatory conversion before printed Karama cost.',
] };
}
export type SemutaAdvisorFaceDanceFixture = ReturnType<typeof createSemutaAdvisorFaceDanceFixture>;