Compose classic Homeworld Nexus optional modules

This commit is contained in:
Quintine committed 2026-10-07 07:14:52 +11:00
1 parent b5f5de5c83
commit c7073dc6b6
21 files changed
+1376 -71

No files matched your search

+3 -10
View File
@@ -263,17 +263,10 @@ for (const configuration of [configurations[0], configurations[1], configuration
});
}
void test('authenticated native Nexus/Discovery setup preserves original seat IDs/dealt custody and excludes unrelated Emperor module profiles', () => {
const initial = initializeDiscoveryClassicNexusEffectsSetup({ advanced: true, tech: true, strongholds: true,
seatIds: ['authenticated-owner', 'authenticated-opponent', 'authenticated-third'] });
const saved = structuredClone(initial);
const accepted = initializeDiscoveryClassicNexusEffectsSetup({ initial });
assert.deepEqual(accepted, saved);
void test('original Nexus/Discovery Emperor purchase rejects unrelated profiles and preserves completed return integrity', () => {
const initial = initializeDiscoveryClassicNexusEffectsSetup({ advanced: true, tech: true, strongholds: true });
const fixture = createDiscoveryClassicNexusEffectsFixture({ initial });
assert.equal(fixture.actor, 'authenticated-owner'); assert.deepEqual(fixture.initial, saved);
assert.deepEqual(fixture.game.players.map(player => player.id), initial.players.map(player => player.id));
assert.deepEqual(initial, saved);
assert.equal(emperorNexusModeSupported(fixture.game), true);
discoveryClassicNexusEffectsInventory(fixture.game);
// Explicit adversarial compositions, not histories claimed to be natural play.
for (const change of [
(game: Game) => { game.expansions = ['ix']; },
@@ -0,0 +1,343 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer, viewGame,
type Action, type Game } from '../game/engine';
import type { FactionId } from '../game/catalog';
import { location, territory } from '../game/board';
import { SPICE_CARDS } from '../game/cards';
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_SPICE_CARDS } from '../game/discoveries';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import type { NativeReserveSelections } from '../game/homeworld-native-reserves';
import { classicNexusModulesProfile } from '../game/nexus-module-profile';
import { reserveShipmentCost } from '../game/shipment-price';
import { nexusInventory } from './fixture-nexus-cards';
import { classicDiscoveryNexusClean, classicDiscoveryNexusPlayer, nextClassicDiscoveryNexusStep,
withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken,
type ClassicDiscoveryNexusStep } from './fixture-discovery-classic-nexus';
export type HomeworldClassicDiscoveryNexusStep = ClassicDiscoveryNexusStep;
export type HomeworldClassicDiscoveryNexusOptions = {
/** Original fresh lobby or its undealt native setup; never a played save. */
initial?: Game;
gameId?: string;
seatIds?: string[];
seats?: 2 | 3 | 4 | 5 | 6;
advanced?: boolean;
/** Original Discovery Spice Cards/tokens/Great Maker. Off keeps the plain Nexus deck. */
discovery?: boolean;
tech?: boolean;
strongholds?: boolean;
/** An actual original Shai-Hulud immediately before the Great Maker, not a Nexus flag write. */
ordinaryWormFirst?: boolean;
};
export type HomeworldClassicDiscoveryNexusShipment = {
actor: string; amount: number; elite: number; cost: number;
territory: string; sector: number; homeworldSources: NativeReserveSelections;
};
export type HomeworldClassicDiscoveryNexusMentat = {
before: Game; step: HomeworldClassicDiscoveryNexusStep; after: Game;
};
export type HomeworldClassicDiscoveryNexusFixture = {
initial: Game; setup: Game; afterSetup: Game; firstStorm: Game; afterBlow: Game;
beforeShipment: Game; afterShipments: Game; collection: Game;
firstMentat: HomeworldClassicDiscoveryNexusMentat; game: Game;
collector: string; victim: string; fremen: string | null; token: string | null;
parent: string; parentSector: number; blowKey: string; blowAmount: number;
wormTerritory: string; wormSource: string; amount: number; elite: number;
shipments: HomeworldClassicDiscoveryNexusShipment[];
actions: HomeworldClassicDiscoveryNexusStep[]; staging: string[];
};
export const homeworldClassicDiscoveryNexusClean = classicDiscoveryNexusClean;
export const homeworldClassicDiscoveryNexusPlayer = classicDiscoveryNexusPlayer;
/** Preserve the army under both original Guild interception producers; the
* original Discovery/Nexus native owner policy keeps every other decision. */
export function nextHomeworldClassicDiscoveryNexusStep(game: Game): HomeworldClassicDiscoveryNexusStep | null {
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard &&
(game.decision?.kind === 'guildShipment' || game.decision?.kind === 'homeworldShipmentGuild'))
return { actor: game.decision.player, action: { type: 'decision', allow: true,
...(game.decision.kind === 'homeworldShipmentGuild' ? { event: game.decision.event } : {}) } };
return nextClassicDiscoveryNexusStep(game);
}
export function applyHomeworldClassicDiscoveryNexusStep(game: Game, step: HomeworldClassicDiscoveryNexusStep,
actions?: HomeworldClassicDiscoveryNexusStep[]): Game {
actions?.push(structuredClone(step));
return withClassicDiscoveryNexusLottery(() => applyAction(game, step.actor, step.action));
}
export function advanceHomeworldClassicDiscoveryNexus(game: Game, until: (game: Game) => boolean,
actions?: HomeworldClassicDiscoveryNexusStep[]): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original lifecycle ended before its owned boundary.');
const step = nextHomeworldClassicDiscoveryNexusStep(game);
assert.ok(step, 'The original continuation requires a human sealed Battle Plan.');
game = applyHomeworldClassicDiscoveryNexusStep(game, step, actions);
}
throw new Error('The original Homeworld/Nexus lifecycle did not reach its owned boundary.');
}
/** Physical conservation of cards, ordinary counters, encountered frames and spice. */
export function homeworldClassicDiscoveryNexusInventory(game: Game): void {
homeworldGameIntegrity(game);
nexusInventory(game);
const spice = [...game.spiceDeck, ...game.spiceDiscard.flat(),
...(game.spiceResolution?.skipped ?? []), ...(game.spiceSequence?.skipped ?? [])];
if (game.discoveries) {
assert.equal(spice.length, 28);
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);
} else {
assert.equal(spice.length, 21);
assert.ok(spice.every(card => !('territory' in card) || !card.discovery));
}
assert.ok(!(game.discoveryEntry && game.nexusCards!.phase?.stage === 'drawing'));
assert.ok(!(game.greatMaker?.stage === 'vote' && game.nexusCards!.phase?.stage === 'drawing'));
}
type SpicePlan = {
blow: { territory: string; sector: number; amount: number; discovery: string | null };
parent: { territory: string; sector: number };
spare: readonly { territory: string; sector: number }[];
};
/** First three conserved original spare land blows, clear of the storm and of
* the printed blow/parent territories, so no spare doubles a harvest site. */
function originalSpareBlows(blow: string, parent: string, storm: number): { territory: string; sector: number }[] {
return SPICE_CARDS
.filter(([id, , sector]) => id !== blow && id !== parent && sector !== storm)
.slice(0, 3)
.map(([id, , sector]) => ({ territory: id, sector }));
}
/** Conserve the original physical blow/spare positions while keeping both
* printed sectors clear of the actual storm, so the real blow and its harvest
* are observable rather than storm-lost. */
function planOriginalSpice(game: Game, discovery: boolean): SpicePlan {
const storm = game.storm;
if (discovery) {
const card = DISCOVERY_SPICE_CARDS.find(candidate =>
DISCOVERY_CARD_PLACEMENTS[candidate.discovery].sector !== storm && candidate.sector !== storm);
assert.ok(card, 'An original undealt Discovery blow must keep its printed sector out of the storm.');
const placement = DISCOVERY_CARD_PLACEMENTS[card.discovery];
return { blow: { territory: card.territory, sector: card.sector, amount: card.amount,
discovery: card.discovery },
parent: { territory: placement.territory, sector: placement.sector },
spare: originalSpareBlows(card.territory, placement.territory, storm) };
}
const blow = SPICE_CARDS.find(([, , sector]) => sector !== storm);
assert.ok(blow, 'An original undealt plain blow must keep its printed sector out of the storm.');
const parent = SPICE_CARDS.find(([id, , sector]) => id !== blow[0] && sector !== storm);
assert.ok(parent);
return { blow: { territory: blow[0], sector: blow[2], amount: blow[1], discovery: null },
parent: { territory: parent[0], sector: parent[2] },
spare: originalSpareBlows(blow[0], parent[0], storm) };
}
function orderOriginalSpice(game: Game, plan: SpicePlan, options: HomeworldClassicDiscoveryNexusOptions): void {
const discovery = !!game.discoveryEnabled;
const take = (choose: (card: Game['spiceDeck'][number]) => boolean) => {
const index = game.spiceDeck.findIndex(choose);
assert.ok(index >= 0, 'Each selected physical Spice Card must remain undealt.');
return game.spiceDeck.splice(index, 1)[0];
};
const plain = (id: string) => (card: Game['spiceDeck'][number]) =>
'territory' in card && !card.discovery && card.territory === id;
// Advanced consumes one spare as the turn-one second pile; Basic consumes none.
const front = [take(card => 'territory' in card && (discovery
? card.discovery === plan.blow.discovery : !card.discovery && card.territory === plan.blow.territory))];
if (game.advanced) front.push(take(plain(plan.spare[0].territory)));
if (!discovery || options.ordinaryWormFirst) front.push(take(card => 'worm' in card && !card.greatMaker));
if (discovery) front.push(take(card => 'worm' in card && !!card.greatMaker));
front.push(take(plain(plan.spare[1].territory)), take(plain(plan.spare[2].territory)));
game.spiceDeck.unshift(...front);
}
export function finishHomeworldClassicDiscoveryNexusMentat(game: Game,
actions?: HomeworldClassicDiscoveryNexusStep[]): HomeworldClassicDiscoveryNexusMentat {
const turn = game.turn;
game = advanceHomeworldClassicDiscoveryNexus(game,
state => state.phase === 8 && homeworldClassicDiscoveryNexusClean(state), actions);
for (let count = 0; count < 100; count++) {
const before = structuredClone(game), step = nextHomeworldClassicDiscoveryNexusStep(game);
assert.ok(step);
game = applyHomeworldClassicDiscoveryNexusStep(game, step, actions);
if (game.turn !== turn || game.status === 'finished') return { before, step, after: game };
}
throw new Error('The original Mentat controls failed to close the owned turn.');
}
/** Original next-turn parent-board free entry; the reveal control decides it. */
export function enterHomeworldClassicDiscoveryNexus(game: Game, actor: string,
actions?: HomeworldClassicDiscoveryNexusStep[]): Game {
assert.equal(game.decision?.kind, 'discoveryEntry');
assert.equal(game.decision!.player, actor);
const view = viewGame(game, actor), action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources);
assert.ok(action, 'The revealed parent-board army must be a legal original free entry.');
return applyHomeworldClassicDiscoveryNexusStep(game, { actor, action }, actions);
}
/** Original create/join/ready/audit initializer, real source-qualified native
* shipments, the printed blow and the Collection reveal. No hand, custody,
* clock, alliance, casualty, wallet or outcome field is written by staging. */
export function createHomeworldClassicDiscoveryNexusFixture(
options: HomeworldClassicDiscoveryNexusOptions = {},
): HomeworldClassicDiscoveryNexusFixture {
let game: Game;
if (options.initial) game = structuredClone(options.initial);
else {
const factions: FactionId[] = ['emperor', 'atreides', 'fremen', 'harkonnen', 'beneGesserit', 'guild'];
const seats = options.seats ?? options.seatIds?.length ?? 3;
assert.ok(seats >= 2 && seats <= 6);
if (options.seatIds) assert.equal(options.seatIds.length, seats);
game = createGame(options.gameId ?? 'HWCLASSICDISCOVERYNEXUS',
newPlayer(options.seatIds?.[0] ?? 'emperor', 'Emperor', 'emperor'),
options.advanced ?? !!options.strongholds);
for (let seat = 1; seat < seats; seat++)
joinGame(game, newPlayer(options.seatIds?.[seat] ?? factions[seat], factions[seat], factions[seat]));
}
assert.deepEqual(game.expansions, []);
assert.ok(!game.leaderSkills && !game.ecazTreachery && !game.spiceBankerIncomePreview);
assert.ok(game.status === 'lobby' || (game.status === 'setup' && game.turn === 1 && game.phase === 0 &&
['prediction', 'traitors'].includes(game.setupStage!) &&
game.players.every(player => !player.hand.length && !player.traitors.length)));
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
const collector = game.players.find(player => player.faction === 'emperor')?.id;
const victim = game.players.find(player => player.faction === 'atreides')?.id;
assert.ok(collector && victim, 'This original programme needs its Emperor split source and an Atreides army.');
const actions: HomeworldClassicDiscoveryNexusStep[] = [];
const fresh = !options.initial;
const wantDiscovery = options.discovery ?? (fresh || !!game.discoveryEnabled);
const wantTech = options.tech ?? (fresh || !!game.techTokens);
const wantStrongholds = options.strongholds ?? (fresh ? game.advanced : !!game.strongholdCards);
if (game.status === 'lobby') {
if (!game.homeworlds) game = applyHomeworldClassicDiscoveryNexusStep(game,
{ actor: game.host, action: { type: 'homeworlds', enabled: true } }, actions);
if (wantTech && !game.techTokens) game = applyHomeworldClassicDiscoveryNexusStep(game,
{ actor: game.host, action: { type: 'techTokens', enabled: true } }, actions);
if (wantStrongholds && !game.strongholdCards) game = applyHomeworldClassicDiscoveryNexusStep(game,
{ actor: game.host, action: { type: 'strongholdCards', enabled: true } }, actions);
game.discoveryEnabled = wantDiscovery;
game.nexusCards ??= { cards: null, phase: null };
for (const player of game.players) if (!player.ready)
game = applyHomeworldClassicDiscoveryNexusStep(game, { actor: player.id, action: { type: 'ready' } }, actions);
}
assert.ok(game.homeworlds && game.nexusCards, 'This programme needs the original Homeworld and Nexus modules.');
assert.equal(!!game.discoveryEnabled, wantDiscovery);
assert.equal(!!game.techTokens, wantTech);
assert.equal(!!game.strongholdCards, wantStrongholds);
assert.ok(classicNexusModulesProfile(game));
const initial = structuredClone(game);
if (game.status === 'lobby') game = withClassicDiscoveryNexusLottery(() => initializeNexusGameForAudit(game));
const setup = structuredClone(game);
game = advanceHomeworldClassicDiscoveryNexus(game, state => state.status === 'playing', actions);
const afterSetup = structuredClone(game);
homeworldClassicDiscoveryNexusInventory(afterSetup);
game = advanceHomeworldClassicDiscoveryNexus(game,
state => state.phase === 1 && homeworldClassicDiscoveryNexusClean(state), actions);
const firstStorm = structuredClone(game);
const plan = planOriginalSpice(game, wantDiscovery);
orderOriginalSpice(game, plan, options);
// The physical token lottery must surround the real Spice Blow action.
if (wantDiscovery) for (let count = 0;
!game.discoveries!.tokens.some(token => token.face === 'cistern' && token.status === 'placed') && count < 100;
count++) {
const step = nextHomeworldClassicDiscoveryNexusStep(game); assert.ok(step);
actions.push(structuredClone(step));
game = withClassicDiscoveryNexusToken(game, 'cistern', () => applyAction(game, step.actor, step.action));
}
const afterBlow = structuredClone(game), blowKey = location(plan.blow.territory, plan.blow.sector);
const amount = game.advanced ? 3 : 2, elite = game.advanced ? 1 : 0;
game = advanceHomeworldClassicDiscoveryNexus(game,
state => state.phase === 5 && homeworldClassicDiscoveryNexusClean(state), actions);
const beforeShipment = structuredClone(game), shipments: HomeworldClassicDiscoveryNexusShipment[] = [];
let afterShipments: Game | null = null;
for (let count = 0; game.phase === 5 && count < 240; count++) {
const actor = game.active;
if (homeworldClassicDiscoveryNexusClean(game) && actor && !shipments.some(row => row.actor === actor) &&
(actor === collector || actor === victim)) {
const total = actor === collector ? amount : 2, starred = actor === collector ? elite : 0;
const sources = nativeShipmentSources(viewGame(game, actor), total, starred);
assert.ok(sources, `Original ${actor} native reserve sources must cover ${total} physical counters.`);
const target = actor === collector ? plan.parent : plan.blow;
const shipment: HomeworldClassicDiscoveryNexusShipment = { actor, amount: total, elite: starred,
territory: target.territory, sector: target.sector, homeworldSources: sources,
cost: reserveShipmentCost({ faction: homeworldClassicDiscoveryNexusPlayer(game, actor).faction, halfRate: false },
territory(target.territory).type, total) };
shipments.push(shipment);
game = applyHomeworldClassicDiscoveryNexusStep(game, { actor, action: { type: 'ship',
territory: target.territory, sector: target.sector, amount: total, elite: starred,
allyPayment: 0, homeworldSources: sources } }, actions);
if (shipments.length === 2) afterShipments = structuredClone(game);
} else {
const step = nextHomeworldClassicDiscoveryNexusStep(game); assert.ok(step);
game = applyHomeworldClassicDiscoveryNexusStep(game, step, actions);
}
}
assert.equal(shipments.length, 2);
assert.ok(afterShipments, 'Both original native shipments must be declared.');
game = advanceHomeworldClassicDiscoveryNexus(game,
state => state.phase === 7 && homeworldClassicDiscoveryNexusClean(state), actions);
const collection = structuredClone(game);
const token = game.discoveries?.tokens.find(candidate => candidate.face === 'cistern')?.id ?? null;
if (token) {
if (viewGame(game, collector).discoveries!.canInspect.includes(token))
game = applyHomeworldClassicDiscoveryNexusStep(game,
{ actor: collector, action: { type: 'discovery', token, reveal: false } }, actions);
assert.ok(viewGame(game, collector).discoveries!.canReveal.includes(token),
'The finder holding the parent-board army may reveal the placed original token.');
game = applyHomeworldClassicDiscoveryNexusStep(game,
{ actor: collector, action: { type: 'discovery', token, reveal: true } }, actions);
}
const firstMentat = finishHomeworldClassicDiscoveryNexusMentat(game, actions);
game = firstMentat.after;
assert.equal(game.turn, 2);
homeworldClassicDiscoveryNexusInventory(game);
return { initial, setup, afterSetup, firstStorm, afterBlow, beforeShipment, afterShipments,
collection, firstMentat, game, collector, victim,
fremen: game.players.find(player => player.faction === 'fremen')?.id ?? null, token,
parent: plan.parent.territory, parentSector: plan.parent.sector, blowKey, blowAmount: plan.blow.amount,
wormTerritory: plan.blow.territory, wormSource: blowKey, amount, elite, shipments, actions,
staging: [
'Original Homeworld + Nexus opt-in with no Skills; original native setup retained.',
wantDiscovery
? 'Original seven Spice Cards/eight Discovery tokens; the Cistern supply lottery is selected around the real blow.'
: 'Original plain Spice deck; this composition has no Great Maker card at all.',
'Conserved original unplayed Spice order only: the printed blow, the Advanced second pile, and the turn-two worm/Great Maker, each kept out of the actual storm.',
'Only real source-qualified native reserve shipments place the two board armies; no wallet, clock, casualty or outcome is assigned.'] };
}
/** Enter or decline the real encounter without silently voting, riding or paying. */
export function openHomeworldClassicDiscoveryNexusEncounter(fixture: HomeworldClassicDiscoveryNexusFixture,
enter = true): Game {
let game = fixture.game;
if (game.decision?.kind === 'discoveryEntry') game = enter
? enterHomeworldClassicDiscoveryNexus(game, fixture.collector, fixture.actions)
: applyHomeworldClassicDiscoveryNexusStep(game, { actor: fixture.collector,
action: { type: 'decision', event: game.decision.event, accept: false } }, fixture.actions);
return advanceHomeworldClassicDiscoveryNexus(game,
state => fixture.token ? state.greatMaker?.stage === 'vote' : !!state.nexus, fixture.actions);
}
export function voteHomeworldClassicDiscoveryNexus(game: Game, votes: readonly boolean[],
actions?: HomeworldClassicDiscoveryNexusStep[]): Game {
for (const yes of votes) {
assert.equal(game.decision?.kind, 'greatMakerVote');
game = applyHomeworldClassicDiscoveryNexusStep(game, { actor: game.decision!.player,
action: { type: 'decision', event: game.decision!.event, yes } }, actions);
}
return game;
}
/** Real settled alliance, then the single original closing Nexus draw. */
export function closeHomeworldClassicDiscoveryNexusAlliance(fixture: HomeworldClassicDiscoveryNexusFixture,
game: Game, card: 'guild' | 'ecaz' = 'ecaz'): { alliance: Game; drawing: Game; after: Game; draw: Action } {
game = advanceHomeworldClassicDiscoveryNexus(game, state => state.nexus && !state.spiceWindow &&
!state.spiceResolution && homeworldClassicDiscoveryNexusClean(state), fixture.actions);
assert.ok(fixture.fremen, 'A settled alliance needs two original seated owners.');
for (const [actor, target] of [[fixture.victim, fixture.fremen], [fixture.fremen, fixture.victim]])
game = applyHomeworldClassicDiscoveryNexusStep(game,
{ actor, action: { type: 'alliance', target } }, fixture.actions);
const alliance = structuredClone(game);
game = advanceHomeworldClassicDiscoveryNexus(game,
state => state.nexusCards!.phase?.stage === 'drawing', fixture.actions);
const deck = game.nexusCards!.cards!.deck, index = deck.indexOf(card); assert.ok(index >= 0);
deck.unshift(deck.splice(index, 1)[0]);
fixture.staging.push(`Ordered the conserved original undealt ${card} Nexus singleton before its real closing draw; no hand assigned.`);
const drawing = structuredClone(game);
const draw: Action = { type: 'nexusCardChoice', turn: game.turn,
card: game.nexusCards!.cards!.hands[fixture.collector], choice: 'draw', ownRedraws: 0 };
game = applyHomeworldClassicDiscoveryNexusStep(game, { actor: fixture.collector, action: draw }, fixture.actions);
return { alliance, drawing, after: game, draw };
}
@@ -0,0 +1,235 @@
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;
}
@@ -0,0 +1,294 @@
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 { discoveryEntryDeclineAction, discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { greatMakerRideAction } from '../game/great-maker-options';
import { nativeShipmentSources } from '../game/homeworld-options';
import { ownedTech } from '../game/tech-tokens';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import {
advanceHomeworldClassicDiscoveryNexus, closeHomeworldClassicDiscoveryNexusAlliance,
createHomeworldClassicDiscoveryNexusFixture, enterHomeworldClassicDiscoveryNexus,
homeworldClassicDiscoveryNexusClean, homeworldClassicDiscoveryNexusInventory,
homeworldClassicDiscoveryNexusPlayer, openHomeworldClassicDiscoveryNexusEncounter,
voteHomeworldClassicDiscoveryNexus,
} from './fixture-homeworld-classic-discovery-nexus';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
function rejected(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
/** Every original difficulty must expose one owned legal continuation and keep
* the physical counters, cards and native custody conserved. */
function legalPolicies(game: Game, owner: string): void {
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, owner);
view.players.find(player => player.id === owner)!.bot = difficulty;
const actions = botActions(view);
assert.ok(actions.length, `${difficulty} needs an owned minimal legal continuation.`);
const after = applyAction(structuredClone(game), owner, actions[0]);
homeworldClassicDiscoveryNexusInventory(after);
}
}
/** Root rulebook physical pp.22–23 and the printed Nexus/Discovery components.
* Only original create/join/ready/initializer/actions run; the tests select
* conserved unplayed Spice positions and supply lotteries, never hands,
* custody, clocks, alliances, casualty frames or outcomes. */
void test('original no-Skills Homeworld/Nexus admits Basic and Advanced classic rosters with undealt native components', () => {
const compositions = [
{ advanced: false, discovery: false, tech: true, strongholds: false },
{ advanced: false, discovery: true, tech: false, strongholds: false },
{ advanced: true, discovery: true, tech: true, strongholds: true },
{ advanced: true, discovery: false, tech: true, strongholds: true },
] as const;
for (const composition of compositions) for (const seats of [3, 6] as const) {
const fixture = createHomeworldClassicDiscoveryNexusFixture({ ...composition, seats });
assert.equal(fixture.game.turn, 2);
homeworldClassicDiscoveryNexusInventory(fixture.game);
const worlds = viewGame(fixture.game, fixture.collector).homeworlds!.worlds!;
for (const player of fixture.game.players)
assert.ok(worlds.some(world => world.native === player.id),
`Seat ${player.id} must keep its original native Homeworld.`);
assert.equal(fixture.afterSetup.status, 'playing');
if (composition.discovery) {
assert.equal(fixture.blowAmount, 6);
assert.equal(fixture.game.decision?.kind, 'discoveryEntry');
assert.equal(fixture.game.decision!.player, fixture.collector);
assert.equal(fixture.game.phase, 0);
} else {
assert.equal(fixture.game.nexus, false);
}
}
});
void test('real source-qualified native shipments fund the blow army and the Collection payment exactly once', () => {
for (const advanced of [false, true]) {
const fixture = createHomeworldClassicDiscoveryNexusFixture({ advanced, discovery: true, tech: false });
assert.equal(fixture.firstStorm.phase, 1);
assert.equal(fixture.firstStorm.techTokens ?? null, null);
assert.equal(fixture.afterBlow.spice[fixture.blowKey], fixture.blowAmount,
'The printed blow must be placed, not storm-lost.');
assert.equal(fixture.blowAmount, 6);
for (const shipment of fixture.shipments) {
const owner = homeworldClassicDiscoveryNexusPlayer(fixture.collection, shipment.actor);
const key = `${shipment.territory}:${shipment.sector}`;
assert.equal(owner.forces[key], shipment.amount);
assert.equal(owner.elites?.forces[key] ?? 0, shipment.elite);
const home = homeworldClassicDiscoveryNexusPlayer(fixture.afterSetup, shipment.actor);
assert.equal(owner.reserves, home.reserves - shipment.amount, 'Only real native counters left the reserve.');
assert.equal(owner.elites?.reserves ?? 0, (home.elites?.reserves ?? 0) - shipment.elite);
for (const source of Object.keys(shipment.homeworldSources))
assert.ok(source === `homeworld:${home.faction}` || source === 'homeworld:emperor:salusa',
'A native shipment may only spend the shipper’s own original Homeworlds.');
}
const baseline = Object.fromEntries(fixture.beforeShipment.players.map(player => [player.id, player.spice]));
const collected = Object.fromEntries(fixture.collection.players.map(player => [player.id, player.spice]));
const ordinaryIncome = quoteSpiceCollection(fixture.afterSetup).receipts;
for (const player of fixture.collection.players) {
const paid = fixture.shipments.filter(row => row.actor === player.id)
.reduce((sum, row) => sum + row.cost, 0);
const harvested: number = player.id === fixture.victim ? fixture.blowAmount : 0;
const income = ordinaryIncome.find(receipt => receipt.player === player.id)!.balance -
homeworldClassicDiscoveryNexusPlayer(fixture.afterSetup, player.id).spice;
assert.equal(collected[player.id], baseline[player.id] - paid + harvested + income,
'Shipment price and printed Collection must settle exactly once.');
}
const idle = homeworldClassicDiscoveryNexusPlayer(fixture.collection, fixture.fremen!);
assert.equal(idle.reserves, homeworldClassicDiscoveryNexusPlayer(fixture.afterSetup, fixture.fremen!).reserves);
homeworldClassicDiscoveryNexusInventory(fixture.collection);
}
});
void test('original worm losses resolve before Great Maker votes and a typed Fremen ride without repeating casualties', () => {
for (const advanced of [false, true]) {
const fixture = createHomeworldClassicDiscoveryNexusFixture({ advanced, discovery: true,
tech: advanced, ordinaryWormFirst: advanced });
const before = homeworldClassicDiscoveryNexusPlayer(fixture.game, fixture.victim);
assert.equal(before.forces[fixture.wormSource], 2);
let game = openHomeworldClassicDiscoveryNexusEncounter(fixture, false);
assert.equal(game.turn, 2);
assert.equal(game.greatMaker!.stage, 'vote');
const devoured = homeworldClassicDiscoveryNexusPlayer(game, fixture.victim);
assert.equal(devoured.forces[fixture.wormSource] ?? 0, 0);
assert.equal(devoured.tanks, before.tanks + 2);
assert.equal(devoured.reserves, before.reserves);
assert.equal(homeworldClassicDiscoveryNexusPlayer(game, fixture.collector)
.forces[`${fixture.parent}:${fixture.parentSector}`], fixture.amount,
'The army shipped to the separate parent site must survive the worm.');
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]);
// Advanced reaches a real majority; Basic stays a minority, so only the
// preceding original Shai-Hulud can add the Nexus there.
const vote: Action = { type: 'decision', event: game.decision!.event,
yes: advanced ? index < order.length - 1 : index === order.length - 1 };
rejected(game, order[(index + 1) % order.length], vote);
game = applyAction(game, order[index], vote);
}
assert.equal(game.nexus, advanced, 'A strict majority adds the Nexus; a tie with no preceding worm adds none.');
assert.equal(game.decision?.kind, 'greatMakerRide');
assert.equal(game.decision!.player, fixture.fremen);
const fremen = homeworldClassicDiscoveryNexusPlayer(game, fixture.fremen!);
const elite = advanced ? 1 : 0;
assert.ok(fremen.reserves >= 2 && (fremen.elites?.reserves ?? 0) >= elite);
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 === 'cistern');
assert.ok(target, 'The revealed nested Cistern is a real Great Maker reserve destination.');
const ride = greatMakerRideAction(view, target.territory, target.sector, 2, elite);
assert.ok(ride);
assert.equal(greatMakerRideAction(view, 'homeworld:fremen', 0, 2, elite), null);
rejected(game, fixture.fremen!, { ...ride, territory: 'homeworld:fremen', sector: 0 });
rejected(game, fixture.fremen!, { ...ride, elite: view.greatMaker!.ride!.eliteMax + 1 });
legalPolicies(game, fixture.fremen!);
game = applyAction(game, fixture.fremen!, ride);
const after = homeworldClassicDiscoveryNexusPlayer(game, fixture.fremen!);
assert.equal(after.forces['cistern:0'], 2);
assert.equal(after.elites?.forces['cistern:0'] ?? 0, elite);
assert.equal(after.reserves, fremen.reserves - 2);
assert.equal(after.elites?.reserves ?? 0, (fremen.elites?.reserves ?? 0) - elite);
assert.equal(after.spice, fremen.spice);
assert.equal(after.shipped, fremen.shipped);
assert.equal(after.moved, fremen.moved);
assert.equal(game.greatMaker?.stage, 'complete');
assert.deepEqual(game.wormRides, []);
homeworldClassicDiscoveryNexusInventory(game);
}
});
void test('the next-turn parent-board free entry reaches the revealed nested site without shipment, movement or spice', () => {
for (const advanced of [false, true]) {
const fixture = createHomeworldClassicDiscoveryNexusFixture({ advanced, discovery: true, tech: false });
const game = fixture.game, view = viewGame(game, fixture.collector);
assert.equal(game.turn, 2);
assert.equal(game.phase, 0);
assert.equal(game.decision?.kind, 'discoveryEntry');
assert.equal(game.decision!.player, fixture.collector);
const sources = view.discoveryEntry!.sources;
assert.deepEqual(sources, [{ source: `${fixture.parent}:${fixture.parentSector}`,
normal: fixture.amount - fixture.elite, elite: fixture.elite }]);
const accept = discoveryEntryMoveAction(view, sources), decline = discoveryEntryDeclineAction(view);
assert.ok(accept && decline);
legalPolicies(game, fixture.collector);
rejected(game, fixture.fremen!, accept);
rejected(game, fixture.collector, { ...accept, groups: [{ source: 'homeworld:emperor', normal: 1, elite: 0 }] });
rejected(game, fixture.collector, { ...accept, groups: [{ ...sources[0], normal: 21 }] });
assert.equal(discoveryEntryMoveAction(view, [{ source: 'homeworld:emperor', normal: 1, elite: 0 }]), null);
assert.equal(discoveryEntryMoveAction(view, [{ ...sources[0], normal: 21 }]), null);
assert.equal(nativeShipmentSources(view, 21, 0), null);
const before = structuredClone(homeworldClassicDiscoveryNexusPlayer(game, fixture.collector));
const declined = applyAction(reload(game), fixture.collector, decline);
assert.deepEqual(homeworldClassicDiscoveryNexusPlayer(declined, fixture.collector).forces, before.forces);
const entered = enterHomeworldClassicDiscoveryNexus(reload(game), fixture.collector);
const after = homeworldClassicDiscoveryNexusPlayer(entered, fixture.collector);
assert.equal(after.forces['cistern:0'], fixture.amount);
assert.equal(after.elites?.forces['cistern:0'] ?? 0, fixture.elite);
assert.equal(after.forces[`${fixture.parent}:${fixture.parentSector}`] ?? 0, 0);
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);
rejected(entered, fixture.collector, accept);
homeworldClassicDiscoveryNexusInventory(entered);
}
});
void test('without Discovery the original worm alone creates the Nexus, and the settled alliance closes one deal', () => {
for (const advanced of [false, true]) {
const fixture = createHomeworldClassicDiscoveryNexusFixture({ advanced, discovery: false,
tech: true, strongholds: advanced });
assert.equal(fixture.token, null);
assert.equal(fixture.game.turn, 2);
assert.equal(fixture.game.nexus, false, 'No Nexus exists before the turn-two worm.');
const production = fixture.firstStorm.techTokens!.production;
assert.equal(production.owner, fixture.fremen, 'The first Storm assigns the Fremen’s original Technology.');
const heighliner = fixture.afterShipments!.techTokens!.heighliners;
if (heighliner.owner) {
assert.equal(heighliner.triggeredTurn, 1, 'A real native shipment triggers Heighliners once per phase.');
assert.equal(heighliner.spice, ownedTech(fixture.afterShipments!.techTokens, heighliner.owner).length);
}
const before = homeworldClassicDiscoveryNexusPlayer(fixture.game, fixture.victim);
assert.equal(before.forces[fixture.wormSource], 2);
const window = advanceHomeworldClassicDiscoveryNexus(fixture.game,
state => state.nexus && !state.spiceWindow && !state.spiceResolution &&
homeworldClassicDiscoveryNexusClean(state), fixture.actions);
assert.equal(window.nexus, true, 'The original Shai-Hulud creates the Nexus by itself.');
const devoured = homeworldClassicDiscoveryNexusPlayer(window, fixture.victim);
assert.equal(devoured.forces[fixture.wormSource] ?? 0, 0);
assert.equal(devoured.tanks, before.tanks + 2);
assert.equal(devoured.reserves, before.reserves);
assert.equal(homeworldClassicDiscoveryNexusPlayer(window, fixture.collector)
.forces[`${fixture.parent}:${fixture.parentSector}`], fixture.amount);
const closed = closeHomeworldClassicDiscoveryNexusAlliance(fixture, window);
assert.equal(closed.drawing.nexusCards!.phase!.stage, 'drawing');
assert.equal(closed.after.nexusCards!.cards!.hands[fixture.collector], 'ecaz');
assert.equal(closed.after.nexusCards!.phase!.stage, 'complete');
assert.equal(closed.after.nexusCards!.cards!.deck.includes('ecaz'), false);
assert.equal(homeworldClassicDiscoveryNexusPlayer(closed.alliance, fixture.victim).ally, fixture.fremen);
assert.equal(homeworldClassicDiscoveryNexusPlayer(closed.alliance, fixture.fremen!).ally, fixture.victim);
rejected(closed.after, fixture.collector, closed.draw);
homeworldClassicDiscoveryNexusInventory(closed.after);
}
});
void test('the Advanced second pile and the settled alliance close exactly one original Nexus deal', () => {
const fixture = createHomeworldClassicDiscoveryNexusFixture({ advanced: true, discovery: true,
tech: true, strongholds: true, ordinaryWormFirst: true });
let game = enterHomeworldClassicDiscoveryNexus(fixture.game, fixture.collector);
game = advanceHomeworldClassicDiscoveryNexus(game, state => state.greatMaker?.stage === 'vote', fixture.actions);
const order = [...game.order];
game = voteHomeworldClassicDiscoveryNexus(game, order.map((_, index) => index < 2));
assert.equal(game.nexus, true);
game = advanceHomeworldClassicDiscoveryNexus(game, state => state.nexus && !state.spiceWindow &&
!state.spiceResolution && homeworldClassicDiscoveryNexusClean(state), fixture.actions);
const closed = closeHomeworldClassicDiscoveryNexusAlliance(fixture, game);
assert.equal(closed.drawing.turn, 2);
assert.equal(closed.drawing.nexusCards!.phase!.stage, 'drawing');
assert.deepEqual(closed.drawing.nexusCards!.phase!.eligible, [fixture.collector]);
assert.equal(closed.after.nexusCards!.cards!.hands[fixture.collector], 'ecaz');
assert.equal(closed.after.nexusCards!.cards!.discard.includes('ecaz'), false);
assert.equal(homeworldClassicDiscoveryNexusPlayer(closed.after, fixture.collector).spice,
homeworldClassicDiscoveryNexusPlayer(closed.drawing, fixture.collector).spice);
assert.equal(closed.after.nexusCards!.phase!.stage, 'complete');
assert.equal(closed.after.strongholdCards!.claimedTurn, 1);
assert.deepEqual(closed.after.wormRides, []);
rejected(closed.after, fixture.collector, closed.draw);
homeworldClassicDiscoveryNexusInventory(closed.after);
});
void test('all four minimal legal policies answer the real free entry, vote, ride and closing draw', () => {
const fixture = createHomeworldClassicDiscoveryNexusFixture({ advanced: true, discovery: true,
tech: true, strongholds: true });
const vote = openHomeworldClassicDiscoveryNexusEncounter(fixture, false);
const ride = voteHomeworldClassicDiscoveryNexus(reload(vote), [...vote.order].map(() => true));
const closed = closeHomeworldClassicDiscoveryNexusAlliance(fixture, ride);
const cases = [
{ game: fixture.game, owner: fixture.collector, type: 'decision' },
{ game: vote, owner: vote.decision!.player, type: 'decision' },
{ game: ride, owner: ride.decision!.player, type: 'decision' },
{ game: closed.drawing, owner: fixture.collector, type: 'nexusCardChoice' },
];
for (const difficulty of DIFFICULTIES) for (const current of cases) {
const view = viewGame(current.game, current.owner);
view.players.find(player => player.id === current.owner)!.bot = difficulty;
const action = botActions(view).find(candidate => candidate.type === current.type);
assert.ok(action, `${difficulty} must answer the owned ${current.game.decision?.kind ?? 'closing Nexus draw'}.`);
const after = applyAction(reload(current.game), current.owner, action);
if (current.game.decision?.kind === 'greatMakerVote')
assert.equal(after.greatMaker!.votes.length, current.game.greatMaker!.votes.length + 1);
else if (current.game.decision?.kind === 'greatMakerRide')
assert.equal(after.greatMaker!.stage, 'complete');
else if (current.game.decision?.kind === 'discoveryEntry')
rejected(after, current.owner, action);
else assert.equal(after.nexusCards!.phase!.stage, 'complete');
homeworldClassicDiscoveryNexusInventory(after);
}
});
@@ -0,0 +1,228 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { DIFFICULTIES } from '../game/bot-profiles';
import type { HomeworldCombatLossContext } from '../game/homeworld-combat-loss';
import { quoteSukPhysicalRescue } from '../game/suk-graduate';
import { strongholdBenefit } from '../game/stronghold-cards';
import { ownedTech } from '../game/tech-tokens';
import { classicHomeworldSkillsBotOffers } from './fixture-homeworld-classic-skills';
import { homeworldSkillsDiscoveryRescueChoice, homeworldSkillsDiscoverySplitReturn } from './fixture-homeworld-skills-discovery';
import {
advanceClassicHomeworldNexusSkills as advance,
assertClassicHomeworldNexusSkillsInventory as inventory,
classicHomeworldNexusSkillsClean as clean,
classicHomeworldNexusSkillsPlayer as player,
classicHomeworldNexusSkillsPool as pool,
classicHomeworldNexusSkillsRevivalWindow as revivalWindow,
createClassicHomeworldNexusSkillsFixture as fixture,
createClassicHomeworldNexusSkillsLobby as lobby,
revealClassicHomeworldNexusSkillsBattle as reveal,
settleClassicHomeworldNexusSkillsBattle as settle,
} from './fixture-homeworld-classic-nexus-skills';
/** Root physical PDF21–23 authorizes original variant composition. These are
* actual original phase programmes, not a configuration matrix. External Suk
* allocations use the existing placement interpretation; printed normal revival
* is separately asserted to return Sardaukar to Salusa, never that Suk split. */
function legalPolicies(game: Game, actor: string): void {
for (const difficulty of DIFFICULTIES) {
const actions = classicHomeworldSkillsBotOffers(game, actor, difficulty);
assert.ok(actions.length, `${difficulty} needs a real owned continuation.`);
inventory(applyAction(structuredClone(game), actor, actions[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 physicalContext(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 } };
}) };
}
/** Original loser played-card cleanup must already be committed before the
* winner is asked to choose Tech. No synthetic winner receipt is introduced. */
function finishWinnerTech(state: Game): Game {
if (state.decision?.kind !== 'techToken') { inventory(state); return state; }
const decision = state.decision, before = structuredClone(state.techTokens);
legalPolicies(state, decision.player);
assert.equal(state.battle, null, 'Original battle disposal precedes the reward choice.');
assert.equal(state.pendingSukRescue ?? null, null);
assert.equal(state.pendingWinnerDiscards ?? null, null);
for (const entry of state.lastBattleContext!.mandatoryDiscard?.entries ?? [])
assert.ok(state.discard.some(card => card.id === entry.card), 'Original played-card cleanup precedes mandatory winner Tech.');
const token = decision.choices[0], action: Action = { type: 'decision', token };
reject(state, decision.loser, action);
reject(state, decision.player, { type: 'decision', decline: true });
const done = settle(applyAction(JSON.parse(JSON.stringify(state)), decision.player, action));
assert.equal(done.techTokens![token].owner, decision.player);
for (const [id, old] of Object.entries(before!)) if (id !== token)
assert.deepEqual(done.techTokens![id as keyof NonNullable<typeof before>], old);
inventory(done); return done;
}
void test('genuine worm/alliance closing draw precedes a paid native withdrawal, local support and one original Shipping-end industry payment', () => {
const original = lobby({ advanced: true, discovery: true, tech: true, strongholds: true });
const f = fixture({ initial: original, kind: 'visitor-suk' });
assert.equal(player(f.beforeClosingDraw, f.owner).ally, null);
legalPolicies(f.beforeClosingDraw, f.owner);
reject(f.beforeClosingDraw, f.beforeClosingDraw.players.find(p => p.ally)!.id, f.closingDraw.action);
reject(f.beforeClosingDraw, f.owner, { ...f.closingDraw.action, turn: f.beforeClosingDraw.turn - 1 });
assert.equal(f.beforeClosingDraw.nexusCards!.cards!.hands[f.owner], null);
assert.equal(f.afterClosingDraw.nexusCards!.cards!.hands[f.owner], 'fremen');
assert.ok(!f.afterClosingDraw.nexusCards!.cards!.deck.includes('fremen'));
assert.equal(f.afterClosingDraw.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 0);
legalPolicies(f.beforeShipment, f.owner);
const before = player(f.beforeShipment, f.owner), after = player(f.afterShipment, f.owner);
assert.equal(after.reserves, before.reserves - 4);
assert.equal(after.elites!.reserves, before.elites!.reserves - 1);
assert.equal(after.spice, before.spice - 4, 'One original Homeworld invasion invoice, not Arrakis or doubled payment.');
assert.deepEqual(pool(f.afterShipment, f.owner, f.territory), { normal: 3, elite: 1 });
assert.deepEqual(pool(f.beforeShipment, f.owner, 'homeworld:emperor'), {
normal: pool(f.afterShipment, f.owner, 'homeworld:emperor').normal + 3, elite: 0,
});
assert.equal(pool(f.afterShipment, f.owner, 'homeworld:emperor:salusa').elite,
pool(f.beforeShipment, f.owner, 'homeworld:emperor:salusa').elite - 1);
const token = f.beforeShippingEnd.techTokens!.heighliners;
assert.equal(token.spice, 1); assert.equal(token.triggeredTurn, 2);
legalPolicies(f.beforeShippingEnd, f.shippingEnd.actor);
assert.equal(player(f.afterShippingEnd, token.owner!).spice, player(f.beforeShippingEnd, token.owner!).spice + token.spice);
assert.equal(f.afterShippingEnd.techTokens!.heighliners.spice, 0);
assert.equal(f.afterShippingEnd.techTokens!.axlotl.spice, 0);
assert.equal(f.afterShippingEnd.techTokens!.production.spice, 0);
assert.equal(f.game.strongholdCards!.owners.tueks_sietch, f.opponent, 'Original Guild board occupation earned the retained public card at first Mentat.');
assert.equal(strongholdBenefit(f.game.strongholdCards, f.opponent, f.territory), null, 'Held Tuek support is source-local, not a benefit on Guild Homeworld.');
for (const actor of [f.owner, f.opponent]) legalPolicies(f.game, actor);
const changed = { ...f, plans: f.plans.map(step => step.actor === f.opponent
? { actor: step.actor, action: { ...step.action, dial: 2, support: 1 } } : step) };
const revealed = reveal(changed), pending = settle(revealed, 'suk');
assert.equal(player(pending, f.owner).spice, player(f.game, f.owner).spice - 2,
'The invading army pays its actual support before Suk rescue.');
assert.equal(player(pending, f.opponent).spice, player(f.game, f.opponent).spice - 1,
'The free native battle-strength bonus does not waive ordinary support or borrow the held Tuek subsidy.');
inventory(pending);
});
for (const kind of ['native-suk', 'visitor-suk'] as const) {
void test(`Advanced original ${kind} saves its actual typed source under HW/Nexus/DS/Tech/Stronghold composition`, () => {
const f = fixture({ advanced: true, discovery: true, tech: true, strongholds: true, kind });
const revealed = reveal(f), pending = settle(revealed, 'suk');
assert.deepEqual(pending.techTokens, revealed.techTokens, 'Real winner Tech ownership cannot move ahead of the owned Suk cleanup.');
reject(f.game, f.owner, { type: 'battlePlan', leader: f.trainer,
dial: kind === 'native-suk' ? 12 : 8, support: 0 });
assert.ok(pending.decision?.kind === 'sukRescue');
const decision = pending.decision, receipt = pending.pendingSukRescue!;
const choice = homeworldSkillsDiscoveryRescueChoice(pending, kind === 'native-suk' ? 'elite' : 'normal');
const option = decision.options[choice], destinations = homeworldSkillsDiscoverySplitReturn(pending, choice);
assert.equal(receipt.territory, f.territory); assert.equal(receipt.skill.mode, 'skilled');
assert.equal(receipt.pool.length, 1); assert.equal(receipt.pool[0].key, f.territory);
assert.equal(option.normal + option.elite, 3);
const physical = quoteSukPhysicalRescue(physicalContext(pending), pending.homeworlds!.custody!, {
player: f.owner, territory: f.territory, skill: receipt.skill, pool: receipt.pool,
losses: receipt.losses!, option, destinations,
});
assert.equal(physical.receipt.source, kind === 'native-suk' ? 'native-homeworld' : 'visitor-homeworld');
legalPolicies(pending, f.owner);
const action: Action = { type: 'decision', event: decision.event, choice, ...(destinations ? { destinations } : {}) };
reject(pending, f.opponent, action);
reject(pending, f.owner, { ...action, event: 'stale-physical-rescue' });
reject(pending, f.owner, { ...action, destinations: { 'homeworld:guild': { normal: 1, elite: 1 } } });
if (kind === 'native-suk') {
assert.equal(decision.reserveHomes, undefined);
assert.equal(receipt.losses!.normal, 0); assert.equal(receipt.losses!.elite, 3);
reject(pending, f.owner, { ...action, destinations: { 'homeworld:emperor': { normal: 0, elite: 3 } } });
} else {
assert.equal(receipt.losses!.normal, 2); assert.equal(receipt.losses!.elite, 1);
assert.deepEqual(destinations, { 'homeworld:emperor': { normal: 0, elite: 1 }, 'homeworld:emperor:salusa': { normal: 1, elite: 0 } });
reject(pending, f.owner, { type: 'decision', event: decision.event, choice });
reject(pending, f.owner, { ...action, destinations: { 'homeworld:emperor': { normal: 0, elite: 1 } } });
}
const resolved = settle(applyAction(JSON.parse(JSON.stringify(pending)), f.owner, action));
assert.deepEqual(resolved.homeworlds!.custody, physical.custody);
assert.equal(player(resolved, f.owner).reserves, physical.players.find(p => p.id === f.owner)!.reserves);
assert.equal(player(resolved, f.owner).elites!.reserves, physical.players.find(p => p.id === f.owner)!.eliteReserves);
assert.equal(player(resolved, f.owner).tanks - player(pending, f.owner).tanks,
receipt.losses!.normal + receipt.losses!.elite - option.normal - option.elite);
assert.equal(resolved.pendingSukRescue ?? null, null);
assert.equal(player(resolved, f.owner).leaders.find(l => l.id === f.trainer)!.dead, false);
assert.ok(resolved.leaderSkills!.assignments.some(a => a.owner === f.owner && a.leader === f.trainer && a.skill === 'suk-graduate'));
if (kind === 'native-suk') assert.deepEqual(resolved.homeworlds!.custody!.salusa, pending.homeworlds!.custody!.salusa,
'Saved native Sardaukar remain at the exact native Salusa source, with no internal move.');
else assert.deepEqual(pool(resolved, f.owner, f.territory), { normal: 2, elite: 0 });
const done = finishWinnerTech(resolved);
for (const token of ownedTech(revealed.techTokens, f.opponent))
assert.equal(done.techTokens![token].owner, f.owner, 'Original losing-owner Tech transfers only after real rescue and card cleanup.');
assert.equal(done.lastBattleContext!.winner, f.owner); inventory(done);
});
}
for (const advanced of [false, true]) {
void test(`${advanced ? 'Advanced typed' : 'Basic ordinary'} real zero-save Suk losses fund exact-three Fremen Nexus return at the next actual Revival`, () => {
const f = fixture({ advanced, discovery: advanced, tech: true, strongholds: advanced });
const pending = settle(reveal(f), 'suk'); assert.ok(pending.decision?.kind === 'sukRescue');
const choice = pending.decision.options.findIndex(option => !option.normal && !option.elite && !option.kept);
assert.ok(choice >= 0, 'The skilled original human option may genuinely decline all rescue.');
legalPolicies(pending, f.owner);
const done = finishWinnerTech(settle(applyAction(pending, f.owner, { type: 'decision', event: pending.decision.event, choice })));
assert.equal(player(done, f.owner).tanks, advanced ? 3 : 4);
assert.equal(player(done, f.owner).elites?.tanks ?? 0, advanced ? 1 : 0);
assert.equal(done.nexusCards!.cards!.hands[f.owner], 'fremen', 'Battle rescue never spends the unrelated physically held Nexus singleton.');
const window = revivalWindow(done, f.actions, f.staging), offer = viewGame(window, f.owner).nexusFremenRevival!;
assert.equal(offer.blocked, null); assert.ok(offer.eliteOptions.includes(advanced ? 1 : 0));
legalPolicies(window, f.owner);
const action: Action = { type: 'nexusFremenRevive', event: offer.event, elite: advanced ? 1 : 0 };
reject(window, f.opponent, action);
reject(window, f.owner, { ...action, event: 'stale-free-three' });
reject(window, f.owner, { ...action, elite: 2 });
const before = structuredClone(player(window, f.owner));
const kaitain = pool(window, f.owner, 'homeworld:emperor');
const salusa = advanced ? pool(window, f.owner, 'homeworld:emperor:salusa') : null;
const paid = applyAction(JSON.parse(JSON.stringify(window)), f.owner, action), after = player(paid, f.owner);
assert.equal(after.spice, before.spice, 'Free-three is not charged as paid revival or external Suk placement.');
assert.equal(after.tanks, before.tanks - 3); assert.equal(after.reserves, before.reserves + 3);
assert.equal(after.revived, 3); assert.equal(after.freeForcesRevived, 3);
assert.deepEqual(pool(paid, f.owner, 'homeworld:emperor'), { normal: kaitain.normal + (advanced ? 2 : 3), elite: kaitain.elite });
if (advanced) {
assert.deepEqual(pool(paid, f.owner, 'homeworld:emperor:salusa'), { normal: salusa!.normal, elite: salusa!.elite + 1 });
assert.equal(after.elites!.tanks, before.elites!.tanks - 1);
assert.equal(after.elites!.reserves, before.elites!.reserves + 1);
assert.equal(after.elites!.revived, 1);
}
assert.equal(paid.nexusCards!.cards!.hands[f.owner], null);
assert.equal(paid.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
reject(paid, f.owner, action);
reject(paid, f.owner, { type: 'revive', amount: 1, elite: 0 });
const token = paid.techTokens!.axlotl, holder = token.owner!;
assert.equal(token.spice, ownedTech(paid.techTokens, holder).length);
const end = advance(paid, g => g.phase === 4 && clean(g) && g.ready.length === g.players.length - 1);
const actor = end.players.find(p => !end.ready.includes(p.id))!.id;
legalPolicies(end, actor);
const ended = applyAction(end, actor, { type: 'ready' });
assert.equal(player(ended, holder).spice, player(end, holder).spice + token.spice);
assert.equal(ended.techTokens!.axlotl.spice, 0); inventory(ended);
});
}
void test('real lethal visitor battle returns the trained public Skill once before any original winner Tech reward', () => {
const f = fixture({ advanced: true, discovery: true, tech: true, strongholds: true, kind: 'visitor-death' });
const weapon = f.plans.find(step => step.actor === f.opponent)!.action.weapon as string;
const resolved = settle(reveal(f));
assert.equal(player(resolved, f.owner).leaders.find(l => l.id === f.trainer)!.dead, true);
assert.ok(!resolved.leaderSkills!.assignments.some(a => a.leader === f.trainer));
assert.equal(resolved.leaderSkills!.deck.filter(skill => skill === 'mentat').length, 1);
assert.equal(player(resolved, f.owner).tanks, 4);
assert.deepEqual(pool(resolved, f.owner, f.territory), { normal: 0, elite: 0 });
const done = finishWinnerTech(resolved);
for (const token of ownedTech(f.game.techTokens, f.owner))
assert.equal(done.techTokens![token].owner, f.opponent, 'Killed trained disc and losing army settle before the original winner reward.');
assert.equal(done.lastBattleContext!.winner, f.opponent);
assert.ok(player(done, f.opponent).hand.some(card => card.id === weapon), 'Original winner may keep the genuinely played projectile through ordinary cleanup.');
const later = advance(done, g => g.turn > done.turn);
assert.equal(later.leaderSkills!.deck.filter(skill => skill === 'mentat').length, 1);
inventory(later);
});