Connect Guild Nexus Betrayal receipt to Bureaucrat per ruling
This commit is contained in:
1 parent
9b83b61669
commit
df9bbc5e04
11 files changed
+534
-16
No files matched your search
@@ -0,0 +1,228 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { bureaucratAlliedFundingModeSupported, bureaucratPaymentModeSupported, bureaucratPaymentSignature } from '../game/bureaucrat-payment';
|
||||
import { guildBetrayalNexusLeaderSkillsProfile } from '../game/leader-skill-profile';
|
||||
import { validateLeaderSkills } from '../game/leader-skills';
|
||||
import { validateNexusCards } from '../game/nexus-cards';
|
||||
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||
import { createGuildBetrayalFixture } from './fixture-nexus-guild-betrayal';
|
||||
import {
|
||||
betrayalFundingSeat as seat, chooseBetrayalFunding, createBureaucratBetrayalFundingFixture,
|
||||
settleBetrayalFunding, useBetrayalFunding, type BureaucratBetrayalFundingFixture,
|
||||
} from './fixture-bureaucrat-betrayal-funding';
|
||||
|
||||
function physicalShipment(game: Game) {
|
||||
return { forces: game.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves,
|
||||
tanks: p.tanks, elites: p.elites, advisors: p.advisors, shipped: p.shipped, moved: p.moved })),
|
||||
phase: game.phase, active: game.active, remaining: game.movementRemaining };
|
||||
}
|
||||
|
||||
/** The Bank has no wallet field: its exact net receipt is the decrease in all
|
||||
* player-controlled spice, including deferred bribes and actual aid escrow. */
|
||||
function playerControlledSpice(game: Game): number {
|
||||
return game.players.reduce((total, p) => total + p.spice + p.bribes, 0) +
|
||||
Object.values(game.aid).reduce((total, aid) => total + aid.amount, 0);
|
||||
}
|
||||
|
||||
function rejectUnchanged(game: Game, actor: string, action: Action): void {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before, 'A rejected choice must not mutate any saved resource or continuation.');
|
||||
}
|
||||
|
||||
function proveReceipt(f: BureaucratBetrayalFundingFixture, done: Game, diverted: number): void {
|
||||
const invoice = f.game.pendingGuildBetrayal!.invoice;
|
||||
assert.equal(invoice.price, f.primary + f.donorAmount);
|
||||
assert.equal(invoice.ownPayment, f.primary);
|
||||
assert.equal(invoice.allyPayment, f.donorAmount);
|
||||
for (const p of done.players) {
|
||||
assert.equal(p.spice, seat(f.beforePledge, p.id).spice -
|
||||
(p.id === f.shipper ? f.primary : 0) - (p.id === f.donor ? f.donorAmount : 0) +
|
||||
(p.id === f.holder ? f.price - diverted : 0), `Exactly the original debit/receipt for ${p.id}`);
|
||||
assert.equal(p.bribes, seat(f.beforePledge, p.id).bribes);
|
||||
}
|
||||
if (f.donor) {
|
||||
assert.equal(seat(f.before, f.donor).spice, seat(f.beforePledge, f.donor).spice - f.donorAmount);
|
||||
assert.equal(f.before.aid[f.donor].amount, f.donorAmount);
|
||||
assert.equal(done.aid[f.donor].amount, 0, 'The native donor escrow is consumed once, without a second wallet debit.');
|
||||
}
|
||||
assert.equal(playerControlledSpice(f.before) - playerControlledSpice(done), diverted,
|
||||
'Only the ruled diversion leaves player custody for the implicit Spice Bank.');
|
||||
assert.deepEqual(physicalShipment(done), physicalShipment(f.original), 'The original native shipment suffix runs once.');
|
||||
const destination = `${String(f.declaration.territory)}:${Number(f.declaration.sector)}`;
|
||||
assert.equal(seat(done, f.shipper).reserves, seat(f.before, f.shipper).reserves - f.price);
|
||||
assert.equal(seat(done, f.shipper).forces[destination], (seat(f.before, f.shipper).forces[destination] ?? 0) + f.price);
|
||||
assert.equal(seat(done, f.shipper).shipped, true);
|
||||
assert.equal(done.pendingGuildBetrayal, null);
|
||||
assert.equal(done.guildBetrayal!.status, 'completed');
|
||||
assert.equal(done.nexusGuildBetrayalHistory!.length, f.before.nexusGuildBetrayalHistory!.length + 1);
|
||||
assert.equal(done.guildBetrayalSourceReceipts!.length, f.before.guildBetrayalSourceReceipts!.length + 1);
|
||||
const receipt = done.nexusGuildBetrayalHistory!.at(-1)!;
|
||||
assert.deepEqual(receipt.invoice, invoice, 'Bureaucrat does not rewrite the gross invoice or its original funding.');
|
||||
assert.equal(receipt.recipient, f.holder);
|
||||
assert.equal(receipt.mode, 'betrayal');
|
||||
assert.equal(receipt.nexusDiscardIndex, f.before.nexusCards!.cards!.discard.length);
|
||||
assert.equal(done.guildBetrayalSourceReceipts!.at(-1)!.receipt, receipt.sourceReceipt);
|
||||
assert.equal(done.nexusCards!.cards!.hands[f.holder], null);
|
||||
assert.deepEqual(done.nexusCards!.cards!.discard, [...f.before.nexusCards!.cards!.discard, 'guild']);
|
||||
validateNexusCards(done.nexusCards!.cards!, done.players);
|
||||
assert.equal(done.bureaucratPayments?.pending, undefined);
|
||||
rejectUnchanged(done, f.holder, { type: 'guildBetrayalUse', event: f.event });
|
||||
rejectUnchanged(done, f.shipper, f.declaration);
|
||||
const ended = applyAction(done, f.shipper, { type: 'endMovement' });
|
||||
assert.notEqual(ended.active, f.shipper);
|
||||
assert.deepEqual(ended.nexusGuildBetrayalHistory, done.nexusGuildBetrayalHistory);
|
||||
assert.deepEqual(ended.guildBetrayalSourceReceipts, done.guildBetrayalSourceReceipts);
|
||||
assert.deepEqual(ended.players.map(p => p.forces), done.players.map(p => p.forces));
|
||||
rejectUnchanged(ended, f.shipper, { type: 'endMovement' });
|
||||
}
|
||||
|
||||
void test('Fresh native Guild classic setup genuinely trains its distinct Bureaucrat and preserves all14 custody', () => {
|
||||
const f = createBureaucratBetrayalFundingFixture({ donorAmount: 3 });
|
||||
assert.equal(f.initial.status, 'setup');
|
||||
assert.equal(f.initial.leaderSkills!.assignments.length, 0);
|
||||
assert.equal(f.trained.status, 'setup');
|
||||
assert.equal(f.trained.leaderSkills!.assignments.find(a => a.skill === 'bureaucrat')?.owner, f.trainer);
|
||||
assert.ok(![f.shipper, f.holder, f.guild, f.donor].includes(f.trainer));
|
||||
validateLeaderSkills(f.trained.leaderSkills!, f.trained.players);
|
||||
const skills = f.trained.leaderSkills!;
|
||||
assert.deepEqual([...skills.deck, ...Object.values(skills.offers).flatMap(o => o.cards), ...skills.assignments.map(a => a.skill)].sort(),
|
||||
LEADER_SKILL_CARDS.map(c => c.id).sort());
|
||||
assert.equal(guildBetrayalNexusLeaderSkillsProfile(f.game), true);
|
||||
assert.equal(bureaucratPaymentModeSupported(f.game), true);
|
||||
assert.equal(bureaucratAlliedFundingModeSupported(f.game), true);
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
for (const [primary, donorAmount] of [[5, 0], [5, 3], [3, 5], [5, 5], [0, 5]]) {
|
||||
for (const redirect of [false, true]) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} Betrayal ${primary}+${donorAmount}: ${redirect ? 'holder gross minus two; Bank two' : 'holder retains gross'}`, () => {
|
||||
const f = createBureaucratBetrayalFundingFixture({ advanced, primary, donorAmount });
|
||||
const pending = useBetrayalFunding(f);
|
||||
assert.equal(pending.decision?.kind, 'bureaucratPayment');
|
||||
assert.equal(pending.decision?.player, f.trainer);
|
||||
assert.equal(seat(pending, f.holder).spice, seat(f.before, f.holder).spice + f.price);
|
||||
assert.deepEqual(physicalShipment(pending), physicalShipment(f.original));
|
||||
const source = pending.bureaucratPayments!.pending!.source;
|
||||
assert.equal(source.kind, 'shipment');
|
||||
assert.equal(source.payee, f.holder);
|
||||
assert.equal(source.amount, f.price);
|
||||
assert.equal(source.payer, primary >= 5 ? f.shipper : f.donor);
|
||||
if (f.donor) assert.deepEqual(source.contributions, [
|
||||
...(primary ? [{ payer: f.shipper, amount: primary }] : []), { payer: f.donor, amount: donorAmount },
|
||||
]);
|
||||
const done = settleBetrayalFunding(chooseBetrayalFunding(pending, redirect));
|
||||
proveReceipt(f, done, redirect ? 2 : 0);
|
||||
assert.deepEqual(done.nexusGuildBetrayalHistory, pending.nexusGuildBetrayalHistory);
|
||||
assert.deepEqual(done.guildBetrayalSourceReceipts, pending.guildBetrayalSourceReceipts);
|
||||
assert.deepEqual(done.nexusCards!.cards, pending.nexusCards!.cards);
|
||||
assert.equal(viewGame(done, f.trainer).bureaucrat.usedThisPhase, redirect);
|
||||
assert.equal(done.bureaucratPayments?.used.length ?? 0, redirect ? 1 : 0);
|
||||
rejectUnchanged(done, f.trainer, { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const options of [{ own: true }, { primary: 3, donorAmount: 3 }, { alreadyUsed: true }]) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} Betrayal no offer ${JSON.stringify(options)}`, () => {
|
||||
const f = createBureaucratBetrayalFundingFixture({ advanced, ...options });
|
||||
const taken = useBetrayalFunding(f);
|
||||
assert.notEqual(taken.decision?.kind, 'bureaucratPayment');
|
||||
assert.equal(taken.bureaucratPayments?.pending, undefined);
|
||||
proveReceipt(f, settleBetrayalFunding(taken), 0);
|
||||
assert.equal(viewGame(taken, f.trainer).bureaucrat.usedThisPhase, 'alreadyUsed' in options);
|
||||
if ('own' in options) assert.equal(f.holder, f.shipper);
|
||||
if ('donorAmount' in options) {
|
||||
assert.ok(f.price >= 5);
|
||||
assert.ok(f.primary < 5 && f.donorAmount < 5, 'Gross never pools sub-five native legs.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} private receipt hides which native leg qualifies`, () => {
|
||||
const primary = createBureaucratBetrayalFundingFixture({ advanced, primary: 5, donorAmount: 3 });
|
||||
const donor = createBureaucratBetrayalFundingFixture({ advanced, primary: 3, donorAmount: 5 });
|
||||
const left = useBetrayalFunding(primary), right = useBetrayalFunding(donor);
|
||||
for (const viewer of left.players) {
|
||||
const a = viewGame(left, viewer.id), b = viewGame(right, viewer.id);
|
||||
for (const projected of [a, b]) {
|
||||
const prompt = projected.bureaucrat.pending!;
|
||||
assert.equal(prompt.payer, null);
|
||||
assert.equal(prompt.payee, primary.holder);
|
||||
assert.equal(prompt.amount, 8);
|
||||
assert.equal(prompt.owner, primary.trainer);
|
||||
assert.equal(prompt.kind, 'shipment');
|
||||
assert.equal(prompt.redirect, 2);
|
||||
assert.equal('contributions' in prompt, false);
|
||||
assert.equal('signature' in prompt, false);
|
||||
assert.equal('bureaucratPayments' in projected, false);
|
||||
assert.equal('bureaucratUseEvents' in projected, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} stale, cross-actor and malformed continuation choices reject immutably`, () => {
|
||||
const f = createBureaucratBetrayalFundingFixture({ advanced, primary: 3, donorAmount: 5 });
|
||||
const pending = useBetrayalFunding(f);
|
||||
const choice: Action = { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true };
|
||||
for (const actor of pending.players.filter(p => p.id !== f.trainer)) rejectUnchanged(pending, actor.id, choice);
|
||||
rejectUnchanged(pending, f.trainer, { ...choice, event: f.event });
|
||||
rejectUnchanged(pending, f.trainer, { ...choice, event: 'stale-payment' });
|
||||
rejectUnchanged(pending, f.trainer, { ...choice, redirect: 'yes' });
|
||||
rejectUnchanged(pending, f.trainer, { ...choice, amount: f.price });
|
||||
rejectUnchanged(pending, f.shipper, { type: 'endMovement' });
|
||||
rejectUnchanged(pending, f.holder, { type: 'guildBetrayalUse', event: f.event });
|
||||
for (const field of ['event', 'recipient', 'amount', 'kind']) {
|
||||
const malformed = structuredClone(pending);
|
||||
const frame = malformed.bureaucratPayments!.pending!;
|
||||
assert.equal(frame.continuation.kind, 'guildBetrayal');
|
||||
Object.assign(frame.continuation, { [field]: field === 'amount' ? f.price + 1 : field === 'recipient' ? f.guild : 'foreign' });
|
||||
// Re-sign the outer frame to exercise the native receipt/continuation link,
|
||||
// not merely the generic changed-signature check.
|
||||
frame.signature = bureaucratPaymentSignature(frame);
|
||||
const saved = structuredClone(malformed);
|
||||
assert.throws(() => viewGame(malformed, f.trainer));
|
||||
assert.throws(() => normalizeAutomaticGame(malformed));
|
||||
rejectUnchanged(malformed, f.trainer, choice);
|
||||
assert.deepEqual(malformed, saved);
|
||||
}
|
||||
proveReceipt(f, settleBetrayalFunding(chooseBetrayalFunding(pending, true)), 2);
|
||||
});
|
||||
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} all four minimal owner bots legally resolve the original Betrayal receipt`, () => {
|
||||
const f = createBureaucratBetrayalFundingFixture({ advanced, primary: 3, donorAmount: 5 });
|
||||
const pending = useBetrayalFunding(f);
|
||||
assert.equal(DIFFICULTIES.length, 4);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(pending, f.trainer);
|
||||
view.players.find(p => p.id === f.trainer)!.bot = difficulty;
|
||||
const choices = botActions(view);
|
||||
assert.equal(choices.length, 1);
|
||||
const done = settleBetrayalFunding(applyAction(pending, f.trainer, choices[0]));
|
||||
proveReceipt(f, done, 2);
|
||||
assert.equal(viewGame(done, f.trainer).bureaucrat.usedThisPhase, true);
|
||||
rejectUnchanged(done, f.trainer, choices[0]);
|
||||
}
|
||||
});
|
||||
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} native no-skills Betrayal still credits its entire funded invoice without an offer`, () => {
|
||||
const f = createGuildBetrayalFixture({ advanced, allyPayment: 1 });
|
||||
const taken = applyAction(f.game, f.holder, { type: 'guildBetrayalUse', event: f.event });
|
||||
assert.notEqual(taken.decision?.kind, 'bureaucratPayment');
|
||||
assert.equal(taken.bureaucratPayments?.pending, undefined);
|
||||
const done = settleBetrayalFunding(taken);
|
||||
assert.equal(seat(done, f.holder).spice, seat(f.before, f.holder).spice + f.price);
|
||||
assert.equal(seat(done, f.shipper).spice, seat(f.before, f.shipper).spice - f.game.pendingGuildBetrayal!.invoice.ownPayment);
|
||||
assert.equal(seat(done, f.donor!).spice, seat(f.before, f.donor!).spice);
|
||||
assert.equal(done.aid[f.donor!].amount, 0);
|
||||
assert.equal(playerControlledSpice(done), playerControlledSpice(f.before));
|
||||
assert.deepEqual(physicalShipment(done), physicalShipment(f.original));
|
||||
assert.deepEqual(done.nexusGuildBetrayalHistory!.at(-1)!.invoice, f.game.pendingGuildBetrayal!.invoice);
|
||||
assert.equal(done.nexusGuildBetrayalHistory!.at(-1)!.recipient, f.holder);
|
||||
assert.deepEqual(done.nexusCards!.cards!.discard, [...f.before.nexusCards!.cards!.discard, 'guild']);
|
||||
rejectUnchanged(done, f.holder, { type: 'guildBetrayalUse', event: f.event });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
function trainedSetup(advanced: boolean, allied: boolean): { 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', 'bureaucrat'],
|
||||
];
|
||||
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.skill === 'bureaucrat')?.owner, 'trainer');
|
||||
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 = trainedSetup(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 });
|
||||
}
|
||||
@@ -85,6 +85,8 @@ function enterGuildFixtureMovement(state: Game, minimumTurn = 1): Game {
|
||||
throw new Error('Genuine Guild fixture did not reach Shipment and Movement.');
|
||||
}
|
||||
function holdGuildFixtureFace(game: Game, holder: string, face: 'guild' | 'choam'): void {
|
||||
const extraFaces: FactionId[] = ['fremen', 'harkonnen', 'ixians', 'choam', 'ecaz', 'moritani', 'beneGesserit', 'atreides'];
|
||||
let extraIndex = 0;
|
||||
// Arrange only the conserved physical Nexus inventory, before any invoice.
|
||||
// Public held-card presence is identical across hidden-face fixture variants.
|
||||
let cards = game.nexusCards!.cards!;
|
||||
@@ -93,7 +95,15 @@ function holdGuildFixtureFace(game: Game, holder: string, face: 'guild' | 'choam
|
||||
if (held !== null) {cards.deck.push(held); cards.hands[p.id] = null;}
|
||||
}
|
||||
for (const p of game.players.filter(seat => !seat.ally)) {
|
||||
const desired = p.id === holder ? face : p.faction === 'guild' ? 'atreides' : 'fremen';
|
||||
let desired: FactionId;
|
||||
if (p.id === holder) desired = face;
|
||||
else if (p.faction === 'guild') desired = 'atreides';
|
||||
else {
|
||||
// Distinct conserved faces for every additional unallied seat; the first
|
||||
// extra keeps the established 'fremen' assignment.
|
||||
desired = extraFaces[extraIndex % extraFaces.length];
|
||||
extraIndex++;
|
||||
}
|
||||
let index = cards.deck.indexOf(desired);
|
||||
if (index < 0) {
|
||||
index = cards.discard.indexOf(desired);
|
||||
|
||||
Reference in new issue
Block a user