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

248 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 { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { createTechTokens, ownedTech } from '../game/tech-tokens';
import { TERRITORIES } from '../game/board';
import type { Card } from '../game/cards';
import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
export type SkillsTechBattleStep = { actor: string; action: Action };
export type SkillsTechBattleOptions = {
/** A fresh authenticated, undealt classic lobby; its seat IDs are retained. */
initial?: Game;
advanced?: boolean;
skill?: 'suk-graduate' | 'sandmaster';
band?: 'normal' | 'skilled';
/** Selection/absence are earned by a real preceding battle, never staged ownership. */
reward?: 'automatic' | 'selection' | 'absent';
};
export type SkillsTechBattleFixture = {
game: Game;
owner: string;
opponent: string;
trainer: string;
territory: string;
key: string;
weapon: string;
defense: string;
losingCard: string;
losingLeader: string;
ownerPlan: Action;
opponentPlan: Action;
band: 'normal' | 'skilled';
skill: 'suk-graduate' | 'sandmaster';
reward: 'automatic' | 'selection' | 'absent';
};
export function skillsTechBattlePlayer(game: Game, id: string): Player {
const player = game.players.find(p => p.id === id);
assert.ok(player);
return player;
}
function withSkillOffer<T>(skill: SkillsTechBattleFixture['skill'], slot: 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);
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');
}
}
/** Native policy, with deliberate no-call/no-spend decisions at original windows. */
export function nextSkillsTechBattleStep(game: Game): SkillsTechBattleStep {
if (game.decision?.kind === 'battleCards')
return { actor: game.decision.player, action: { type: 'decision', discard: [] } };
if (game.decision?.kind === 'techToken')
return { actor: game.decision.player, action: { type: 'decision', token: game.decision.choices[0] } };
if (!game.decision && !game.response && game.battle?.preLeader?.closed === false) {
const battle = game.battle;
const actor = battle.preLeader!.ready.includes(battle.attacker) ? battle.defender : battle.attacker;
return { actor, action: { type: 'battlePreparationReady', event: battle.preLeader!.event } };
}
const next = nextSpiceBankerIncomeNativeStep(game);
assert.ok(next);
return next;
}
function step(game: Game): Game {
const next = nextSkillsTechBattleStep(game);
return applyAction(game, next.actor, next.action);
}
function reachPhase(state: Game, target: number): Game {
let game = state;
for (let i = 0; (game.phase !== target || game.phaseOpening || game.response || game.decision) && i < 1000; i++) {
assert.equal(game.turn, 1);
game = step(game);
}
assert.equal(game.phase, target);
assert.ok(!game.phaseOpening && !game.response && !game.decision);
return game;
}
function untrained(game: Game, player: Player, strongest: boolean) {
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
const leader = player.leaders.filter(l => !trained.has(l.id) && !l.dead && !l.usedAt)
.sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength)[0];
assert.ok(leader);
return leader;
}
function held(game: Game, player: Player, predicate: (card: Card) => boolean): string {
const index = game.deck.findIndex(predicate);
assert.ok(index >= 0, 'Conserved physical card must remain in the actual deck.');
const card = game.deck.splice(index, 1)[0];
player.hand.push(card);
return card.id;
}
/** Fresh original setup/first Storm/Tech assignment. Only the initial skill offer,
* unplayed Spice Card order and conserved battle board/cards are controlled.
* Wallets, phases, turn, plans, receipts and Tech owners are never fabricated.
* The optional preliminary battle earns a second token or leaves no token.
* Its second, already-present conflict keeps the genuine Battle phase open. */
export function createSkillsTechBattleFixture(options: SkillsTechBattleOptions = {}): SkillsTechBattleFixture {
const skill = options.skill ?? 'suk-graduate';
const band = options.band ?? 'skilled';
const reward = options.reward ?? 'automatic';
assert.ok(skill !== 'sandmaster' || band === 'skilled', 'Sandmaster victory uses its lower band.');
let game: Game;
if (options.initial) game = structuredClone(options.initial);
else {
game = createGame('SKILLSTECHBATTLE', newPlayer('guild', 'Guild', 'guild'), options.advanced ?? false);
joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
joinGame(game, newPlayer('beneGesserit', 'Bene Gesserit', 'beneGesserit'));
}
assert.equal(game.status, 'lobby');
assert.ok(game.players.length >= 3 && game.players.length <= 6);
assert.equal(game.expansions.length, 0);
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
const owner = game.players.find(p => p.faction === 'guild');
const opponent = game.players.find(p => p.faction === 'emperor');
assert.ok(owner && opponent, 'Authenticated lobby needs Guild and Emperor seats.');
if (!game.techTokens) game.techTokens = createTechTokens();
assert.deepEqual(game.techTokens, createTechTokens());
for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' });
game = withSkillOffer(skill, game.players.findIndex(p => p.id === owner.id) * 2,
() => initializeLeaderSkillsGameForAudit(game));
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 view = viewGame(game, actor).leaderSkills!;
const selected = actor === owner.id ? skill : offer.cards.find(c => !view.unavailableSkills?.[c]);
assert.ok(selected && offer.cards.includes(selected) && !view.unavailableSkills?.[selected]);
const eligible = new Set(view.eligibleLeaders.map(leader => leader.id));
const leader = skillsTechBattlePlayer(game, actor).leaders.filter(l => eligible.has(l.id))
.sort((a, b) => b.strength - a.strength)[0];
assert.ok(leader);
game = applyAction(game, actor, { type: 'leaderSkill', event: offer.event, skill: selected, leader: leader.id });
} else game = step(game);
}
assert.equal(game.status, 'playing');
// Arrange two real, unplayed non-worm blows; the first supplies the battle pile.
for (const [position, territory] of [[0, 'hagga_basin'], [1, 'funeral_plain']] as const) {
const index = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card && card.territory === territory);
assert.ok(index >= position);
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
game = reachPhase(game, 5);
const winner = skillsTechBattlePlayer(game, owner.id);
const enemy = skillsTechBattlePlayer(game, opponent.id);
const territory = 'hagga_basin';
const key = `${territory}:12`;
assert.ok(game.spice[key] > 0 && !TERRITORIES.find(t => t.id === territory)!.sectors.includes(game.storm));
// Return every board/card counter to its original physical reserve/deck.
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 = {};
}
if (p.advisors) p.advisors = {};
}
for (const p of [winner, enemy]) {
assert.ok(p.reserves - (p.elites?.reserves ?? 0) >= 10);
p.reserves -= 8; p.forces[key] = 8;
}
let supportTerritory: string | undefined;
if (reward !== 'automatic') {
const desert = TERRITORIES.find(t => t.type === 'sand' && t.id !== territory && !t.sectors.includes(game.storm));
assert.ok(desert);
supportTerritory = desert.id;
for (const p of [winner, enemy]) { p.reserves -= 2; p.forces[`${desert.id}:${desert.sectors[0]}`] = 2; }
}
game = reachPhase(game, 6);
if (supportTerritory) {
game = openSkillsTechBattle(game, winner.id, enemy.id, supportTerritory, true);
const firstWinner = skillsTechBattlePlayer(game, reward === 'selection' ? enemy.id : winner.id);
const firstLoser = skillsTechBattlePlayer(game, reward === 'selection' ? winner.id : enemy.id);
const winLeader = untrained(game, firstWinner, true);
const loseLeader = untrained(game, firstLoser, false);
assert.ok(winLeader.strength > loseLeader.strength);
game = applyAction(game, firstWinner.id, { type: 'battlePlan', leader: winLeader.id, dial: 0, support: 0 });
game = applyAction(game, firstLoser.id, { type: 'battlePlan', leader: loseLeader.id, dial: 0, support: 0 });
game = finishSkillsTechBattle(game, 'complete');
assert.equal(game.phase, 6);
assert.equal(game.lastBattleContext!.winner, firstWinner.id);
assert.equal(ownedTech(game.techTokens, enemy.id).length, reward === 'selection' ? 2 : 0);
}
const trainer = game.leaderSkills!.assignments.find(a => a.owner === winner.id && a.skill === skill)!.leader;
const selectedLeader = band === 'skilled' ? trainer : untrained(game, skillsTechBattlePlayer(game, winner.id), true).id;
const losingLeader = untrained(game, skillsTechBattlePlayer(game, enemy.id), false).id;
const weapon = held(game, skillsTechBattlePlayer(game, winner.id), card => card.kind === 'projectile');
const defense = held(game, skillsTechBattlePlayer(game, winner.id), card => card.kind === 'shield');
const losingCard = held(game, skillsTechBattlePlayer(game, enemy.id), card => card.kind === 'worthless');
return {
game, owner: winner.id, opponent: enemy.id, trainer, territory, key, weapon, defense, losingCard, losingLeader,
ownerPlan: { type: 'battlePlan', leader: selectedLeader, dial: 4, support: game.advanced ? 4 : 0, weapon, defense },
opponentPlan: { type: 'battlePlan', leader: losingLeader, dial: 0, support: 0, weapon: losingCard },
skill, band, reward,
};
}
/** Stop at actual private plan controls after original visibility/preparation. */
export function openSkillsTechBattle(state: Game, owner: string, opponent: string, territory: string, hide = true): Game {
assert.ok(state.active === owner || state.active === opponent);
let game = applyAction(state, state.active!, { type: 'chooseBattle', territory, target: state.active === owner ? opponent : owner });
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: game.decision.player === owner ? hide : true });
else game = step(game);
}
assert.ok(game.battle && !game.response && !game.decision);
return game;
}
/** Resolve calls/mandatory cleanup, stopping BEFORE the requested native choice.
* complete means this battle's reward is complete, not that the game is over. */
export function finishSkillsTechBattle(state: Game, stop: 'skill' | 'cleanup' | 'tech' | 'complete' = 'complete'): Game {
let game = state;
for (let i = 0; i < 200; i++) {
if (stop === 'skill' && game.decision?.kind === 'sukRescue') return game;
if ((stop === 'skill' || stop === 'cleanup') && game.decision?.kind === 'battleCards') return game;
if (stop === 'tech' && game.decision?.kind === 'techToken') return game;
if (!game.battle && !game.response && !game.decision && !game.pendingTreacheryDiscard) return game;
if (game.decision?.kind === 'sukRescue') {
const decision = game.decision;
const maximum = Math.max(...decision.options.map(o => o.normal + o.elite));
const choice = decision.options.findIndex(o => o.normal + o.elite === maximum);
game = applyAction(game, decision.player, { type: 'decision', event: decision.event, choice });
} else game = step(game);
}
throw new Error('Native skills/Tech battle aftermath did not settle.');
}