Files
Dune/tests/fixture-bureaucrat-betrayal-funding.ts
T

149 lines
8.1 KiB
TypeScript
Raw 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, initializeGuildBetrayalLeaderSkillsGameForAudit, joinGame,
newPlayer, viewGame, type Action, type Game,
} from '../game/engine';
import type { FactionId } from '../game/catalog';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { createGuildBetrayalFixture, type GuildBetrayalFixture } from './fixture-nexus-guild-betrayal';
export const betrayalFundingSeat = (game: Game, id: string) => game.players.find(p => p.id === id)!;
/** Scope exactly the thirteen Fisher–Yates draws of the original all14 deck;
* all later setup entropy remains native. No assignment or offer is replaced. */
function skillLottery<T>(selected: LeaderSkillId[], operation: () => T): T {
const working: LeaderSkillId[] = LEADER_SKILL_CARDS.map(card => card.id);
const remainder = working.filter(skill => !selected.includes(skill));
const desired = [...selected.flatMap(skill => [skill, remainder.shift()!]), ...remainder];
const rolls: number[] = [];
for (let i = working.length - 1; i > 0; i--) {
const j = working.indexOf(desired[i]);
assert.ok(j >= 0 && j <= i);
rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
[working[i], working[j]] = [working[j], working[i]];
}
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 && cursor < rolls.length)
array[0] = rolls[cursor++];
else Reflect.apply(native, crypto, [array]);
return array;
};
try { const result = operation(); assert.equal(cursor, 13); return result; }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function createGuildBetrayalSkillsSetup(advanced = false, allied = false,
trainerSkill: LeaderSkillId = 'bureaucrat'): { initial: Game; trained: Game; trainer: string } {
const roster: [string, FactionId, LeaderSkillId][] = [
['holder', 'atreides', 'warmaster'], ['guild', 'guild', 'killer-medic'],
['shipper', 'emperor', 'master-of-assassins'],
...(allied ? [['donor', 'harkonnen', 'swordmaster-of-ginaz'] as [string, FactionId, LeaderSkillId]] : []),
['trainer', 'beneGesserit', trainerSkill],
];
let game = createGame('BUREAUCRATBETRAYAL', newPlayer(roster[0][0], roster[0][0], roster[0][1]), advanced, []);
for (const [id, faction] of roster.slice(1)) joinGame(game, newPlayer(id, id, faction));
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
game = skillLottery(roster.map(([, , skill]) => skill), () => initializeGuildBetrayalLeaderSkillsGameForAudit(game));
const initial = structuredClone(game);
assert.equal(game.status, 'setup');
if (game.setupStage === 'prediction')
game = applyAction(game, 'trainer', { type: 'predict', faction: 'emperor', turn: 4 });
assert.equal(game.setupStage, 'leaderSkills');
while (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0];
const offer = viewGame(game, actor).leaderSkills!;
const skill = roster.find(([id]) => id === actor)![2];
assert.ok(offer.offer!.cards.includes(skill) && !offer.unavailableSkills?.[skill]);
const leader = offer.eligibleLeaders[0];
assert.ok(leader);
game = applyAction(game, actor, { type: 'leaderSkill', event: offer.offer!.event, skill, leader: leader.id });
}
assert.equal(game.status, 'setup', 'The shared fixture owns the remaining native setup.');
validateLeaderSkills(game.leaderSkills!, game.players);
assert.equal(game.leaderSkills!.assignments.find(a => a.owner === 'trainer')?.skill, trainerSkill);
return { initial, trained: game, trainer: 'trainer' };
}
/** Finish only real native acknowledgements/income, declining optional ordinary
* Bureaucrat income for the all-pass physical-shipment comparison. */
export function settleBetrayalFunding(state: Game): Game {
let game = state;
for (let n = 0; n < 100; n++) {
if (game.pendingGuildBetrayal) {
const frame = game.pendingGuildBetrayal;
const actor = frame.required.find(id => !frame.passed.includes(id))!;
game = applyAction(game, actor, { type: 'guildBetrayalPass', event: frame.invoice.event });
} else if (game.decision?.kind === 'bureaucratPayment') {
game = chooseBetrayalFunding(game, false);
} else if (game.decision?.kind === 'guildShipment') {
game = applyAction(game, game.decision.player, { type: 'decision', allow: true });
} else if (game.decision?.kind === 'advisor') {
game = applyAction(game, game.decision.player, { type: 'decision', accept: false });
} else if (game.response) {
const actor = game.players.find(p => !game.response!.passed.includes(p.id))!.id;
game = applyAction(game, actor, { type: 'passResponse' });
} else return game;
}
throw Error('The original Betrayal shipment continuation did not settle.');
}
export function chooseBetrayalFunding(game: Game, redirect: boolean): Game {
assert.equal(game.decision?.kind, 'bureaucratPayment');
return applyAction(game, game.decision!.player, { type: 'decision', event: game.bureaucratPaymentEvent, redirect });
}
export interface BureaucratBetrayalFundingFixture extends GuildBetrayalFixture {
initial: Game;
trained: Game;
trainer: string;
beforePledge: Game;
primary: number;
donorAmount: number;
}
/** Reuse the shared native fixture's conserved card custody and original setup,
* then declare the requested larger shipment from its pre-declaration boundary.
* The provisional two-force branch is never replayed into this branch. Wallets,
* forces, signed invoices, escrow and usage ledgers are not assigned here. */
export function createBureaucratBetrayalFundingFixture({
advanced = false, primary = 5, donorAmount = 0, own = false, alreadyUsed = false,
}: { advanced?: boolean; primary?: number; donorAmount?: number; own?: boolean; alreadyUsed?: boolean } = {}): BureaucratBetrayalFundingFixture {
const setup = createGuildBetrayalSkillsSetup(advanced, donorAmount > 0);
const base = createGuildBetrayalFixture({ initial: setup.trained, advanced,
payer: own ? 'holder' : 'other', allyPayment: donorAmount > 0 ? 1 : 0 });
let game = structuredClone(base.before);
if (base.donor) game = applyAction(game, base.donor, { type: 'pledgeAid', amount: 0 });
if (alreadyUsed) {
assert.ok(betrayalFundingSeat(game, base.shipper).spice >= primary + 5);
game = applyAction(game, base.shipper, { type: 'bribe', target: base.guild, amount: 5 });
game = chooseBetrayalFunding(game, true);
assert.equal(viewGame(game, setup.trainer).bureaucrat.usedThisPhase, true);
}
const beforePledge = structuredClone(game);
if (base.donor) game = applyAction(game, base.donor, { type: 'pledgeAid', amount: donorAmount });
assert.ok(betrayalFundingSeat(game, base.shipper).spice >= primary, 'The original wallet must fund its real invoice leg.');
const before = structuredClone(game);
const declaration: Action = { ...base.declaration, amount: primary + donorAmount, allyPayment: donorAmount };
game = applyAction(game, base.shipper, declaration);
if (game.decision?.kind === 'guildShipment')
game = applyAction(game, game.decision.player, { type: 'decision', allow: true });
assert.ok(game.pendingGuildBetrayal, 'A real accepted paid shipment creates the invoice.');
const invoice = game.pendingGuildBetrayal.invoice;
assert.equal(invoice.price, primary + donorAmount);
assert.equal(invoice.ownPayment, primary);
assert.equal(invoice.allyPayment, donorAmount);
return { ...base, ...setup, beforePledge, primary, donorAmount, game, before, declaration,
event: invoice.event, price: invoice.price, original: settleBetrayalFunding(game) };
}
export function useBetrayalFunding(fixture: BureaucratBetrayalFundingFixture): Game {
return applyAction(fixture.game, fixture.holder, { type: 'guildBetrayalUse', event: fixture.event });
}