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

236 lines
16 KiB
TypeScript

import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
import type { FactionId } from '../game/catalog';
import type { LeaderSkillId } from '../game/leader-skill-cards';
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
import { validateDiscoveryState } from '../game/discoveries';
import { homeworldContext } from '../game/homeworld-game';
import { homeworldForceGroups } from '../game/homeworld-custody';
import {
assertClassicHomeworldSkillsCustody, classicHomeworldSkillsPlayer,
createClassicHomeworldSkillsSetup, nextClassicHomeworldSkillsStep,
type ClassicHomeworldSkillsSetup, type ClassicHomeworldSkillsStep,
} from './fixture-homeworld-classic-skills';
import { nextHomeworldSkillsDiscoveryStep } from './fixture-homeworld-skills-discovery';
export type ClassicHomeworldNexusSkillsStep = ClassicHomeworldSkillsStep;
export type ClassicHomeworldNexusSkillsOptions = {
/** Original fresh lobby only. Never redeal an existing offer, hand or save. */
initial?: Game;
advanced?: boolean;
discovery?: boolean;
tech?: boolean;
strongholds?: boolean;
roster?: readonly FactionId[];
skill?: LeaderSkillId;
};
export type ClassicHomeworldNexusSkillsFixture = ClassicHomeworldSkillsSetup & {
kind: 'native-suk' | 'visitor-suk' | 'visitor-death';
beforeClosingDraw: Game;
closingDraw: ClassicHomeworldNexusSkillsStep;
afterClosingDraw: Game;
beforeShipment: Game;
shipment: ClassicHomeworldNexusSkillsStep;
afterShipment: Game;
beforeShippingEnd: Game;
shippingEnd: ClassicHomeworldNexusSkillsStep;
afterShippingEnd: Game;
game: Game;
owner: string;
opponent: string;
trainer: string;
territory: string;
plans: ClassicHomeworldNexusSkillsStep[];
};
export const classicHomeworldNexusSkillsPlayer = classicHomeworldSkillsPlayer;
export const classicHomeworldNexusSkillsClean = (game: Game) =>
!game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
/** Original private Nexus opt-in, not a fabricated player action. The returned
* lobby is also usable by the integration owner's original private QA route. */
export function createClassicHomeworldNexusSkillsLobby(options: ClassicHomeworldNexusSkillsOptions = {}): Game {
const roster: readonly FactionId[] = options.roster ?? ['emperor', 'guild', 'atreides', 'harkonnen'];
let game = options.initial ? structuredClone(options.initial)
: createGame('CLASSICHWNEXUSSKILLS', 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.equal(game.status, 'lobby', 'Accept only the original fresh lobby, never an existing saved setup.');
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
assert.ok(game.players.every(p => ['emperor', 'guild', 'atreides', 'harkonnen', 'fremen', 'beneGesserit'].includes(p.faction)));
assert.equal(new Set(game.players.map(p => p.faction)).size, game.players.length);
if (!game.homeworlds) game = applyAction(game, game.host, { type: 'homeworlds', enabled: true });
if (options.tech && !game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
if (options.strongholds && !game.strongholdCards) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true });
if (options.discovery) game.discoveryEnabled = true;
if (!game.nexusCards) game.nexusCards = { cards: null, phase: null };
assert.equal(game.nexusCards.cards, null); assert.equal(game.nexusCards.phase, null);
return game;
}
export function createClassicHomeworldNexusSkillsSetup(options: ClassicHomeworldNexusSkillsOptions = {}): ClassicHomeworldSkillsSetup {
const initial = createClassicHomeworldNexusSkillsLobby(options), readyActions: ClassicHomeworldNexusSkillsStep[] = [];
let game = structuredClone(initial);
for (const player of game.players) if (!player.ready) {
const step: ClassicHomeworldNexusSkillsStep = { actor: player.id, action: { type: 'ready' } };
readyActions.push(step); game = applyAction(game, step.actor, step.action);
}
const setup = createClassicHomeworldSkillsSetup({ initial: game, skill: options.skill ?? 'suk-graduate' });
return { ...setup, initial, actions: [...readyActions, ...setup.actions] };
}
export function assertClassicHomeworldNexusSkillsInventory(game: Game): void {
assertClassicHomeworldSkillsCustody(game);
assert.ok(game.nexusCards?.cards);
validateNexusCards(game.nexusCards.cards, game.players);
const cards = game.nexusCards.cards;
assert.deepEqual([...cards.deck, ...cards.discard, ...Object.values(cards.hands).filter(c => c !== null)].sort(), [...NEXUS_FACTIONS].sort());
if (game.discoveryEnabled) {
assert.ok(game.discoveries); validateDiscoveryState(game.discoveries);
const spice = [...game.spiceDeck, ...game.spiceDiscard.flat(), ...(game.spiceResolution?.skipped ?? []), ...(game.spiceSequence?.skipped ?? [])];
assert.equal(spice.filter(c => 'territory' in c && !!c.discovery).length, 6);
assert.equal(spice.filter(c => 'worm' in c && c.greatMaker).length, 1);
assert.equal(game.discoveries.tokens.length, 8);
}
}
export function nextClassicHomeworldNexusSkillsStep(game: Game): ClassicHomeworldNexusSkillsStep | null {
if (!game.phaseOpening && !game.response) {
const decision = game.decision;
if (decision?.kind === 'guildShipment') return { actor: decision.player, action: { type: 'decision', allow: true } };
if (decision?.kind === 'homeworldShipmentGuild') return { actor: decision.player, action: { type: 'decision', event: decision.event, allow: true } };
if (decision?.kind === 'techToken') return null; // Mandatory human winner choice, after original cleanup.
if (game.nexusCards?.phase?.stage === 'drawing' && !decision) {
const actor = game.nexusCards.phase.eligible.find(id => !game.nexusCards!.phase!.done.includes(id));
if (actor) return { actor, action: { type: 'nexusCardChoice', turn: game.turn,
card: game.nexusCards.cards!.hands[actor], choice: 'keep', ownRedraws: 0 } };
}
}
return game.discoveryEnabled ? nextHomeworldSkillsDiscoveryStep(game)
: nextClassicHomeworldSkillsStep(game, game.players.find(p => p.faction === 'emperor')?.id);
}
export function advanceClassicHomeworldNexusSkills(state: Game, until: (game: Game) => boolean,
actions?: ClassicHomeworldNexusSkillsStep[]): Game {
let game = state;
for (let n = 0; n < 1800; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const next = nextClassicHomeworldNexusSkillsStep(game);
assert.ok(next, 'Original continuation stops at human sealed plans, Suk rescue or mandatory winner Tech.');
actions?.push(structuredClone(next)); game = applyAction(game, next.actor, next.action);
}
throw Error('Original Homeworld/Nexus/Skills programme did not reach its owned boundary.');
}
function act(game: Game, step: ClassicHomeworldNexusSkillsStep, actions: ClassicHomeworldNexusSkillsStep[]): Game {
actions.push(structuredClone(step)); return applyAction(game, step.actor, step.action);
}
function orderSpice(game: Game, kinds: ('land' | 'worm')[], staging: string[]): void {
for (const [position, kind] of kinds.entries()) {
const index = game.spiceDeck.findIndex((card, i) => i >= position && (kind === 'worm'
? 'worm' in card && !card.greatMaker : 'territory' in card && !card.discovery));
assert.ok(index >= position, 'Select only a conserved original unplayed Spice Card.');
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
staging.push(`Selected only original unplayed Spice order: ${kinds.join(', ')}; no board, wallet, hand, casualty or clock staging.`);
}
export function classicHomeworldNexusSkillsPool(game: Game, actor: string, home: string) {
return homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!)
.find(world => world.id === home)!.forces[actor] ?? { normal: 0, elite: 0 };
}
/** Full first Mentat -> second-turn natural worm -> reciprocal alliance ->
* complete Spice closure -> original private draw -> paid physical invasion.
* Every army and casualty is created by original actions. DS7/8 remain original
* components; ordinary Spice is deliberately selected so this branch has no
* incidental nested entry or Great Maker army. */
export function createClassicHomeworldNexusSkillsFixture(options: ClassicHomeworldNexusSkillsOptions & {
kind?: ClassicHomeworldNexusSkillsFixture['kind'];
} = {}): ClassicHomeworldNexusSkillsFixture {
const kind = options.kind ?? 'visitor-suk';
const setup = createClassicHomeworldNexusSkillsSetup({ ...options, skill: kind === 'visitor-death' ? 'mentat' : 'suk-graduate' });
let game = structuredClone(setup.afterSetup);
const owner = game.players.find(p => p.faction === 'emperor')!.id;
const opponent = game.players.find(p => p.faction === 'guild')!.id;
assert.equal(game.players[0].id, owner, 'This original lottery trains the actual first Emperor seat.');
assert.ok(!game.players.some(p => p.faction === 'fremen'), 'Free-three programme needs genuinely absent Fremen.');
assert.ok(game.players.length >= 4, 'Two noncombatants form a real alliance, leaving both combatants unallied.');
const trainer = game.leaderSkills!.assignments.find(a => a.owner === owner)!.leader;
orderSpice(game, game.advanced ? ['land', 'land'] : ['land'], setup.staging);
game = advanceClassicHomeworldNexusSkills(game, g => g.turn === 2 && g.phase === 1 && classicHomeworldNexusSkillsClean(g), setup.actions);
orderSpice(game, game.advanced ? ['worm', 'land', 'land'] : ['worm', 'land'], setup.staging);
let allied = false;
for (let n = 0; game.nexusCards?.phase?.stage !== 'drawing' && n < 240; n++) {
if (game.nexus && !game.spiceWindow && !game.spiceResolution && classicHomeworldNexusSkillsClean(game) && !allied) {
const others = game.players.filter(p => p.id !== owner && p.id !== opponent);
for (const [from, to] of [[others[0].id, others[1].id], [others[1].id, others[0].id]])
game = act(game, { actor: from, action: { type: 'alliance', target: to } }, setup.actions);
allied = true;
}
const next = nextClassicHomeworldNexusSkillsStep(game); assert.ok(next);
game = act(game, next, setup.actions);
}
assert.ok(allied); assert.equal(game.nexusCards?.phase?.stage, 'drawing');
assert.ok(game.nexusCards!.phase!.eligible.includes(owner));
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf('fremen'); assert.ok(index >= 0);
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
setup.staging.push('Selected the original undealt Fremen Nexus singleton before the qualifying real closing draw; never wrote a held Nexus hand.');
const beforeClosingDraw = structuredClone(game), closingDraw: ClassicHomeworldNexusSkillsStep = {
actor: owner, action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands[owner], choice: 'draw', ownRedraws: 0 },
};
game = act(game, closingDraw, setup.actions); const afterClosingDraw = structuredClone(game);
assert.equal(game.nexusCards!.cards!.hands[owner], 'fremen');
game = advanceClassicHomeworldNexusSkills(game, g => g.phase === 5 && g.active === (kind === 'native-suk' ? opponent : owner) && classicHomeworldNexusSkillsClean(g), setup.actions);
const territory = kind === 'native-suk' ? game.advanced ? 'homeworld:emperor:salusa' : 'homeworld:emperor' : 'homeworld:guild';
const shipper = kind === 'native-suk' ? opponent : owner;
const sources = shipper === owner && game.advanced ? {
'homeworld:emperor': { normal: 3, elite: 0 }, 'homeworld:emperor:salusa': { normal: 0, elite: 1 },
} : { [`homeworld:${classicHomeworldSkillsPlayer(game, shipper).faction}`]: { normal: kind === 'native-suk' ? 3 : 4, elite: 0 } };
const beforeShipment = structuredClone(game), shipment: ClassicHomeworldNexusSkillsStep = { actor: shipper,
action: { type: 'homeworldShip', event: viewGame(game, shipper).homeworldShipment!.event, destination: territory, sources } };
game = act(game, shipment, setup.actions);
game = advanceClassicHomeworldNexusSkills(game, g => !g.pendingHomeworldShipment && classicHomeworldNexusSkillsClean(g), setup.actions);
const afterShipment = structuredClone(game);
// Preserve the exact original phase-end action, rather than infer industry
// payment from a later wallet containing unrelated Collection/battle income.
let beforeShippingEnd: Game | undefined, shippingEnd: ClassicHomeworldNexusSkillsStep | undefined;
for (let n = 0; game.phase === 5 && n < 240; n++) {
const before = structuredClone(game), next = nextClassicHomeworldNexusSkillsStep(game); assert.ok(next);
game = act(game, next, setup.actions);
if (game.phase !== 5) { beforeShippingEnd = before; shippingEnd = next; }
}
assert.ok(beforeShippingEnd && shippingEnd);
const afterShippingEnd = structuredClone(game);
game = advanceClassicHomeworldNexusSkills(game, g => g.phase === 6 && classicHomeworldNexusSkillsClean(g), setup.actions);
const actor = game.active!; assert.ok(actor === owner || actor === opponent);
game = act(game, { actor, action: { type: 'chooseBattle', territory, target: actor === owner ? opponent : owner } }, setup.actions);
game = advanceClassicHomeworldNexusSkills(game, g => !!g.battle && classicHomeworldNexusSkillsClean(g) && !g.battle.preparation && g.battle.preLeader?.closed !== false, setup.actions);
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
const enemy = classicHomeworldSkillsPlayer(game, opponent);
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 === 'visitor-death' ? enemy.hand.find(c => c.kind === 'projectile')?.id : undefined;
if (kind === 'visitor-death') assert.ok(weapon, 'Use only the projectile genuinely dealt to the Guild at original setup.');
const dial = kind === 'visitor-death' ? 0 : kind === 'native-suk' && game.advanced ? 6 : 4;
const plans: ClassicHomeworldNexusSkillsStep[] = [
{ actor: owner, action: { type: 'battlePlan', leader: trainer, dial, support: game.advanced && dial && kind !== 'native-suk' ? 2 : 0 } },
{ actor: opponent, action: { type: 'battlePlan', leader: leader.id, dial: 0, weapon } },
];
assertClassicHomeworldNexusSkillsInventory(game);
return { ...setup, kind, beforeClosingDraw, closingDraw, afterClosingDraw, beforeShipment, shipment, afterShipment,
beforeShippingEnd, shippingEnd, afterShippingEnd, game, owner, opponent, trainer, territory, plans };
}
export function revealClassicHomeworldNexusSkillsBattle(fixture: ClassicHomeworldNexusSkillsFixture): Game {
let game = structuredClone(fixture.game);
for (const step of fixture.plans) {
game = applyAction(game, step.actor, step.action);
if (!game.battle?.revealed) game = advanceClassicHomeworldNexusSkills(game,
g => classicHomeworldNexusSkillsClean(g) && !!g.battle && !g.battle.preparation);
}
assert.ok(game.battle?.revealed); return game;
}
export function settleClassicHomeworldNexusSkillsBattle(state: Game, stop: 'suk' | 'cleanup' = 'cleanup'): Game {
return advanceClassicHomeworldNexusSkills(state, game => stop === 'suk' && game.decision?.kind === 'sukRescue' ||
game.decision?.kind === 'techToken' || !game.battle && classicHomeworldNexusSkillsClean(game));
}
export function classicHomeworldNexusSkillsRevivalWindow(state: Game, actions?: ClassicHomeworldNexusSkillsStep[], staging: string[] = []): Game {
const turn = state.turn;
let game = advanceClassicHomeworldNexusSkills(state, g => g.turn === turn + 1 && g.phase === 1 && classicHomeworldNexusSkillsClean(g), actions);
orderSpice(game, game.advanced ? ['land', 'land'] : ['land'], staging);
game = advanceClassicHomeworldNexusSkills(game, g => g.turn === turn + 1 && g.phase === 4 && classicHomeworldNexusSkillsClean(g), actions);
return game;
}