Files
Dune/tests/fixture-advanced-native-skills.ts
T

261 lines
13 KiB
TypeScript

import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeLeaderSkillsGameForAudit, initializeSpiceBankerIncomeGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game, type Player,
} 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 { newRevivalRules } from '../game/revival';
import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
export type AdvancedNativeSkillsOptions = {
family: 'ixians' | 'choam' | 'tleilaxu' | 'moritani' | 'richese';
requestedSkill?: LeaderSkillId;
skillOwner?: FactionId;
opponents?: readonly FactionId[];
bankerIncome?: boolean;
/** Richese also exercises its already-admitted Basic native rule band. */
rules?: 'basic' | 'advanced';
/** Continue this actual authenticated lobby/setup without replacing its seats. */
initial?: Game;
};
export function advancedNativePlayer(game: Game, faction: FactionId): Player {
const player = game.players.find(p => p.faction === faction);
assert.ok(player, `Missing actual ${faction} seat`);
return player;
}
/** Only the first physical all14 shuffle is controlled. Later native shuffles
* use real entropy. Place the requested skill in its actual owner's offer. */
function withSkillShuffle<T>(skill: LeaderSkillId, ownerIndex: number, initialize: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto);
const target = LEADER_SKILL_CARDS.findIndex(card => card.id === skill);
const slot = ownerIndex * 2;
assert.ok(target >= 0 && slot < LEADER_SKILL_CARDS.length);
let index = LEADER_SKILL_CARDS.length - 1;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (index <= 0) return Reflect.apply(original, crypto, [array]) as V;
assert.ok(array instanceof Uint32Array && array.length === 1);
const swap = index === Math.max(target, slot) ? Math.min(target, slot) : index;
array[0] = swap === index ? 0xffffffff : Math.ceil(swap * 0x100000000 / (index + 1));
index--;
return array;
};
try { return initialize(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Genuine fresh Advanced native lobby, native starting-card decisions and
* physical skill offers. No played save is converted and no deck is cherry-picked. */
export function initializeAdvancedNativeSkillsSetup(options: AdvancedNativeSkillsOptions): Game {
const ownerFaction = options.skillOwner ?? options.family;
const expansion = options.family === 'moritani' ? 'ecaz' :
options.family === 'choam' || options.family === 'richese' ? 'choam' : 'ix';
let game: Game;
if (options.initial) game = structuredClone(options.initial);
else {
const factions = [ownerFaction, options.family, ...(options.opponents ?? ['atreides', 'emperor'] as FactionId[])]
.filter((faction, index, all) => all.indexOf(faction) === index);
game = createGame('ADVNATIVE', newPlayer(factions[0], factions[0], factions[0]), options.rules !== 'basic', [expansion]);
for (const faction of factions.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
}
assert.equal(game.advanced, options.rules !== 'basic');
assert.ok(game.expansions.includes(expansion));
const owner = advancedNativePlayer(game, ownerFaction);
if (game.status === 'setup') {
assert.ok(game.leaderSkills && game.turn === 1 && game.phase === 0,
'Only the original fresh native skill setup may continue.');
return game;
}
assert.equal(game.status, 'lobby');
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
return withSkillShuffle(options.requestedSkill ?? 'suk-graduate', game.players.findIndex(p => p.id === owner.id),
() => options.bankerIncome ? initializeSpiceBankerIncomeGameForAudit(game) : initializeLeaderSkillsGameForAudit(game));
}
/** Finish original setup decisions from the actual own-seat projections. */
export function completeAdvancedNativeSkillsSetup(state: Game, options: AdvancedNativeSkillsOptions): Game {
let game = state;
const owner = advancedNativePlayer(game, options.skillOwner ?? options.family).id;
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 requested = options.requestedSkill ?? 'suk-graduate';
const skill = actor === owner && offer.cards.includes(requested) && !view.unavailableSkills?.[requested]
? requested : offer.cards.find(card => !view.unavailableSkills?.[card]);
assert.ok(skill && view.eligibleLeaders[0]);
game = applyAction(game, actor, { type: 'leaderSkill', event: offer.event, skill, leader: view.eligibleLeaders[0].id });
} else game = advancedNativeStep(game);
}
assert.equal(game.status, 'playing');
return game;
}
export function completedAdvancedNativeSkillsGame(options: AdvancedNativeSkillsOptions): Game {
return completeAdvancedNativeSkillsSetup(initializeAdvancedNativeSkillsSetup(options), options);
}
export function advancedNativeStep(game: Game): Game {
// Original native Moritani choices stay real actions; decline optional Terror
// placement/use rather than letting a policy invent fixture phase history.
if (game.decision?.kind === 'moritaniPlacement')
return applyAction(game, game.decision.player, { type: 'decision', decline: true });
if (game.decision?.kind === 'moritaniTerror' && ['select', 'offer'].includes(game.pendingTerrorEntry!.stage))
return applyAction(game, game.decision.player, { type: 'decision', decline: true });
const lot = game.richeseAuction;
if (lot && !lot.outcome && !game.response && !game.decision && !game.phaseOpening) {
const actor = lot.method === 'silent'
? lot.order.find(id => lot.eligible.includes(id) && !Object.hasOwn(lot.sealed, id)) : lot.active;
assert.ok(actor, 'Original native lot must retain its next bidder.');
return applyAction(game, actor, { type: 'richeseBid', event: lot.event, amount: lot.method === 'silent' ? 0 : null });
}
const next = nextSpiceBankerIncomeNativeStep(game);
assert.ok(next);
return applyAction(game, next.actor, next.action);
}
export function advanceAdvancedNativeSkillsToPhase(state: Game, target: number): Game {
let game = state;
for (let i = 0; (game.phase !== target || game.phaseOpening || game.response || game.decision)
&& game.turn === state.turn && game.status === 'playing' && i < 1000; i++) game = advancedNativeStep(game);
assert.equal(game.turn, state.turn);
assert.equal(game.phase, target);
assert.ok(!game.phaseOpening && !game.response && !game.decision);
return game;
}
export function rejectAdvancedNativeAction(game: Game, actor: string, action: Action, pattern?: RegExp): void {
const before = structuredClone(game);
if (pattern) assert.throws(() => applyAction(game, actor, action), pattern);
else assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
export function assertAdvancedNativeCustody(game: Game): void {
validateLeaderSkills(game.leaderSkills!, game.players);
for (const p of game.players) {
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0), 20);
if (p.elites) {
assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0), p.faction === 'ixians' ? 7 : 5);
for (const [key, count] of Object.entries(p.elites.forces)) assert.ok(count <= (p.forces[key] ?? 0));
}
}
}
/** Explicit controlled rule-unit position AFTER genuine setup. Return every
* deployed piece to its own reserves, then transfer only the selected forces;
* preserve real wallets, leaders, traits and every physical skill/treachery card.
* This is not claimed to be a played turn history. */
export function createAdvancedNativeSkillBattle(options: AdvancedNativeSkillsOptions): Game {
const game = completedAdvancedNativeSkillsGame(options);
return stageAdvancedNativeSkillBattle(game,
advancedNativePlayer(game, options.skillOwner ?? options.family).id,
advancedNativePlayer(game, 'emperor').id);
}
/** Reuse original authenticated seats and conserved inventories after revival. */
export function stageAdvancedNativeSkillBattle(state: Game, actor: string, target: string): Game {
const game = structuredClone(state);
const owner = game.players.find(p => p.id === actor)!;
const enemy = game.players.find(p => p.id === target)!;
assert.ok(owner && enemy && owner !== enemy);
for (const p of game.players) {
game.deck.push(...p.hand);
p.hand = [];
p.reserves += Object.values(p.forces).reduce((sum, n) => sum + n, 0);
p.forces = {};
if (p.elites) {
p.elites.reserves += Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0);
p.elites.forces = {};
}
}
for (const p of [owner, enemy]) {
assert.ok(p.reserves >= 8);
p.reserves -= 8;
p.forces['wind_pass:14'] = 8;
}
if (owner.faction === 'ixians') {
owner.elites!.reserves -= 2;
owner.elites!.forces['wind_pass:14'] = 2;
}
Object.assign(game, { phase: 6, active: owner.id, order: [owner.id, enemy.id, ...game.players.filter(p => p !== owner && p !== enemy).map(p => p.id)],
storm: 18, ready: [], decision: null, response: null, phaseOpening: null });
assertAdvancedNativeCustody(game);
return game;
}
/** Explicit conserved Revival position after genuine Advanced Tleilaxu setup.
* Only untrained original discs are staged dead; no assigned card is removed.
* Wallets and all original card/force inventories remain untouched. */
export function stageAdvancedTleilaxuForeignRevival(state: Game): {
game: Game; actor: string; originalOwner: string; leader: string; ownDead: string;
} {
const game = structuredClone(state);
assert.equal(game.status, 'playing');
assert.equal(game.advanced, true);
const owner = advancedNativePlayer(game, 'tleilaxu');
const foreignOwner = advancedNativePlayer(game, 'emperor');
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
const own = owner.leaders.find(l => !trained.has(l.id) && !l.gholaBy && !l.capturedBy)!;
const foreign = foreignOwner.leaders.filter(l => !trained.has(l.id) && !l.capturedBy)
.sort((a, b) => a.strength - b.strength)[0];
assert.ok(own && foreign);
for (const leader of [own, foreign]) {
leader.dead = true;
leader.deaths = Math.max(1, leader.deaths ?? 0);
}
Object.assign(game, { phase: 4, active: null, ready: [], decision: null, response: null,
phaseOpening: null, revivalRules: newRevivalRules() });
for (const p of game.players) p.leaderRevived = false;
assertAdvancedNativeCustody(game);
return { game, actor: owner.id, originalOwner: foreignOwner.id, leader: foreign.id, ownDead: own.id };
}
export function openAdvancedNativeSkillBattle(state: Game, actor: string, target: string, hideSkills = true): Game {
let game = applyAction(state, actor, { type: 'chooseBattle', territory: 'wind_pass', target });
for (let i = 0; (game.response || game.decision || game.battle?.preparation || game.battle?.preLeader?.closed === false) && i < 100; i++) {
if (game.decision?.kind === 'leaderSkillVisibility') {
game = applyAction(game, game.decision.player, { type: 'leaderSkillVisibility', event: game.decision.event, hide: hideSkills });
} else if (!game.response && !game.decision && game.battle?.preLeader?.closed === false) {
const stage = game.battle.preLeader;
const next = stage.ready.includes(game.battle.attacker) ? game.battle.defender : game.battle.attacker;
assert.ok(!stage.ready.includes(next), 'Original pre-leader preparation must close after both acknowledgments.');
game = applyAction(game, next, { type: 'battlePreparationReady', event: stage.event });
} else game = advancedNativeStep(game);
}
assert.equal(game.decision, null);
assert.equal(game.response, null);
return game;
}
/** Resolve only native response/cleanup actions, stopping at optional Suk rescue. */
export function finishAdvancedNativeSkillAftermath(state: Game): Game {
let game = state;
for (let i = 0; (game.response || game.decision || game.battle || game.pendingTreacheryDiscard) && i < 100; i++) {
if (game.decision?.kind === 'sukRescue') break;
if (game.decision?.kind === 'moritaniAssassinate') {
game = applyAction(game, game.decision.player, { type: 'decision', event: game.decision.event, decline: true });
continue;
}
if (game.decision?.kind === 'ixSubstitution') {
game = applyAction(game, game.decision.player, { type: 'decision', decline: true });
} else if (game.decision?.kind === 'battleCards') {
game = applyAction(game, game.decision.player, { type: 'decision', discard: [] });
} else game = advancedNativeStep(game);
}
return game;
}