Files
Dune/tests/fixture-native-banker-income.ts
T

192 lines
9.1 KiB
TypeScript

import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeLeaderSkillsGameForAudit,
joinGame, newPlayer, viewGame, type Game,
} from '../game/engine';
import type { FactionId } from '../game/catalog';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { forceRevivalQuote } from '../game/revival';
import {
createSpiceBankerIncomeFixture, nextSpiceBankerIncomeNativeStep,
type SpiceBankerIncomeFixture,
} from './fixture-spice-banker-income';
export const NATIVE_BANKER_FAMILIES = ['ixians', 'tleilaxu', 'choam', 'moritani'] as const;
export type NativeBankerFamily = typeof NATIVE_BANKER_FAMILIES[number];
export type NativeBankerIncomeOptions = {
/** Continue an original fresh lobby or its undealt admitted setup. */
initial?: Game;
family: NativeBankerFamily;
/** Only the Ixian family may include the original Tleilaxu setup as well. */
withTleilaxu?: boolean;
bankerFaction?: FactionId;
payerFaction?: FactionId;
kind?: 'auction' | 'force-revival';
/** Auction price, or number of real Tanks to revive. */
amount?: number;
/** Conserved cyborg subset of the revival casualties, Ixians only. */
elite?: number;
stageIncomeCounter?: boolean;
};
/** Control only the original all14 skill shuffle. Treachery, traitors, Face
* Dancers, Terror tokens and every later native shuffle retain real entropy. */
function withNativeBankerSkillShuffle<T>(initialize: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto);
type RandomInput = Parameters<typeof original>[0];
const target = LEADER_SKILL_CARDS.findIndex(card => card.id === 'spice-banker');
let shuffleIndex = LEADER_SKILL_CARDS.length - 1;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (shuffleIndex <= 0) {
original(array as RandomInput);
return array;
}
assert.ok(array instanceof Uint32Array && array.length === 1,
'The initializer must begin with the original skill shuffle.');
array[0] = shuffleIndex-- === target ? 0 : 0xffffffff;
return array;
};
try { return initialize(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Fresh ready Basic native lobby admitted through the real Banker initializer;
* no played state is retrofitted and no existing deck or offer is replaced. */
export function initializeNativeBankerIncomeSetup(options: NativeBankerIncomeOptions): Game {
assert.ok(!options.withTleilaxu || options.family === 'ixians');
const native: FactionId[] = [options.family];
if (options.withTleilaxu) native.push('tleilaxu');
const banker = options.bankerFaction ?? options.family;
const seats: FactionId[] = [banker, ...native, 'atreides', 'emperor'];
const factions = seats.filter((faction, index, all) => all.indexOf(faction) === index);
assert.ok(native.includes(banker) || banker === 'atreides' || banker === 'emperor');
const expansion: Game['expansions'][number] = options.family === 'choam' ? 'choam'
: options.family === 'moritani' ? 'ecaz' : 'ix';
let game = options.initial ? structuredClone(options.initial)
: createGame('NATBANKR', newPlayer(banker, banker, banker), false, [expansion]);
if (options.initial) {
assert.equal(game.advanced, false);
assert.deepEqual(game.expansions, [expansion]);
if (game.status === 'setup') {
assert.ok(game.spiceBankerIncomePreview && game.turn === 1 && game.phase === 0);
return game;
}
assert.equal(game.status, 'lobby');
} else for (const faction of factions.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
return withNativeBankerSkillShuffle(() => initializeLeaderSkillsGameForAudit(game));
}
function step(game: Game): Game {
const next = nextSpiceBankerIncomeNativeStep(game);
assert.ok(next);
return applyAction(game, next.actor, next.action);
}
/** Original Ix starting-card selection, skill assignments, Traitors/Face Dancers
* and Moritani placement finish through existing own-view actions. */
export function completeNativeBankerIncomeSetup(state: Game): Game {
let game = state;
for (let i = 0; game.status === 'setup' && i < 200; i++) {
if (game.decision?.kind === 'ixSetup') {
const actor = game.decision.player;
const card = viewGame(game, actor).ixTechnology!.setup![0];
assert.ok(card);
game = applyAction(game, actor, { type: 'decision', card: card.id });
} else if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0];
const offer = game.leaderSkills!.offers[actor];
const view = viewGame(game, actor).leaderSkills!;
const skill = offer.cards.includes('spice-banker') ? 'spice-banker'
: offer.cards.find(card => !view.unavailableSkills?.[card]);
assert.ok(skill);
assert.ok(view.eligibleLeaders[0]);
game = applyAction(game, actor, {
type: 'leaderSkill', event: offer.event, skill, leader: view.eligibleLeaders[0].id,
});
} else if (game.setupStage === 'traitors' && !game.decision) {
const player = game.players.find(p => p.faction !== 'tleilaxu' && p.traitorChoices.length);
assert.ok(player);
game = applyAction(game, player.id, { type: 'traitor', leader: player.traitorChoices[0] });
} else if (game.decision?.kind === 'moritaniSetup') {
game = applyAction(game, game.decision.player, { type: 'decision', territory: 'polar_sink', sector: 0 });
} else game = step(game);
}
assert.equal(game.status, 'playing');
assert.equal(game.leaderSkills!.assignments.find(a => a.skill === 'spice-banker')!.owner, game.players[0].id);
return game;
}
/** Advance original phase controls without staging a phase or payment receipt. */
export function advanceNativeBankerIncomeToPhase(state: Game, target: number): Game {
let game = state;
const turn = state.turn;
for (let i = 0; (game.phase !== target || game.phaseOpening || game.response || game.decision)
&& game.status === 'playing' && game.turn === turn && i < 1000; i++) game = step(game);
assert.equal(game.turn, turn);
assert.equal(game.phase, target);
assert.ok(!game.phaseOpening);
assert.equal(game.response, null);
assert.equal(game.decision, null);
return game;
}
/** Reusable native runtime case: actual other-payer Emperor self-auction to the
* Bank by default. Revival stages only conserved normal/cyborg casualties after genuine
* setup; original quotes, responses, force returns and recipient credits execute. */
export function createNativeBankerIncomeFixture(options: NativeBankerIncomeOptions): SpiceBankerIncomeFixture {
const initial = initializeNativeBankerIncomeSetup(options);
if ((options.kind ?? 'auction') === 'auction') {
return createSpiceBankerIncomeFixture({
initial, kind: 'auction', payerFaction: options.payerFaction ?? 'emperor',
amount: options.amount ?? 4, stageIncomeCounter: options.stageIncomeCounter,
});
}
let game = completeNativeBankerIncomeSetup(initial);
game = advanceNativeBankerIncomeToPhase(game, 4);
const assignment = game.leaderSkills!.assignments.find(a => a.skill === 'spice-banker')!;
const payer = game.players.find(p => p.faction === (options.payerFaction ?? 'emperor'))!;
assert.ok(payer);
const count = options.amount ?? 3;
assert.ok(payer.reserves >= count);
// Labelled conserved rule-unit casualty position, not fabricated earned income.
payer.reserves -= count;
payer.tanks += count;
const elite = options.elite ?? 0;
if (elite) {
assert.equal(payer.faction, 'ixians');
assert.ok(elite <= count && payer.elites && payer.elites.reserves >= elite);
payer.elites.reserves -= elite;
payer.elites.tanks += elite;
}
if (options.stageIncomeCounter) {
const owner = game.players.find(p => p.id === assignment.owner)!;
if (!owner.hand.some(card => card.effect === 'karama')) {
const index = game.deck.findIndex(card => card.effect === 'karama');
assert.ok(index >= 0, 'A genuine native Karama must remain for conserved counter staging.');
owner.hand.push(game.deck.splice(index, 1)[0]);
}
}
const amount = forceRevivalQuote(game, payer, count, elite).cost;
return {
game, beforePayment: structuredClone(game), actor: payer.id, payer: payer.id,
paymentAction: { type: 'revive', amount: count, ...(elite ? { elite } : {}) }, owner: assignment.owner,
leader: assignment.leader, event: game.code, kind: 'force-revival', amount,
bankLegs: [{ payer: payer.id, amount }],
};
}
/** Continue the real action and its original private/native response suffix. */
export function payNativeBankerIncomeFixture(fixture: SpiceBankerIncomeFixture): Game {
let game = applyAction(fixture.game, fixture.actor, fixture.paymentAction);
for (let i = 0; (game.response || game.decision || game.pendingRevival) && i < 200; i++) game = step(game);
assert.equal(game.response, null);
assert.equal(game.decision, null);
assert.ok(!game.pendingRevival);
return game;
}