Offer Semuta on World, battle and Face Dance conversion costs

This commit is contained in:
Quintine committed 2026-10-26 04:50:02 +11:00
1 parent 27959b4655
commit 92f2e8125b
11 files changed
+1561 -1

No files matched your search

+264
View File
@@ -0,0 +1,264 @@
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>;