Files
Dune/tests/fixture-skills-tech-payments.ts
T

206 lines
10 KiB
TypeScript

import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeLeaderSkillsGameForAudit,
joinGame, newPlayer, viewGame, type Action, type Game,
} from '../game/engine';
import { TERRITORIES } from '../game/board';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { forceRevivalQuote } from '../game/revival';
import type { TechId } from '../game/tech-tokens';
import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
export type SkillsTechPaymentsOptions = {
/** Fresh authenticated undealt classic lobby; original seat IDs/order survive. */
initial?: Game;
advanced?: boolean;
kind?: 'force-revival' | 'shipment' | 'guild-shipment';
withGuild?: boolean;
/** Real counters revived or shipped, including the free Smuggler counter. */
amount?: number;
/** Ordinary full price when false; the default shipment explicitly opts in. */
smuggler?: boolean;
};
export type SkillsTechPaymentsStep = { actor: string; action: Action };
export type SkillsTechPaymentsFixture = {
game: Game;
beforePayment: Game;
setup: Game;
actions: SkillsTechPaymentsStep[];
actor: string;
payer: string;
owner: string;
smugglerOwner: string;
paymentAction: Action;
amount: number;
cost: number;
token: TechId;
tokenOwner: string;
destination?: string;
};
/** Control only the initial all14 shuffle so native two-card offers contain
* Banker/Smuggler. All later skill, card, Traitor and token shuffles stay native. */
function withPaymentSkillOffers<T>(game: Game, initialize: () => T): T {
const cards: LeaderSkillId[] = LEADER_SKILL_CARDS.map(card => card.id);
const selected: LeaderSkillId[] = game.players.map(player => player.faction === 'atreides' ? 'spice-banker'
: player.faction === 'harkonnen' ? 'smuggler' : player.faction === 'emperor' ? 'warmaster'
: player.faction === 'guild' ? 'swordmaster-of-ginaz' : player.faction === 'fremen' ? 'killer-medic'
: 'prana-bindu-adept');
// Fill each original two-card offer without introducing another payment or
// optional question ability into the continuation policy.
const desired: LeaderSkillId[] = [];
const remaining = cards.filter(card => !selected.includes(card));
for (const skill of selected) desired.push(skill, remaining.shift()!);
desired.push(...remaining);
const working = [...cards];
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) / (i + 1)) * 0x100000000));
[working[i], working[j]] = [working[j], working[i]];
}
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto);
type RandomInput = Parameters<typeof original>[0];
let index = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (index < rolls.length) {
assert.ok(array instanceof Uint32Array && array.length === 1);
array[0] = rolls[index++];
} else original(array as RandomInput);
return array;
};
try { return initialize(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function nextSkillsTechPaymentsNativeStep(game: Game): SkillsTechPaymentsStep | null {
return nextSpiceBankerIncomeNativeStep(game);
}
function step(game: Game, actions?: SkillsTechPaymentsStep[]): Game {
const next = nextSkillsTechPaymentsNativeStep(game);
assert.ok(next);
actions?.push(next);
return applyAction(game, next.actor, next.action);
}
export function initializeSkillsTechPaymentsSetup(options: SkillsTechPaymentsOptions = {}): Game {
let game = options.initial ? structuredClone(options.initial)
: createGame('SKILLTECH', newPlayer('banker', 'Banker', 'atreides'), options.advanced ?? false);
assert.equal(game.status, 'lobby', 'Continue only a fresh undealt authenticated lobby.');
assert.equal(game.expansions.length, 0);
if (!options.initial) {
joinGame(game, newPlayer('smuggler', 'Smuggler', 'harkonnen'));
joinGame(game, newPlayer('payer', 'Payer', 'emperor'));
if (options.withGuild || options.kind === 'guild-shipment') joinGame(game, newPlayer('guild', 'Guild', 'guild'));
}
assert.ok(game.players.some(player => player.faction === 'atreides'));
assert.ok(game.players.some(player => player.faction === 'harkonnen'));
assert.ok(game.players.some(player => player.faction === 'emperor'));
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
if (options.withGuild || options.kind === 'guild-shipment') assert.ok(game.players.some(player => player.faction === 'guild'));
// The normal host lobby control creates canonical UNUSED tokens. Original
// setup/first Storm alone assign ownership; never retrofit a played game.
if (!game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
return withPaymentSkillOffers(game, () => initializeLeaderSkillsGameForAudit(game));
}
export function completeSkillsTechPaymentsSetup(state: Game, actions?: SkillsTechPaymentsStep[]): Game {
let game = state;
for (let i = 0; game.status === 'setup' && i < 200; i++) {
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0];
const offer = game.leaderSkills!.offers[actor];
const player = game.players.find(seat => seat.id === actor)!;
const view = viewGame(game, actor).leaderSkills!;
const skill = player.faction === 'atreides' ? 'spice-banker'
: player.faction === 'harkonnen' ? 'smuggler' : offer.cards[0];
assert.ok(offer.cards.includes(skill));
assert.ok(view.eligibleLeaders[0]);
const action: Action = { type: 'leaderSkill', event: offer.event, skill, leader: view.eligibleLeaders[0].id };
actions?.push({ actor, action });
game = applyAction(game, actor, action);
} else game = step(game, actions);
}
assert.equal(game.status, 'playing');
return game;
}
/** First clean native boundary at/after target; empty Battle may auto-skip. Never assign turn or phase. */
export function advanceSkillsTechPaymentsToPhase(state: Game, target: number, actions?: SkillsTechPaymentsStep[]): Game {
let game = state;
const turn = game.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, actions);
assert.equal(game.turn, turn);
assert.ok(game.phase >= target);
assert.ok(!game.phaseOpening && !game.response && !game.decision);
return game;
}
/** Empty native stronghold avoids incidental sand spice collection and combat;
* no board occupancy or token ownership is staged for shipment. */
export function skillsTechPaymentsEmptyDestination(game: Game): { territory: string; sector: number } {
const destination = TERRITORIES.find(territory => territory.type === 'stronghold'
&& !territory.sectors.includes(game.storm)
&& game.players.every(player => Object.entries(player.forces)
.every(([key, amount]) => !amount || !key.startsWith(`${territory.id}:`))));
assert.ok(destination);
return { territory: destination.id, sector: destination.sectors[0] };
}
/** Stops BEFORE the native payment/companion action, preserving the original
* available wallets and IDs for an authenticated human payment smoke. The only
* controlled position is labelled conserved Emperor reserve->Tanks casualties. */
export function createSkillsTechPaymentsFixture(options: SkillsTechPaymentsOptions = {}): SkillsTechPaymentsFixture {
const actions: SkillsTechPaymentsStep[] = [];
const setup = initializeSkillsTechPaymentsSetup(options);
let game = completeSkillsTechPaymentsSetup(setup, actions);
const kind = options.kind ?? 'force-revival';
const owner = game.players.find(player => player.faction === 'atreides')!.id;
const smugglerOwner = game.players.find(player => player.faction === 'harkonnen')!.id;
const actor = kind === 'force-revival' ? game.players.find(player => player.faction === 'emperor')!.id
: kind === 'guild-shipment' ? game.players.find(player => player.faction === 'guild')!.id : smugglerOwner;
game = advanceSkillsTechPaymentsToPhase(game, kind === 'force-revival' ? 4 : 5, actions);
let amount: number;
let cost: number;
let paymentAction: Action;
let destination: string | undefined;
if (kind === 'force-revival') {
amount = options.amount ?? 3;
const payer = game.players.find(player => player.id === actor)!;
assert.ok(payer.reserves >= amount);
// Explicit conserved casualty unit position, not fabricated native money.
payer.reserves -= amount;
payer.tanks += amount;
cost = forceRevivalQuote(game, payer, amount).cost;
paymentAction = { type: 'revive', amount };
} else {
for (let i = 0; game.active !== actor && i < 20; i++) game = step(game, actions);
assert.equal(game.active, actor);
const target = skillsTechPaymentsEmptyDestination(game);
destination = `${target.territory}:${target.sector}`;
amount = options.amount ?? (kind === 'guild-shipment' ? 8 : 5);
const smuggler = kind === 'shipment' && options.smuggler !== false;
cost = kind === 'guild-shipment' ? Math.ceil(amount / 2) : amount - (smuggler ? 1 : 0);
paymentAction = { type: 'ship', ...target, amount, ...(smuggler ? { smuggler: true } : {}) };
}
const token = kind === 'force-revival' ? 'axlotl' : 'heighliners';
const tokenOwner = game.techTokens![token].owner;
assert.ok(tokenOwner, 'Ownership must have been assigned by the original first Storm.');
return { game, beforePayment: structuredClone(game), setup, actions, actor, payer: actor,
owner, smugglerOwner, paymentAction, amount, cost, token, tokenOwner, destination };
}
export function paySkillsTechPaymentsFixture(fixture: SkillsTechPaymentsFixture): Game {
let game = applyAction(fixture.game, fixture.actor, fixture.paymentAction);
for (let i = 0; (game.response || game.decision || game.pendingRevival || game.pendingShipment) && i < 200; i++) game = step(game);
assert.ok(!game.response && !game.decision && !game.pendingRevival && !game.pendingShipment);
return game;
}