265 lines
20 KiB
TypeScript
265 lines
20 KiB
TypeScript
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>;
|