Files
Dune/tests/fixture-homeworld-classic-skills.ts

289 lines
20 KiB
TypeScript

import assert from 'node:assert/strict';
import { applyAction, createGame, initializeHomeworldOccupationGameForAudit, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game, type Player } from '../game/engine';
import { botActions } from '../game/bots';
import type { Difficulty } from '../game/bot-profiles';
import { territory } from '../game/board';
import { treacheryDeck } from '../game/cards';
import type { FactionId } from '../game/catalog';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
import { homeworldForceGroups } from '../game/homeworld-custody';
import { quoteNativeReserveWithdrawal } from '../game/homeworld-native-reserves';
import { homeworldCombatLocation } from '../game/homeworld-combat';
import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type ClassicHomeworldSkillsStep = { actor: string; action: Action };
export type ClassicHomeworldSkillsCase = 'native-bonus' | 'visitor-bonus' | 'native-death' | 'visitor-death' | 'native-suk' | 'visitor-suk' | 'arrakis-suk' | 'smuggler-payment';
export type ClassicHomeworldSkillsSetup = {
initial: Game;
offered: Game;
afterSetup: Game;
actions: ClassicHomeworldSkillsStep[];
staging: string[];
};
export type ClassicHomeworldSkillsFixture = ClassicHomeworldSkillsSetup & {
kind: ClassicHomeworldSkillsCase;
beforeShipment: Game | null;
shipment: ClassicHomeworldSkillsStep | null;
afterShipment: Game | null;
beforeBattle: Game | null;
game: Game;
owner: string;
opponent: string;
trainer: string;
territory: string;
plans: ClassicHomeworldSkillsStep[];
};
export const classicHomeworldSkillsPlayer = (game: Game, id: string): Player => {
const player = game.players.find(p => p.id === id); assert.ok(player); return player;
};
const sum = (values: Record<string, number>) => Object.values(values).reduce((a, b) => a + b, 0);
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
export function assertClassicHomeworldSkillsCustody(game: Game): void {
homeworldGameIntegrity(game);
validateLeaderSkills(game.leaderSkills!, game.players);
const homes = game.homeworlds?.custody ? homeworldForceGroups(homeworldContext(game), game.homeworlds.custody) : [];
for (const player of game.players) {
const visitors = homes.filter(home => home.native !== player.id).map(home => home.forces[player.id] ?? { normal: 0, elite: 0 });
assert.equal(player.reserves + player.tanks + sum(player.forces) + visitors.reduce((n, pool) => n + pool.normal + pool.elite, 0), 20, player.faction);
if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks + sum(player.elites.forces) + visitors.reduce((n, pool) => n + pool.elite, 0), player.faction === 'emperor' ? 5 : 3, player.faction);
}
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand), ...(game.auction?.cards.slice(game.auction.index) ?? [])];
assert.deepEqual(cards.map(c => c.id).sort(), treacheryDeck([]).map(c => c.id).sort());
}
/** Compute actual Fisher-Yates draws, not an assignment or held-card injection. */
function permutationDraws<T>(source: readonly T[], desired: readonly T[]): number[] {
const current = [...source], draws: number[] = [];
for (let i = current.length - 1; i > 0; i--) {
const j = current.indexOf(desired[i]); assert.ok(j >= 0 && j <= i);
draws.push(Math.floor(((j + 0.5) / (i + 1)) * 4294967296));
[current[i], current[j]] = [current[j], current[i]];
}
assert.deepEqual(current, desired); return draws;
}
function originalLottery<T>(draws: readonly number[], run: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto); let index = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = draws[index++] ?? 0xffffffff;
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return run(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function classicHomeworldSkillsBotOffers(game: Game, actor: string, difficulty: Difficulty): Action[] {
const view = viewGame(game, actor);
view.players.find(p => p.id === actor)!.bot = difficulty;
return botActions(view);
}
/** Original all-fourteen setup. Only original skill and Treachery shuffle draws
* are controlled. Starting hands, wallets, offers and leader custody are dealt
* by the original initializer and selected with real setup actions. */
export function createClassicHomeworldSkillsSetup(options: {
advanced?: boolean;
roster?: readonly FactionId[];
initial?: Game;
skill?: LeaderSkillId;
occupation?: boolean;
} = {}): ClassicHomeworldSkillsSetup {
const roster = options.roster ?? ['emperor', 'guild', 'atreides', 'harkonnen'];
let game = options.initial ? structuredClone(options.initial)
: createGame('CLASSICHWSKILLS', newPlayer(roster[0], roster[0], roster[0]), options.advanced ?? false);
if (!options.initial) for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
assert.ok(game.status === 'lobby' || (game.status === 'setup' && game.leaderSkills && game.homeworlds && game.turn === 1 && game.phase === 0),
'Continue only an original fresh lobby or its existing Homeworld Skills setup.');
if (game.status === 'lobby') {
if (!game.homeworlds) game = applyAction(game, game.host, { type: 'homeworlds', enabled: true });
for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' });
}
const initial = structuredClone(game), actions: ClassicHomeworldSkillsStep[] = [];
const owner = game.players[0].id, skill = options.skill ?? 'mentat';
const canonical = LEADER_SKILL_CARDS.map(c => c.id), requested: LeaderSkillId[] = [skill];
for (const p of game.players.slice(1)) {
const preferred: LeaderSkillId = p.faction === 'atreides' ? 'bureaucrat' :
p.faction === 'harkonnen' ? 'smuggler' : p.faction === 'guild' ? 'swordmaster-of-ginaz' :
p.faction === 'fremen' ? 'killer-medic' : 'prana-bindu-adept';
const selected = requested.includes(preferred)
? (['warmaster', 'planetologist', 'diplomat', 'master-of-assassins', 'sandmaster'] as const).find(id => !requested.includes(id))!
: preferred;
assert.ok(selected); requested.push(selected);
}
const rest = canonical.filter(id => !requested.includes(id));
const desired = requested.flatMap(id => [id, rest.shift()!]).concat(rest);
const cards = treacheryDeck([]), prefix: string[] = [];
for (const [index, player] of game.players.entries()) {
const kind = index === 0 ? 'worthless' : index === 1 ? 'projectile' : 'snooper';
const card = cards.find(c => c.kind === kind && !prefix.includes(c.id)) ?? cards.find(c => !prefix.includes(c.id))!;
prefix.push(card.id);
if (player.faction === 'harkonnen') prefix.push(cards.find(c => !prefix.includes(c.id))!.id);
}
const deckOrder = [...prefix, ...cards.map(c => c.id).filter(id => !prefix.includes(id))];
if (game.status === 'lobby') game = originalLottery(
[...permutationDraws(canonical, desired), ...permutationDraws(cards.map(c => c.id), deckOrder)],
() => options.occupation ? initializeHomeworldOccupationGameForAudit(game, true)
: initializeLeaderSkillsGameForAudit(game));
let offered: Game | null = null;
for (let count = 0; game.status === 'setup' && count < 200; count++) {
let next: ClassicHomeworldSkillsStep;
if (game.setupStage === 'leaderSkills') {
offered ??= structuredClone(game);
const actor = Object.keys(game.leaderSkills!.offers)[0];
const view = viewGame(game, actor).leaderSkills!, offer = view.offer!;
const selected = requested[game.players.findIndex(p => p.id === actor)];
assert.ok(offer.cards.includes(selected));
assert.ok(!view.unavailableSkills?.[selected]);
const leaders = view.eligibleLeaders.map(l => classicHomeworldSkillsPlayer(game, actor).leaders.find(disc => disc.id === l.id)!)
.sort((a, b) => b.strength - a.strength);
assert.ok(leaders[0]);
next = { actor, action: { type: 'leaderSkill', event: offer.event, skill: selected, leader: leaders[0].id } };
} else {
const candidate = nextStrongholdFactionsNativeStep(game); assert.ok(candidate); next = candidate;
}
actions.push(structuredClone(next)); game = applyAction(game, next.actor, next.action);
}
assert.ok(offered); assert.equal(game.status, 'playing');
assert.ok(game.leaderSkills!.assignments.some(a => a.owner === owner && a.skill === skill));
assertClassicHomeworldSkillsCustody(game);
return { initial, offered, afterSetup: structuredClone(game), actions,
staging: ['Controlled the original skill/Treachery lottery before any component was dealt; all starting hands and skill assignments are real setup selections.'] };
}
export function nextClassicHomeworldSkillsStep(game: Game, hideOwner?: string): ClassicHomeworldSkillsStep | null {
const decision = game.decision;
if (decision?.kind === 'leaderSkillVisibility') return { actor: decision.player, action: {
type: 'leaderSkillVisibility', event: decision.event, hide: decision.player === hideOwner,
} };
if (decision?.kind === 'mentatQuestion') return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } };
if (decision?.kind === 'rihani') return { actor: decision.player, action: { type: 'decision', event: decision.event, draw: false } };
if (decision?.kind === 'bureaucratPayment') return { actor: decision.player, action: { type: 'decision', event: game.bureaucratPaymentEvent, redirect: false } };
if (decision?.kind === 'sukRescue') return null;
if (decision?.kind === 'battleLosses' && game.advanced && game.pendingSukRescue) {
const elite = Math.max(...decision.options.map(option => option.elite));
return { actor: decision.player, action: { type: 'decision',
choice: decision.options.findIndex(option => option.elite === elite) } };
}
if (decision?.kind === 'homeworldDefense') return { actor: decision.player, action: { type: 'decision', event: decision.event, use: false } };
return nextStrongholdFactionsNativeStep(game);
}
export function advanceClassicHomeworldSkills(state: Game, until: (game: Game) => boolean,
actions?: ClassicHomeworldSkillsStep[], hideOwner?: string): Game {
let game = state;
for (let count = 0; count < 1600; count++) {
if (until(game)) return game;
const next = nextClassicHomeworldSkillsStep(game, hideOwner); assert.ok(next, 'A real human Battle Plan or Suk choice is required');
actions?.push(structuredClone(next)); game = applyAction(game, next.actor, next.action);
}
throw Error('Original classic Homeworld Skills programme did not reach its requested boundary');
}
function shipHome(game: Game, actor: string, destination: string, normal: number, elite: number): ClassicHomeworldSkillsStep {
const player = classicHomeworldSkillsPlayer(game, actor);
const sources = player.faction === 'emperor' && game.advanced ? {
'homeworld:emperor': { normal, elite: 0 }, 'homeworld:emperor:salusa': { normal: 0, elite },
} : { [`homeworld:${player.faction}`]: { normal, elite } };
return { actor, action: { type: 'homeworldShip', event: viewGame(game, actor).homeworldShipment!.event, destination, sources } };
}
/** Genuine phase sequence and invasion shipment. The Arrakis case alone uses a
* labeled conserved later board position; it never fabricates a battle window,
* casualty, death, invoice, skill assignment or held card. */
export function createClassicHomeworldSkillsFixture(options: { kind?: ClassicHomeworldSkillsCase; advanced?: boolean; initial?: Game } = {}): ClassicHomeworldSkillsFixture {
const kind = options.kind ?? 'native-bonus', advanced = options.advanced ?? false;
const skill = kind.includes('suk') ? 'suk-graduate' : kind === 'smuggler-payment' ? 'smuggler' : 'mentat';
const roster: FactionId[] = ['emperor', 'guild', 'atreides', 'harkonnen'];
const setup = createClassicHomeworldSkillsSetup({ advanced, initial: options.initial, roster, skill });
let game = advanceClassicHomeworldSkills(setup.afterSetup, g => g.phase === 5 && clean(g), setup.actions);
const owner = game.players[0].id, opponent = game.players[1].id;
const trainer = game.leaderSkills!.assignments.find(a => a.owner === owner)!.leader;
const visitor = kind.startsWith('visitor');
const location = kind === 'arrakis-suk' ? 'false_wall_south' : kind === 'smuggler-payment' ? 'sietch_tabr' :
visitor ? 'homeworld:guild' : kind === 'native-suk' && advanced ? 'homeworld:emperor:salusa' : 'homeworld:emperor';
let beforeShipment: Game | null = null, shipment: ClassicHomeworldSkillsStep | null = null, afterShipment: Game | null = null;
if (kind === 'arrakis-suk') {
const count = 8, elite = advanced ? 2 : 0;
const sources = advanced ? { 'homeworld:emperor': { normal: count - elite, elite: 0 }, 'homeworld:emperor:salusa': { normal: 0, elite } } : undefined;
const quote = quoteNativeReserveWithdrawal(homeworldContext(game), game.homeworlds!.custody!, owner, { normal: count - elite, elite }, sources);
game.homeworlds!.custody = quote.state;
for (const update of quote.players) { const p = classicHomeworldSkillsPlayer(game, update.id); p.reserves = update.reserves; if (p.elites) p.elites.reserves = update.eliteReserves; }
const key = `${location}:${territory(location).sectors[0]}`;
classicHomeworldSkillsPlayer(game, owner).forces[key] = count;
if (elite) classicHomeworldSkillsPlayer(game, owner).elites!.forces[key] = elite;
const other = classicHomeworldSkillsPlayer(game, opponent);
const total = sum(other.forces); other.forces = { [key]: total };
setup.staging.push('Conserved later Arrakis position: eight Emperor counters withdrew from their real native homes; the original Guild board army moved to the same desert. No shipment or battle receipt is claimed for these placements.');
assertClassicHomeworldSkillsCustody(game);
} else {
const actor = kind === 'smuggler-payment' || visitor ? owner : opponent;
game = advanceClassicHomeworldSkills(game, g => g.phase === 5 && clean(g) && g.active === actor, setup.actions);
beforeShipment = structuredClone(game);
shipment = kind === 'smuggler-payment' ? { actor, action: { type: 'ship', territory: location,
sector: territory(location).sectors[0], amount: 6, elite: advanced ? 2 : 0, smuggler: true,
homeworldSources: advanced ? { 'homeworld:emperor': { normal: 4, elite: 0 }, 'homeworld:emperor:salusa': { normal: 0, elite: 2 } } : undefined } }
: shipHome(game, actor, location, visitor ? 4 : 3, visitor && advanced ? 1 : 0);
setup.actions.push(structuredClone(shipment)); game = applyAction(game, actor, shipment.action);
if (game.decision?.kind === 'guildShipment') {
const allow = { actor: game.decision.player, action: { type: 'decision', allow: true } };
setup.actions.push(allow); game = applyAction(game, allow.actor, allow.action);
}
afterShipment = structuredClone(game);
if (kind === 'smuggler-payment') return { ...setup, kind, beforeShipment, shipment, afterShipment, beforeBattle: null, game, owner, opponent, trainer, territory: location, plans: [] };
game = advanceClassicHomeworldSkills(game, g => g.phase === 5 && clean(g), setup.actions);
}
game = advanceClassicHomeworldSkills(game, g => g.phase === 6 && clean(g), setup.actions);
const beforeBattle = structuredClone(game), actor = game.active!;
assert.ok(actor === owner || actor === opponent);
const choose = { actor, action: { type: 'chooseBattle', territory: location, target: actor === owner ? opponent : owner } };
setup.actions.push(choose); game = applyAction(game, actor, choose.action);
game = advanceClassicHomeworldSkills(game, g => !!g.battle && clean(g) && !g.battle.preparation && g.battle.preLeader?.closed !== false, setup.actions, owner);
const enemy = classicHomeworldSkillsPlayer(game, opponent);
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
const leader = enemy.leaders.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => a.strength - b.strength)[0]; assert.ok(leader);
const weapon = kind.endsWith('death') ? enemy.hand.find(c => c.kind === 'projectile')?.id : undefined;
if (kind.endsWith('death')) assert.ok(weapon, 'Actual original opponent starting hand holds the lethal weapon');
const dial = kind.endsWith('death') ? 0 : kind === 'native-suk' && advanced ? 6 : 4;
const plans: ClassicHomeworldSkillsStep[] = [
{ actor: owner, action: { type: 'battlePlan', leader: trainer, dial, support: advanced && dial && kind !== 'native-suk' ? 2 : 0 } },
{ actor: opponent, action: { type: 'battlePlan', leader: leader.id, dial: 0, weapon } },
];
assertClassicHomeworldSkillsCustody(game);
return { ...setup, kind, beforeShipment, shipment, afterShipment, beforeBattle, game, owner, opponent, trainer, territory: location, plans };
}
export function revealClassicHomeworldSkillsBattle(fixture: ClassicHomeworldSkillsFixture): Game {
let game = structuredClone(fixture.game);
for (const step of fixture.plans) {
game = applyAction(game, step.actor, step.action);
if (!game.battle?.revealed) game = advanceClassicHomeworldSkills(game, g => clean(g) && !!g.battle && !g.battle.preparation, undefined, fixture.owner);
}
assert.ok(game.battle?.revealed); return game;
}
export function settleClassicHomeworldSkillsBattle(state: Game, stop: 'suk' | 'complete' = 'complete'): Game {
return advanceClassicHomeworldSkills(state, game => stop === 'suk' && game.decision?.kind === 'sukRescue' ||
!game.battle && clean(game) && !game.pendingTreacheryDiscard);
}
export function quoteClassicHomeworldSkillsBattle(game: Game) {
const battle = game.battle; assert.ok(battle?.revealed);
const side = (actor: string): ResolutionCombatant => {
const player = classicHomeworldSkillsPlayer(game, actor), view = viewGame(game, actor);
return { id: actor, faction: player.faction, spice: player.spice, hand: player.hand,
plan: battle.plans[actor], leader: player.leaders.find(l => l.id === battle.plans[actor].leader),
forces: view.battle!.ownForces!, leaderSkills: view.leaderSkills!.assignments.filter(a => a.controller === actor),
occupiedStrongholds: new Set(Object.keys(player.forces).map(key => key.split(':')[0]).filter(id => territory(id).type === 'stronghold')).size };
};
const home = battle.territory.startsWith('homeworld:') ? homeworldCombatLocation({ ...homeworldContext(game), order: game.order }, game.homeworlds!.custody!, battle.territory) : null;
return quoteBattleResolution({ advanced: game.advanced, typedCasualties: true, turn: game.turn, territory: battle.territory,
aggressor: viewGame(game, game.host).battle!.aggressor,
...(home ? { homeworld: { native: home.native, card: home.card, side: home.side, nativeForces: home.forces[home.native] } } : {}),
attacker: side(battle.attacker), defender: side(battle.defender),
voters: viewGame(game, game.host).battle!.traitorVoters.map(id => ({ id, beneficiary: id, called: battle.traitorCalls[id] ?? false, traitors: classicHomeworldSkillsPlayer(game, id).traitors })),
participants: game.players, physicalCards: [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)],
pendingAuditorPresent: false, pendingRetentionPresent: false });
}