260 lines
13 KiB
TypeScript
260 lines
13 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction, createGame, initializeLeaderSkillsGameForAudit, 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' | 'ecaz';
|
|
requestedSkill?: LeaderSkillId;
|
|
skillOwner?: FactionId;
|
|
opponents?: readonly FactionId[];
|
|
/** Richese and Ecaz also exercise their Basic native rule bands. */
|
|
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' || options.family === 'ecaz' ? '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),
|
|
() => 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;
|
|
}
|