Compose classic Homeworld Skills and Discovery with physical custody

This commit is contained in:
Quintine committed 2026-10-06 18:07:41 +11:00
1 parent 4a3bd8fe90
commit e2dcebca35
24 files changed
+1426 -54

No files matched your search

@@ -0,0 +1,197 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeDiscoveryGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game,
} from '../game/engine';
import { classicHomeworldDiscoveryProfile } from '../game/discovery-module-profile';
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_SPICE_CARDS, DISCOVERY_TOKEN_BY_ID, validateDiscoveryState,
type DiscoveryLocationId, type DiscoveryOpaqueTokenId } from '../game/discoveries';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import { nativeShipmentSources } from '../game/homeworld-options';
import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
export type HomeworldClassicDiscoveryStep = { actor: string; action: Action };
export type HomeworldClassicDiscoveryOptions = {
/** Original fresh lobby/staged setup; existing first hands are not redealt. */
initial?: Game;
advanced?: boolean;
seats?: 2 | 3 | 4 | 5 | 6;
face?: DiscoveryLocationId;
/** Exercise the previous blow army, distinct from the token's printed parent. */
shipForMaker?: boolean;
/** Real turn-one movement supplies a later split ordinary-reserve shipment. */
prepareImperialSplit?: boolean;
};
export type HomeworldClassicDiscoveryFixture = {
initial: Game; setup: Game; afterSetup: Game; game: Game;
actions: HomeworldClassicDiscoveryStep[]; staging: string[];
collector: string; fremen: string; token: DiscoveryOpaqueTokenId; face: DiscoveryLocationId;
parent: string; parentSector: number; amount: number; elite: number;
wormTerritory: string; wormSector: number;
};
export const homeworldClassicDiscoveryClean = (game: Game) =>
!game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
export const homeworldClassicDiscoveryPlayer = (game: Game, actor: string) => {
const player = game.players.find(player => player.id === actor);
assert.ok(player); return player;
};
/** Public owned continuations only. Storm dials use their original legal range;
* no phase clock, casualty, hand, balance or signed Discovery frame is staged. */
export function nextHomeworldClassicDiscoveryStep(game: Game): HomeworldClassicDiscoveryStep {
if (!game.phaseOpening && !game.response) {
const decision = game.decision;
if (decision?.kind === 'discoveryEntry' || decision?.kind === 'greatMakerRide')
return { actor: decision.player, action: { type: 'decision', event: decision.event, accept: false } };
if (decision?.kind === 'greatMakerVote')
return { actor: decision.player, action: { type: 'decision', event: decision.event, yes: false } };
if (decision?.kind === 'wormRide')
return { actor: decision.player, action: { type: 'decision', accept: false } };
if (decision?.kind === 'wormProtection')
return { actor: decision.player, action: { type: 'decision', accept: true } };
}
const next = nextSpiceBankerIncomeNativeStep(game);
assert.ok(next, 'Original Homeworld/Discovery continuation needs an owned legal action.');
return next;
}
export function advanceHomeworldClassicDiscovery(state: Game, until: (game: Game) => boolean,
actions?: HomeworldClassicDiscoveryStep[]): Game {
let game = state;
for (let i = 0; !until(game) && i < 1800; i++) {
assert.notEqual(game.status, 'finished');
const next = nextHomeworldClassicDiscoveryStep(game);
actions?.push(structuredClone(next)); game = applyAction(game, next.actor, next.action);
}
assert.ok(until(game), 'Original Homeworld/Discovery programme did not reach its action window.');
return game;
}
export function homeworldClassicDiscoveryInventory(game: Game): void {
homeworldGameIntegrity(game);
validateDiscoveryState(game.discoveries!);
const spice = [...game.spiceDeck, ...game.spiceDiscard.flat(), ...(game.spiceResolution?.skipped ?? []),
...(game.spiceSequence?.skipped ?? [])];
assert.equal(spice.filter(card => 'territory' in card && !!card.discovery).length, 6);
assert.equal(spice.filter(card => 'worm' in card && card.greatMaker).length, 1);
assert.equal(game.discoveries!.tokens.length, 8);
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)];
assert.equal(cards.length, 33);
assert.equal(new Set(cards.map(card => card.id)).size, 33);
for (const player of game.players) if (player.elites) {
assert.equal(player.elites.reserves + player.elites.tanks +
Object.values(player.elites.forces).reduce((a, b) => a + b, 0), player.faction === 'fremen' ? 3 : 5);
}
}
function selectedTokenLottery<T>(game: Game, face: DiscoveryLocationId, operation: () => T): T {
const pool = game.discoveries!.tokens.filter(token => token.status === 'supply' && token.type === DISCOVERY_TOKEN_BY_ID[face].type);
const index = pool.findIndex(token => token.face === face); assert.ok(index >= 0);
const native = crypto.getRandomValues.bind(crypto), descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1)
array[0] = Math.floor((index + 0.5) / pool.length * 0x100000000);
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
function frontSpice(game: Game, choose: (card: Game['spiceDeck'][number]) => boolean, position: number): void {
const index = game.spiceDeck.findIndex((card, i) => i >= position && choose(card));
assert.ok(index >= position, 'The original unplayed physical Spice Card must remain available.');
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
/** Root rulebook physical pp.22–23: reserves/Homeworlds, Emperor split sources,
* seven Spice Cards/eight tokens, actual printed blow and Collection reveal.
* Only original unplayed Spice order and the original supply lottery are selected. */
export function createHomeworldClassicDiscoveryFixture(options: HomeworldClassicDiscoveryOptions = {}): HomeworldClassicDiscoveryFixture {
let game: Game;
if (options.initial) game = structuredClone(options.initial);
else {
const factions = ['emperor', 'fremen', 'guild', 'harkonnen', 'atreides', 'beneGesserit'] as const;
game = createGame('HWCLASSICDISCOVERY', newPlayer('emperor', 'Emperor', 'emperor'), options.advanced ?? false);
for (const faction of factions.slice(1, options.seats ?? 4)) joinGame(game, newPlayer(faction, faction, faction));
game = applyAction(game, game.host, { type: 'homeworlds', enabled: true });
game.discoveryEnabled = true;
}
assert.ok(game.status === 'lobby' || (game.status === 'setup' && game.turn === 1 && game.phase === 0));
assert.ok(classicHomeworldDiscoveryProfile(game));
const collector = game.players.find(player => player.faction === 'emperor')!.id;
const fremen = game.players.find(player => player.faction === 'fremen')!.id;
assert.ok(collector && fremen);
const actions: HomeworldClassicDiscoveryStep[] = [];
if (game.status === 'lobby') for (const player of game.players) if (!player.ready) {
const next: HomeworldClassicDiscoveryStep = { actor: player.id, action: { type: 'ready' } };
actions.push(next); game = applyAction(game, next.actor, next.action);
}
const initial = structuredClone(game);
if (game.status === 'lobby') game = initializeDiscoveryGameForAudit(game);
const setup = structuredClone(game);
game = advanceHomeworldClassicDiscovery(game, state => state.status === 'playing', actions);
const afterSetup = structuredClone(game), face = options.face ?? 'cistern';
frontSpice(game, card => 'territory' in card && card.discovery === 'discovery-hagga-basin', 0);
// Advanced consumes two piles; Basic consumes one. The next original Great
// Maker therefore meets pile zero's original Hagga Basin blow on turn two.
if (game.advanced) frontSpice(game, card => 'territory' in card && !card.discovery && card.territory !== 'hagga_basin', 1);
const nextTurnIndex = game.advanced ? 2 : 1;
frontSpice(game, card => 'worm' in card && !!card.greatMaker, nextTurnIndex);
for (let position = nextTurnIndex + 1; position < nextTurnIndex + (game.advanced ? 3 : 2); position++)
frontSpice(game, card => 'territory' in card && !card.discovery && card.territory !== 'hagga_basin', position);
const staging = ['Reordered conserved original unplayed Spice Cards for the printed Hagga Basin Discovery blow and turn-two Great Maker; original starting hands/Treachery deck untouched.',
`Selected ${face} through the actual original Hiereg supply-token lottery; no reveal, force position or Discovery continuation injected.`];
game = advanceHomeworldClassicDiscovery(game, state => state.phase === 1 && homeworldClassicDiscoveryClean(state), actions);
game = selectedTokenLottery(game, face, () => advanceHomeworldClassicDiscovery(game,
state => state.discoveries!.tokens.some(token => token.face === face && token.status === 'placed'), actions));
game = advanceHomeworldClassicDiscovery(game, state => state.phase === 5 && state.active === collector && homeworldClassicDiscoveryClean(state), actions);
const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
const blow = DISCOVERY_SPICE_CARDS.find(card => card.discovery === 'discovery-hagga-basin')!;
const destination = options.shipForMaker ? blow : placement;
const amount = game.advanced ? 3 : 2, elite = game.advanced ? 1 : 0;
const sources = nativeShipmentSources(viewGame(game, collector), amount, elite); assert.ok(sources);
const shipment: HomeworldClassicDiscoveryStep = { actor: collector, action: { type: 'ship',
territory: destination.territory, sector: destination.sector, amount, elite, allyPayment: 0, homeworldSources: sources } };
actions.push(shipment); game = applyAction(game, collector, shipment.action);
if (game.advanced && options.prepareImperialSplit) {
game = advanceHomeworldClassicDiscovery(game, state => !state.pendingShipment && homeworldClassicDiscoveryClean(state), actions);
const movement = viewGame(game, collector).homeworldMove!;
const transfer: HomeworldClassicDiscoveryStep = { actor: collector, action: {
type: 'emperorHomeworldMove', event: movement.event, origin: 'homeworld:emperor', normal: 1, elite: 0,
} };
actions.push(transfer); game = applyAction(game, collector, transfer.action);
}
game = advanceHomeworldClassicDiscovery(game, state => state.phase === 7 && homeworldClassicDiscoveryClean(state), actions);
const token = game.discoveries!.tokens.find(token => token.face === face)!;
assert.equal(game.turn, 1); homeworldClassicDiscoveryInventory(game);
return { initial, setup, afterSetup, game, actions, staging, collector, fremen, token: token.id, face,
parent: placement.territory, parentSector: placement.sector, amount, elite,
wormTerritory: blow.territory, wormSector: blow.sector };
}
export function revealHomeworldClassicDiscovery(fixture: HomeworldClassicDiscoveryFixture): Game {
let game = fixture.game;
if (viewGame(game, fixture.collector).discoveries!.canInspect.includes(fixture.token))
game = applyAction(game, fixture.collector, { type: 'discovery', token: fixture.token, reveal: false });
assert.ok(viewGame(game, fixture.collector).discoveries!.canReveal.includes(fixture.token));
return applyAction(game, fixture.collector, { type: 'discovery', token: fixture.token, reveal: true });
}
export function homeworldClassicDiscoveryEntryWindow(fixture: HomeworldClassicDiscoveryFixture): Game {
return advanceHomeworldClassicDiscovery(revealHomeworldClassicDiscovery(fixture), game =>
game.turn === 2 && game.decision?.kind === 'discoveryEntry' && game.decision.player === fixture.collector);
}
export function enterHomeworldClassicDiscovery(fixture: HomeworldClassicDiscoveryFixture): Game {
const game = homeworldClassicDiscoveryEntryWindow(fixture), view = viewGame(game, fixture.collector);
const action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources); assert.ok(action);
return applyAction(game, fixture.collector, action);
}
export function homeworldClassicDiscoveryShipmentWindow(state: Game, owner: string): Game {
return advanceHomeworldClassicDiscovery(state, game => game.turn === 2 && game.phase === 5 &&
game.active === owner && homeworldClassicDiscoveryClean(game));
}
export function settleHomeworldClassicDiscoveryArrival(state: Game): Game {
return advanceHomeworldClassicDiscovery(state, game => !game.pendingShipment && homeworldClassicDiscoveryClean(game));
}
export function homeworldClassicGreatMakerVoteWindow(fixture: HomeworldClassicDiscoveryFixture): Game {
return advanceHomeworldClassicDiscovery(fixture.game, game => game.decision?.kind === 'greatMakerVote');
}
+286
View File
@@ -0,0 +1,286 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, 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;
} = {}): 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)],
() => 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 });
}
+1 -1
View File
@@ -37,7 +37,7 @@ export function nextSpiceBankerIncomeNativeStep(game:Game):SpiceBankerIncomeNati
const voter=viewGame(game,game.host).battle!.traitorVoters.find(id=>game.battle!.traitorCalls[id]===undefined);
if(voter) return {actor:voter,action:{type:'traitorCall',call:false}};
}
if(game.phase!==6||!game.active) {
if([1,2,4,7,8].includes(game.phase)||(game.phase===0&&game.stormPending!==null)) {
const seat=game.players.find(p=>!game.ready.includes(p.id));
if(seat) return {actor:seat.id,action:{type:'ready'}};
}
+1 -1
View File
@@ -124,7 +124,7 @@ export function nextStrongholdFactionsNativeStep(game: Game): StrongholdFactions
}
if (game.phase === 3 && game.auction) return { actor: game.auction.active, action: { type: 'passBid' } };
if (game.phase === 5) return { actor: game.active!, action: { type: 'endMovement' } };
if (game.phase !== 6 || !game.active) {
if ([1, 2, 4, 7, 8].includes(game.phase) || (game.phase === 0 && game.stormPending !== null)) {
const actor = game.players.find(p => !game.ready.includes(p.id))?.id;
if (actor) return { actor, action: { type: 'ready' } };
}
@@ -0,0 +1,232 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { gameTerritories, validGameLocation } from '../game/board';
import { discoveryEntryDeclineAction, discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { greatMakerRideAction } from '../game/great-maker-options';
import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import {
advanceHomeworldClassicDiscovery, createHomeworldClassicDiscoveryFixture,
enterHomeworldClassicDiscovery, homeworldClassicDiscoveryClean,
homeworldClassicDiscoveryEntryWindow, homeworldClassicDiscoveryInventory,
homeworldClassicDiscoveryPlayer, homeworldClassicDiscoveryShipmentWindow,
homeworldClassicGreatMakerVoteWindow, settleHomeworldClassicDiscoveryArrival,
} from './fixture-homeworld-classic-discovery';
/** Source: authorized root UNOFFICIAL_Revised_Dune_Rulebook_v.2.3_web.pdf,
* physical p.22 Homeworlds (not territories; normal battle modifications;
* Emperor's split shipment), physical p.23 Discovery Tokens/Great Maker
* (7 cards/8 tokens; ordinary worm; storm-order majority; native reserve ride;
* parent-board-only next-turn free entry; normal paid shipment at 1/force).
* These programmes select original unplayed Spice positions/token lottery,
* not held cards, fake casualties, free-entry frames or source receipts. */
function legalPolicies(game: Game, actor: string): void {
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, actor);
view.players.find(player => player.id === actor)!.bot = difficulty;
const actions = botActions(view);
assert.ok(actions.length, `${difficulty} needs an owned minimal legal continuation.`);
const done = applyAction(structuredClone(game), actor, actions[0]);
homeworldClassicDiscoveryInventory(done);
}
}
function reject(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
void test('original Homeworld/Discovery setup admits Basic/Advanced two through six base seats with all physical cards and typed forces', () => {
for (const advanced of [false, true]) for (const seats of [2, 3, 4, 5, 6] as const) {
const fixture = createHomeworldClassicDiscoveryFixture({ advanced, seats });
assert.equal(fixture.game.players.length, seats);
assert.equal(fixture.game.turn, 1); assert.equal(fixture.game.phase, 7);
homeworldClassicDiscoveryInventory(fixture.setup);
homeworldClassicDiscoveryInventory(fixture.afterSetup);
homeworldClassicDiscoveryInventory(fixture.game);
}
});
void test('Great Maker devours the actual previous blow army before storm-order voting, with majority and tie outcomes and typed native ride', () => {
for (const advanced of [false, true]) for (const majority of [false, true]) {
const fixture = createHomeworldClassicDiscoveryFixture({ advanced, shipForMaker: true });
// Explicit later conserved position, not a shipment/history receipt: keep
// native board counters beside the actually shipped Emperor army to exercise
// the printed ordinary-worm Fremen survival before the reserve-only ride.
const nativePosition = homeworldClassicDiscoveryPlayer(fixture.game, fixture.fremen);
const parentKey = `${fixture.wormTerritory}:${fixture.wormSector}`, positionedElite = advanced ? 1 : 0;
assert.ok(nativePosition.reserves - (nativePosition.elites?.reserves ?? 0) >= 2 - positionedElite);
nativePosition.reserves -= 2;
nativePosition.forces[parentKey] = (nativePosition.forces[parentKey] ?? 0) + 2;
if (positionedElite) {
nativePosition.elites!.reserves -= positionedElite;
nativePosition.elites!.forces[parentKey] = (nativePosition.elites!.forces[parentKey] ?? 0) + positionedElite;
}
fixture.staging.push(`Conserved two original Fremen native-reserve counters (${positionedElite} elite) into the previous blow sector after turn-one Collection opened; no casualty, shipment or history fabricated.`);
const nativeBeforeWorm = structuredClone(nativePosition);
homeworldClassicDiscoveryInventory(fixture.game);
const originalArmy = homeworldClassicDiscoveryPlayer(fixture.game, fixture.collector);
let game = homeworldClassicGreatMakerVoteWindow(fixture);
assert.equal(game.turn, 2); assert.equal(game.phase, 1);
assert.equal(game.greatMaker!.territory, fixture.wormTerritory);
const devoured = homeworldClassicDiscoveryPlayer(game, fixture.collector);
assert.equal(devoured.forces[parentKey] ?? 0, 0);
assert.equal(devoured.tanks, originalArmy.tanks + fixture.amount);
assert.equal(devoured.elites?.tanks ?? 0, (originalArmy.elites?.tanks ?? 0) + fixture.elite);
assert.equal(devoured.reserves, originalArmy.reserves);
assert.equal(game.discoveries!.tokens.find(token => token.id === fixture.token)!.revealedTurn, null);
const order = [...game.order];
for (let index = 0; index < order.length; index++) {
assert.equal(game.decision?.kind, 'greatMakerVote'); assert.equal(game.decision!.player, order[index]);
legalPolicies(game, order[index]);
const vote: Action = { type: 'decision', event: game.decision!.event, yes: index < (majority ? 3 : 2) };
reject(game, order[(index + 1) % order.length], vote);
game = applyAction(game, order[index], vote);
}
const survived = homeworldClassicDiscoveryPlayer(game, fixture.fremen);
assert.equal(survived.forces[parentKey], nativeBeforeWorm.forces[parentKey]);
assert.equal(survived.elites?.forces[parentKey] ?? 0, nativeBeforeWorm.elites?.forces[parentKey] ?? 0);
assert.equal(survived.tanks, nativeBeforeWorm.tanks);
assert.equal(survived.reserves, nativeBeforeWorm.reserves);
assert.equal(game.nexus, majority, 'Two of four is not a majority; three is.');
assert.equal(game.decision?.kind, 'greatMakerRide'); assert.equal(game.decision!.player, fixture.fremen);
legalPolicies(game, fixture.fremen);
const before = structuredClone(homeworldClassicDiscoveryPlayer(game, fixture.fremen));
const view = viewGame(game, fixture.fremen), destinations = view.greatMaker!.ride!.destinations;
assert.ok(destinations.every(destination => !destination.territory.startsWith('homeworld:')));
const target = destinations.find(destination => destination.territory === 'polar_sink')!; assert.ok(target);
const elite = advanced ? 1 : 0, action = greatMakerRideAction(view, target.territory, target.sector, 2, elite);
assert.ok(action);
assert.equal(greatMakerRideAction(view, 'homeworld:fremen', 0, 2, elite), null);
reject(game, fixture.fremen, { ...action, territory: 'homeworld:fremen', sector: 0 });
reject(game, fixture.fremen, { ...action, territory: fixture.parent, sector: game.storm });
reject(game, fixture.fremen, { ...action, elite: 4 });
game = applyAction(game, fixture.fremen, action);
const after = homeworldClassicDiscoveryPlayer(game, fixture.fremen);
assert.equal(after.reserves, before.reserves - 2);
assert.equal(after.elites?.reserves ?? 0, (before.elites?.reserves ?? 0) - elite);
assert.equal(after.forces[`${target.territory}:${target.sector}`], (before.forces[`${target.territory}:${target.sector}`] ?? 0) + 2);
assert.equal(after.elites?.forces[`${target.territory}:${target.sector}`] ?? 0,
(before.elites?.forces[`${target.territory}:${target.sector}`] ?? 0) + elite);
assert.equal(after.spice, before.spice); assert.equal(after.moved, before.moved); assert.equal(after.shipped, before.shipped);
const home = viewGame(game, fixture.fremen).homeworlds!.worlds!.find(world => world.native === fixture.fremen)!;
assert.equal(home.forces[fixture.fremen].normal + home.forces[fixture.fremen].elite, after.reserves);
assert.equal(home.forces[fixture.fremen].elite, after.elites?.reserves ?? 0);
homeworldClassicDiscoveryInventory(game);
}
});
void test('actual next-turn free entry moves only the parent board ordinary/elite groups and leaves native pools, spice and normal allowances untouched', () => {
for (const advanced of [false, true]) {
const fixture = createHomeworldClassicDiscoveryFixture({ advanced });
const game = homeworldClassicDiscoveryEntryWindow(fixture), view = viewGame(game, fixture.collector);
assert.equal(game.turn, 2); assert.equal(game.phase, 0);
assert.equal(homeworldClassicDiscoveryPlayer(game, fixture.collector).forces[`${fixture.face}:0`] ?? 0, 0);
const sources = view.discoveryEntry!.sources;
assert.deepEqual(sources, [{ source: `${fixture.parent}:${fixture.parentSector}`, normal: fixture.amount - fixture.elite, elite: fixture.elite }]);
legalPolicies(game, fixture.collector);
const accept = discoveryEntryMoveAction(view, sources), decline = discoveryEntryDeclineAction(view); assert.ok(accept && decline);
const before = structuredClone(homeworldClassicDiscoveryPlayer(game, fixture.collector)), custody = structuredClone(game.homeworlds!.custody);
const declined = applyAction(structuredClone(game), fixture.collector, decline);
assert.deepEqual(homeworldClassicDiscoveryPlayer(declined, fixture.collector).forces, before.forces);
for (const source of ['homeworld:emperor', 'homeworld:emperor:salusa', `${fixture.face}:0`, 'arrakeen:10']) {
const groups = [{ source, normal: 1, elite: 0 }];
assert.equal(discoveryEntryMoveAction(view, groups), null);
reject(game, fixture.collector, { ...accept, groups });
}
reject(game, fixture.fremen, accept);
reject(game, fixture.collector, { ...accept, groups: [{ ...sources[0], normal: 21 }] });
const entered = applyAction(game, fixture.collector, accept), after = homeworldClassicDiscoveryPlayer(entered, fixture.collector);
assert.equal(after.forces[`${fixture.face}:0`], fixture.amount);
assert.equal(after.elites?.forces[`${fixture.face}:0`] ?? 0, fixture.elite);
assert.equal(after.forces[`${fixture.parent}:${fixture.parentSector}`] ?? 0, 0);
assert.deepEqual(entered.homeworlds!.custody, custody);
assert.equal(after.reserves, before.reserves); assert.equal(after.elites?.reserves, before.elites?.reserves);
assert.equal(after.spice, before.spice); assert.equal(after.shipped, before.shipped); assert.equal(after.moved, before.moved);
assert.ok(gameTerritories(entered).every(territory => !territory.id.startsWith('homeworld:')));
assert.ok(entered.discoveries!.tokens.every(token => !token.territory?.startsWith('homeworld:')));
homeworldClassicDiscoveryInventory(entered);
}
});
void test('normal revealed nested shipment uses the original tariff and explicit Emperor Kaitain/Salusa custody; concealed sites and invalid source allocations fail', () => {
for (const advanced of [false, true]) {
const fixture = createHomeworldClassicDiscoveryFixture({ advanced, prepareImperialSplit: true });
const concealed = homeworldClassicDiscoveryShipmentWindow(fixture.game, fixture.collector);
const hiddenShip: Action = { type: 'ship', territory: fixture.face, sector: 0, amount: 2, elite: 0,
allyPayment: 0, homeworldSources: nativeShipmentSources(viewGame(concealed, fixture.collector), 2, 0) };
assert.equal(validGameLocation(concealed, fixture.face, 0), false);
reject(concealed, fixture.collector, hiddenShip);
let game = homeworldClassicDiscoveryShipmentWindow(enterHomeworldClassicDiscovery(fixture), fixture.collector);
const view = viewGame(game, fixture.collector), before = structuredClone(homeworldClassicDiscoveryPlayer(game, fixture.collector));
const homes = structuredClone(view.homeworlds!.worlds!), salusa = game.homeworlds!.custody!.salusa && { ...game.homeworlds!.custody!.salusa };
const sources = advanced ? { 'homeworld:emperor': { normal: 1, elite: 0 }, 'homeworld:emperor:salusa': { normal: 1, elite: 0 } }
: { 'homeworld:emperor': { normal: 2, elite: 0 } };
const action: Action = { type: 'ship', territory: fixture.face, sector: 0, amount: 2, elite: 0, allyPayment: 0, homeworldSources: sources };
legalPolicies(game, fixture.collector);
reject(game, fixture.collector, { ...action, territory: 'shrine' });
reject(game, fixture.collector, { ...action, sector: 1 });
reject(game, fixture.collector, { ...action, homeworldSources: { 'homeworld:fremen': { normal: 2, elite: 0 } } });
reject(game, fixture.collector, { ...action, homeworldSources: { 'homeworld:emperor': { normal: 1, elite: 0 } } });
reject(game, fixture.collector, { ...action, homeworldSources: { [`${fixture.face}:0`]: { normal: 2, elite: 0 } } });
game = settleHomeworldClassicDiscoveryArrival(applyAction(game, fixture.collector, action));
const after = homeworldClassicDiscoveryPlayer(game, fixture.collector);
assert.equal(after.spice, before.spice - 2, 'Printed nested tariff is one spice per shipped force.');
assert.equal(after.reserves, before.reserves - 2); assert.equal(after.elites?.reserves, before.elites?.reserves);
assert.equal(after.forces[`${fixture.face}:0`], before.forces[`${fixture.face}:0`] + 2);
assert.equal(after.elites?.forces[`${fixture.face}:0`], before.elites?.forces[`${fixture.face}:0`]);
assert.equal(after.shipped, true); assert.equal(after.moved, before.moved);
const nextHomes = viewGame(game, fixture.collector).homeworlds!.worlds!;
for (const home of homes.filter(home => home.native === fixture.collector)) {
const next = nextHomes.find(candidate => candidate.id === home.id)!;
assert.equal(next.forces[fixture.collector].normal, home.forces[fixture.collector].normal - (sources[home.id as keyof typeof sources]?.normal ?? 0));
assert.equal(next.forces[fixture.collector].elite, home.forces[fixture.collector].elite);
}
if (advanced) assert.equal(game.homeworlds!.custody!.salusa!.normal, salusa!.normal - 1);
homeworldClassicDiscoveryInventory(game);
}
});
void test('original native-home invasion remains a separate normal battle with native strength, native-only traitor calls and no Jacurutu reward', () => {
for (const advanced of [false, true]) {
const fixture = createHomeworldClassicDiscoveryFixture({ advanced, face: 'jacurutu-sietch' });
let game = homeworldClassicDiscoveryShipmentWindow(enterHomeworldClassicDiscovery(fixture), fixture.collector);
const home = viewGame(game, fixture.collector).homeworlds!.worlds!.find(world => world.native === fixture.fremen)!;
const choice = homeworldShipmentChoice(viewGame(game, fixture.collector), home.id,
{ 'homeworld:emperor': { normal: 1, elite: 0 } });
assert.ok(choice.action); assert.equal(choice.cost, 1);
game = applyAction(game, fixture.collector, choice.action);
game = advanceHomeworldClassicDiscovery(game, state => state.phase === 6 && !!state.active && homeworldClassicDiscoveryClean(state));
const actor = game.active!; assert.ok(actor === fixture.collector || actor === fixture.fremen);
game = applyAction(game, actor, { type: 'chooseBattle', territory: home.id,
target: actor === fixture.collector ? fixture.fremen : fixture.collector });
game = advanceHomeworldClassicDiscovery(game, state => !!state.battle && homeworldClassicDiscoveryClean(state) && !state.battle.preparation);
const view = viewGame(game, fixture.collector), nativeBefore = structuredClone(homeworldClassicDiscoveryPlayer(game, fixture.fremen));
assert.equal(view.battle!.native, fixture.fremen); assert.equal(view.battle!.nativeBattleStrength, home.nativeBattleStrength);
assert.deepEqual(view.battle!.traitorVoters, [fixture.fremen]);
const discoveryBefore = structuredClone(game.discoveries), boardBefore = game.players.map(player => ({ id: player.id, forces: { ...player.forces } }));
const collection = quoteSpiceCollection(game).receipts.find(receipt => receipt.player === fixture.fremen)!.balance - nativeBefore.spice;
const invader = homeworldClassicDiscoveryPlayer(game, fixture.collector), native = homeworldClassicDiscoveryPlayer(game, fixture.fremen);
const weak = [...invader.leaders].filter(leader => !leader.dead).sort((a, b) => a.strength - b.strength)[0];
const strong = [...native.leaders].filter(leader => !leader.dead).sort((a, b) => b.strength - a.strength)[0];
legalPolicies(game, fixture.collector);
game = applyAction(game, fixture.collector, { type: 'battlePlan', dial: 0, support: 0, leader: weak.id });
legalPolicies(game, fixture.fremen);
game = applyAction(game, fixture.fremen, { type: 'battlePlan', dial: 0, support: 0, leader: strong.id });
reject(game, fixture.collector, { type: 'traitorCall', call: true });
game = applyAction(game, fixture.fremen, { type: 'traitorCall', call: false });
game = advanceHomeworldClassicDiscovery(game, state => !state.battle && !state.homeworldBattleLoss && homeworldClassicDiscoveryClean(state));
assert.equal(game.lastBattleContext!.territory, home.id); assert.equal(game.lastBattleContext!.winner, fixture.fremen);
assert.equal(homeworldClassicDiscoveryPlayer(game, fixture.collector).tanks, invader.tanks + 1);
const nativeAfter = homeworldClassicDiscoveryPlayer(game, fixture.fremen);
assert.equal(nativeAfter.reserves, nativeBefore.reserves); assert.equal(nativeAfter.tanks, nativeBefore.tanks);
assert.equal(nativeAfter.spice, nativeBefore.spice + (game.phase === 7 ? collection : 0), 'One undialed invading loss earns no Jacurutu reward on a Homeworld.');
assert.deepEqual(game.discoveries, discoveryBefore);
assert.deepEqual(game.players.map(player => ({ id: player.id, forces: player.forces })), boardBefore);
homeworldClassicDiscoveryInventory(game);
}
});
@@ -0,0 +1,206 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, type Action, type Game } from '../game/engine';
import { DIFFICULTIES } from '../game/bot-profiles';
import type { FactionId } from '../game/catalog';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { homeworldContext } from '../game/homeworld-game';
import { homeworldForceGroups } from '../game/homeworld-custody';
import type { HomeworldCombatLossContext } from '../game/homeworld-combat-loss';
import { quoteSukPhysicalRescue, type SukReserveDestinations } from '../game/suk-graduate';
import {
advanceClassicHomeworldSkills as advance, assertClassicHomeworldSkillsCustody as custody,
classicHomeworldSkillsBotOffers as offers, classicHomeworldSkillsPlayer as player,
createClassicHomeworldSkillsFixture as fixture, createClassicHomeworldSkillsSetup as setup,
quoteClassicHomeworldSkillsBattle as quoteBattle, revealClassicHomeworldSkillsBattle as reveal,
settleClassicHomeworldSkillsBattle as settle,
} from './fixture-homeworld-classic-skills';
function legalOffers(game: Game, actor: string): void {
for (const level of DIFFICULTIES) {
const actions = offers(game, actor, level); assert.ok(actions.length, `${level} has a real supported offer`);
for (const action of actions) custody(applyAction(game, actor, action));
}
}
function context(game: Game): HomeworldCombatLossContext {
return { advanced: game.advanced, players: game.players.map(p => {
const total = Object.values(p.forces).reduce((a, b) => a + b, 0);
const elite = Object.values(p.elites?.forces ?? {}).reduce((a, b) => a + b, 0);
return { id: p.id, faction: p.faction, reserves: p.reserves, eliteReserves: p.elites?.reserves ?? 0,
tanks: p.tanks, eliteTanks: p.elites?.tanks ?? 0, battleLosses: p.battleLosses,
boardForces: { normal: total - elite, elite } };
}) };
}
function pool(game: Game, actor: string, location: string) {
return homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!)
.find(home => home.id === location)!.forces[actor] ?? { normal: 0, elite: 0 };
}
function reject(game: Game, actor: string, action: Action): void {
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
function rescueChoice(game: Game, elite: boolean): number {
const decision = game.decision; assert.equal(decision?.kind, 'sukRescue');
if (decision?.kind !== 'sukRescue') throw Error('A real rescue choice is required');
const maximum = Math.max(...decision.options.map(option => option.normal + option.elite));
const choice = decision.options.findIndex(option => option.normal + option.elite === maximum &&
(elite ? option.elite > 0 && option.kept?.kind === 'elite' : option.kept?.kind === 'normal'));
assert.ok(choice >= 0); return choice;
}
function splitReturn(game: Game, choice: number): SukReserveDestinations | undefined {
const decision = game.decision; assert.ok(decision?.kind === 'sukRescue');
if (!decision.reserveHomes) return undefined;
const option = decision.options[choice];
const normal = option.normal - (option.kept?.kind === 'normal' ? 1 : 0);
const elite = option.elite - (option.kept?.kind === 'elite' ? 1 : 0);
if (!normal && !elite) return undefined;
// Deliberately NOT the revival mapping: any returned ordinary pieces go to
// Salusa and any returned Sardaukar go to Kaitain. This application placement
// choice exercises exact two-home conservation, not a publisher ruling.
return Object.fromEntries(decision.reserveHomes.map(home => [home.id,
home.secondary ? { normal, elite: 0 } : { normal: 0, elite }]));
}
void test('original Basic/Advanced two-through-six classic Homeworld setups retain starting hands and all fourteen physical skills', () => {
const roster: FactionId[] = ['emperor', 'guild', 'atreides', 'harkonnen', 'fremen', 'beneGesserit'];
for (const advanced of [false, true]) for (let seats = 2; seats <= 6; seats++) {
const f = setup({ advanced, roster: roster.slice(0, seats) });
const physical = f.offered.leaderSkills!;
assert.deepEqual([...physical.deck, ...Object.values(physical.offers).flatMap(o => o.cards), ...physical.assignments.map(a => a.skill)].sort(), LEADER_SKILL_CARDS.map(c => c.id).sort());
for (const p of f.afterSetup.players) {
assert.equal(p.hand.length, p.faction === 'harkonnen' ? 2 : 1);
}
for (const p of f.offered.players) legalOffers(f.offered, p.id);
custody(f.offered); custody(f.afterSetup);
}
});
void test('every physical skill remains selectable through actual Emperor offers', () => {
for (const advanced of [false, true]) for (const card of LEADER_SKILL_CARDS) {
const f = setup({ advanced, roster: ['emperor', 'guild'], skill: card.id });
assert.equal(f.afterSetup.leaderSkills!.assignments.find(a => a.owner === 'emperor')!.skill, card.id);
legalOffers(f.offered, 'emperor');
custody(f.afterSetup);
}
});
for (const advanced of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} trained native and invader add the actual Mentat bonus without turning native strength into counters`, () => {
for (const kind of ['native-bonus', 'visitor-bonus'] as const) {
const f = fixture({ kind, advanced });
for (const actor of [f.owner, f.opponent]) legalOffers(f.game, actor);
const revealed = reveal(f), quote = quoteBattle(revealed);
const own = revealed.battle!.attacker === f.owner ? 'attacker' : 'defender';
const leader = player(revealed, f.owner).leaders.find(l => l.id === f.trainer)!;
const alternative = player(f.game, f.owner).leaders.find(disc => disc.id !== f.trainer && !disc.dead)!;
const untrained = reveal({ ...f, plans: f.plans.map(step => step.actor === f.owner
? { ...step, action: { ...step.action, leader: alternative.id } } : step) });
const untrainedQuote = quoteBattle(untrained);
assert.equal(quote.scores![own] - untrainedQuote.scores![own], leader.strength - alternative.strength + 2);
const beforePool = pool(revealed, f.owner, f.territory);
const completed = settle(revealed);
assert.equal(completed.lastBattleContext!.winner, f.owner);
const losses = player(completed, f.owner).tanks - player(revealed, f.owner).tanks;
const afterPool = pool(completed, f.owner, f.territory);
assert.equal(beforePool.normal + beforePool.elite - afterPool.normal - afterPool.elite, losses);
assert.equal(player(completed, f.owner).leaders.find(l => l.id === f.trainer)!.dead, false);
custody(completed);
}
});
void test(`${advanced ? 'Advanced' : 'Basic'} actual lethal native and invader plans kill the trained disc and return its one physical skill exactly once`, () => {
for (const kind of ['native-death', 'visitor-death'] as const) {
const f = fixture({ kind, advanced }), revealed = reveal(f);
assert.equal(quoteBattle(revealed).leaderDeaths[revealed.battle!.attacker === f.owner ? 'attacker' : 'defender'], true);
const done = settle(revealed);
assert.equal(player(done, f.owner).leaders.find(l => l.id === f.trainer)!.dead, true);
assert.ok(!done.leaderSkills!.assignments.some(a => a.leader === f.trainer));
assert.equal(done.leaderSkills!.deck.filter(id => id === 'mentat').length, 1);
const ended = advance(done, game => game.turn > done.turn);
assert.equal(ended.leaderSkills!.deck.filter(id => id === 'mentat').length, 1);
custody(done); custody(ended);
}
});
void test(`${advanced ? 'Advanced' : 'Basic'} native Homeworld trained Suk saves actual counters in their same reserve pool`, () => {
const f = fixture({ kind: 'native-suk', advanced }), revealed = reveal(f), pending = settle(revealed, 'suk');
assert.equal(pending.pendingSukRescue!.territory, f.territory);
assert.equal(pending.pendingSukRescue!.skill.mode, 'skilled');
assert.equal(pending.pendingSukRescue!.pool.length, 1);
const choice = rescueChoice(pending, advanced);
assert.ok(pending.decision?.kind === 'sukRescue');
assert.equal(pending.decision.reserveHomes, undefined, 'Native rescue cannot offer a free Emperor internal move');
legalOffers(pending, f.owner);
const before = pool(pending, f.owner, f.territory), option = pending.decision.options[choice];
reject(pending, f.owner, { type: 'decision', event: pending.decision.event, choice,
destinations: { 'homeworld:emperor': { normal: advanced ? 0 : 1, elite: advanced ? 1 : 0 } } });
const done = applyAction(JSON.parse(JSON.stringify(pending)), f.owner, { type: 'decision', event: pending.decision.event, choice });
const completed = settle(done), after = pool(completed, f.owner, f.territory);
assert.equal(before.normal - after.normal, pending.pendingSukRescue!.losses!.normal - option.normal);
assert.equal(before.elite - after.elite, pending.pendingSukRescue!.losses!.elite - option.elite);
if (advanced) assert.deepEqual(completed.homeworlds!.custody!.salusa, pending.homeworlds!.custody!.salusa,
'Three dialed native Sardaukar saved at Salusa never leave Salusa or duplicate their reserve totals');
custody(completed);
});
void test(`${advanced ? 'Advanced' : 'Basic'} visitor Homeworld and Arrakis trained Suk conserve typed casualties, board/visitor pools and native return allocations`, () => {
for (const kind of ['visitor-suk', 'arrakis-suk'] as const) {
const f = fixture({ kind, advanced }), revealed = reveal(f);
let pending = settle(revealed, 'suk');
assert.ok(pending.decision?.kind === 'sukRescue');
const choice = rescueChoice(pending, false), option = pending.decision.options[choice];
const destinations = splitReturn(pending, choice), original = pending.pendingSukRescue!;
assert.equal(original.skill.mode, 'skilled');
if (advanced) assert.equal(pending.decision.reserveHomes?.length, 2);
if (advanced) {
assert.equal(original.losses!.normal, 2);
assert.equal(original.losses!.elite, 1);
}
legalOffers(pending, f.owner);
const physical = quoteSukPhysicalRescue(context(pending), pending.homeworlds!.custody!, {
player: f.owner, territory: f.territory, skill: original.skill, pool: original.pool,
losses: original.losses!, option, destinations,
});
assert.equal(physical.receipt.source, kind === 'visitor-suk' ? 'visitor-homeworld' : 'arrakis');
if (advanced) {
const incomplete = { 'homeworld:emperor': { normal: 0, elite: 0 } };
reject(pending, f.owner, { type: 'decision', event: pending.decision.event, choice, destinations: incomplete });
reject(pending, f.owner, { type: 'decision', event: pending.decision.event, choice, destinations: { 'homeworld:guild': { normal: 2, elite: 0 } } });
reject(pending, f.owner, { type: 'decision', event: pending.decision.event, choice });
}
const before = player(pending, f.owner);
pending = applyAction(JSON.parse(JSON.stringify(pending)), f.owner, { type: 'decision', event: pending.decision.event, choice, ...(destinations ? { destinations } : {}) });
const done = settle(pending), after = player(done, f.owner);
assert.equal(after.tanks - before.tanks, original.losses!.normal + original.losses!.elite - option.normal - option.elite);
assert.deepEqual(done.homeworlds!.custody, physical.custody);
assert.equal(after.reserves, physical.players.find(p => p.id === f.owner)!.reserves);
assert.equal(after.elites?.reserves, physical.players.find(p => p.id === f.owner)!.eliteReserves);
assert.equal(done.pendingSukRescue ?? null, null);
custody(done);
}
});
void test(`${advanced ? 'Advanced' : 'Basic'} real empty-territory Smuggler shipment withdraws six physical counters priced as five and presents the actual Bureaucrat payer invoice`, () => {
const f = fixture({ kind: 'smuggler-payment', advanced });
const before = f.beforeShipment!, after = f.afterShipment!;
assert.equal(player(after, f.owner).reserves, player(before, f.owner).reserves - 6);
assert.equal(player(after, f.owner).spice, player(before, f.owner).spice - 5);
assert.equal(Object.values(player(after, f.owner).forces).reduce((a, b) => a + b, 0), 6);
if (advanced) assert.equal(player(after, f.owner).elites!.reserves, player(before, f.owner).elites!.reserves - 2);
legalOffers(before, f.owner);
const invoice = advance(after, game => game.decision?.kind === 'bureaucratPayment');
const payment = invoice.bureaucratPayments!.pending!.source;
assert.equal(payment.kind, 'shipment'); assert.equal(payment.payer, f.owner);
assert.equal(payment.payee, f.opponent); assert.equal(payment.amount, 5);
const bureaucrat = invoice.decision!.player;
assert.notEqual(bureaucrat, payment.payer); assert.notEqual(bureaucrat, payment.payee);
legalOffers(invoice, bureaucrat);
const paid = applyAction(JSON.parse(JSON.stringify(invoice)), bureaucrat, { type: 'decision', event: invoice.decision!.kind === 'bureaucratPayment' ? invoice.decision!.event : '', redirect: true });
const done = advance(paid, game => !game.response && !game.decision && !game.phaseOpening);
assert.equal(player(done, f.opponent).spice, player(before, f.opponent).spice + 3);
assert.equal(player(done, f.owner).spice, player(before, f.owner).spice - 5);
assert.equal(done.bureaucratPayments!.used.length, 1);
assert.deepEqual(player(done, f.owner).forces, player(after, f.owner).forces, 'Invoice settlement cannot replay physical arrival');
custody(done);
});
}