Compose classic Homeworld optional modules through original rules
This commit is contained in:
1 parent
3dd36bd9ff
commit
fa025220f8
18 files changed
+1220
-94
No files matched your search
@@ -55,13 +55,12 @@ void test('Discovery cannot redeal started components or silently remove another
|
||||
void test('Discovery Skills cannot silently drop unsupported native families or unrelated modules and previews', () => {
|
||||
const unsupported = lobby(['ixians', 'ecaz', 'guild'], ['ix', 'ecaz']);
|
||||
assert.throws(() => initializeLeaderSkillsGameForAudit(unsupported), RuleError);
|
||||
const overlays = Array.from({ length: 5 }, () => lobby(['guild', 'emperor', 'harkonnen'], []));
|
||||
overlays[0].homeworlds = { custody: null };
|
||||
overlays[1] = lobby(['ixians', 'choam', 'guild'], ['ix', 'choam']);
|
||||
overlays[1].nexusCards = { cards: null, phase: null };
|
||||
overlays[2].advanced = false; overlays[2].strongholdCards = createStrongholdCards();
|
||||
overlays[3].mentatQuestionPreview = true;
|
||||
overlays[4].spiceBankerIncomePreview = true;
|
||||
const overlays = Array.from({ length: 4 }, () => lobby(['guild', 'emperor', 'harkonnen'], []));
|
||||
overlays[0] = lobby(['ixians', 'choam', 'guild'], ['ix', 'choam']);
|
||||
overlays[0].nexusCards = { cards: null, phase: null };
|
||||
overlays[1].advanced = false; overlays[1].strongholdCards = createStrongholdCards();
|
||||
overlays[2].mentatQuestionPreview = true;
|
||||
overlays[3].spiceBankerIncomePreview = true;
|
||||
for (const game of overlays) assert.throws(() => initializeLeaderSkillsGameForAudit(game), RuleError);
|
||||
const two = lobby(['guild', 'emperor'], []); two.techTokens = createTechTokens();
|
||||
assert.throws(() => initializeLeaderSkillsGameForAudit(two), RuleError);
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeDiscoveryGameForAudit, joinGame, newPlayer,
|
||||
viewGame, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
||||
import {
|
||||
createHomeworldClassicDiscoveryFixture, homeworldClassicDiscoveryClean,
|
||||
homeworldClassicDiscoveryPlayer, nextHomeworldClassicDiscoveryStep,
|
||||
type HomeworldClassicDiscoveryFixture,
|
||||
} from './fixture-homeworld-classic-discovery';
|
||||
|
||||
export type HomeworldOptionalStep = { actor: string; action: Action };
|
||||
export type HomeworldOptionalOptions = {
|
||||
initial?: Game;
|
||||
advanced?: boolean;
|
||||
seats?: 2 | 3 | 4 | 5 | 6;
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
};
|
||||
export type HomeworldOptionalFixture = HomeworldClassicDiscoveryFixture & {
|
||||
beforeFirstStorm: Game;
|
||||
afterFirstStorm: Game;
|
||||
};
|
||||
export const optionalHomeworldPlayer = homeworldClassicDiscoveryPlayer;
|
||||
export const optionalHomeworldClean = homeworldClassicDiscoveryClean;
|
||||
|
||||
/** Only original undealt card lotteries are chosen. The first seven random
|
||||
* draws shuffle the eight opaque Discovery faces; the following shuffle is the
|
||||
* original 33-card Treachery deck. Starting cards are dealt by native setup. */
|
||||
function originalStartingLottery<T>(game: Game, initialize: () => T): T {
|
||||
const cards = treacheryDeck(), remaining = [...cards];
|
||||
const prefix = game.players.flatMap(player => {
|
||||
const kinds = player.faction === 'emperor' ? ['poison'] : player.faction === 'harkonnen'
|
||||
? ['shield', 'worthless'] : player.faction === 'atreides' ? ['shield'] : ['worthless'];
|
||||
return kinds.map(kind => {
|
||||
const index = remaining.findIndex(card => card.kind === kind); assert.ok(index >= 0);
|
||||
return remaining.splice(index, 1)[0];
|
||||
});
|
||||
});
|
||||
const desired = [...prefix, ...remaining], working = [...cards], rolls: number[] = [];
|
||||
for (let i = working.length - 1; i > 0; i--) {
|
||||
const j = working.findIndex(card => card.id === desired[i].id); assert.ok(j >= 0 && j <= i);
|
||||
rolls.push(Math.floor((j + 0.5) / (i + 1) * 0x100000000));
|
||||
[working[i], working[j]] = [working[j], working[i]];
|
||||
}
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const original = crypto.getRandomValues.bind(crypto); let draw = 0;
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1) {
|
||||
if (draw < 7) array[0] = 0xffffffff;
|
||||
else if (draw < 7 + rolls.length) array[0] = rolls[draw - 7];
|
||||
else Reflect.apply(original, crypto, [array]);
|
||||
draw++;
|
||||
} else Reflect.apply(original, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return initialize(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
|
||||
/** Original fresh no-Skills lobby, optional unused physical components, original
|
||||
* Homeworld/Discovery initializer. No owners, balances, forces or held cards are
|
||||
* injected; even the optional components are selected by their host controls. */
|
||||
export function createHomeworldOptionalLobby(options: HomeworldOptionalOptions = {}): Game {
|
||||
let game = options.initial ? structuredClone(options.initial)
|
||||
: createGame('HWOPTIONAL', newPlayer('emperor', 'Emperor', 'emperor'), options.advanced ?? true);
|
||||
if (!options.initial) {
|
||||
const roster: FactionId[] = ['fremen', 'guild', 'atreides', 'harkonnen', 'beneGesserit'];
|
||||
for (const faction of roster.slice(0, (options.seats ?? 5) - 1)) joinGame(game, newPlayer(faction, faction, faction));
|
||||
}
|
||||
assert.ok(game.status === 'lobby' || (game.status === 'setup' &&
|
||||
(game.setupStage === 'prediction' || game.setupStage === 'traitors') &&
|
||||
game.turn === 1 && game.phase === 0 && !game.leaderSkills &&
|
||||
game.discoveryEnabled && game.discoveries && game.homeworlds?.custody === null),
|
||||
'Continue only an original fresh lobby or its initial Homeworld Discovery setup.');
|
||||
if (game.status === 'setup') return game;
|
||||
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 });
|
||||
game.discoveryEnabled = true;
|
||||
return game;
|
||||
}
|
||||
export function initializeHomeworldOptional(options: HomeworldOptionalOptions = {}): Game {
|
||||
let game = createHomeworldOptionalLobby(options);
|
||||
if (game.status === 'setup') return game;
|
||||
for (const seat of game.players) if (!seat.ready) game = applyAction(game, seat.id, { type: 'ready' });
|
||||
return originalStartingLottery(game, () => initializeDiscoveryGameForAudit(game));
|
||||
}
|
||||
export function nextHomeworldOptionalStep(game: Game): HomeworldOptionalStep {
|
||||
if (!game.phaseOpening && !game.response) {
|
||||
if (game.decision?.kind === 'battleCards') return { actor: game.decision.player, action: { type: 'decision', discard: [] } };
|
||||
if (game.decision?.kind === 'techToken') return { actor: game.decision.player,
|
||||
action: { type: 'decision', token: game.decision.choices[0] } };
|
||||
}
|
||||
return nextHomeworldClassicDiscoveryStep(game);
|
||||
}
|
||||
export function actHomeworldOptional(game: Game, step: HomeworldOptionalStep, actions?: HomeworldOptionalStep[]): Game {
|
||||
actions?.push(structuredClone(step)); return applyAction(game, step.actor, step.action);
|
||||
}
|
||||
export function advanceHomeworldOptional(state: Game, until: (game: Game) => boolean,
|
||||
actions?: HomeworldOptionalStep[]): Game {
|
||||
let game = state;
|
||||
for (let i = 0; !until(game) && i < 1800; i++) {
|
||||
assert.notEqual(game.status, 'finished');
|
||||
game = actHomeworldOptional(game, nextHomeworldOptionalStep(game), actions);
|
||||
}
|
||||
assert.ok(until(game), 'The original optional Homeworld action window was not reached.');
|
||||
return game;
|
||||
}
|
||||
export function createHomeworldOptionalFixture(options: HomeworldOptionalOptions = {}): HomeworldOptionalFixture {
|
||||
const setup = initializeHomeworldOptional(options);
|
||||
const beforeFirstStorm = advanceHomeworldOptional(setup, game => game.status === 'playing' &&
|
||||
game.phase === 0 && optionalHomeworldClean(game));
|
||||
const afterFirstStorm = advanceHomeworldOptional(beforeFirstStorm, game => game.phase === 1 && optionalHomeworldClean(game));
|
||||
const fixture = createHomeworldClassicDiscoveryFixture({ initial: setup, prepareImperialSplit: true });
|
||||
return { ...fixture, beforeFirstStorm, afterFirstStorm };
|
||||
}
|
||||
export function optionalHomeworldMovementWindow(state: Game, actor: string, turn = 2): Game {
|
||||
return advanceHomeworldOptional(state, game => game.turn === turn && game.phase === 5 &&
|
||||
game.active === actor && optionalHomeworldClean(game));
|
||||
}
|
||||
export function settleHomeworldOptionalArrival(state: Game): Game {
|
||||
return advanceHomeworldOptional(state, game => !game.pendingShipment && !game.pendingHomeworldShipment && optionalHomeworldClean(game));
|
||||
}
|
||||
export function shipHomeworldOptional(game: Game, actor: string, territory: string, sector: number,
|
||||
amount: number, elite = 0, sources = nativeShipmentSources(viewGame(game, actor), amount, elite)): Game {
|
||||
assert.ok(sources);
|
||||
return settleHomeworldOptionalArrival(applyAction(game, actor,
|
||||
{ type: 'ship', territory, sector, amount, elite, allyPayment: 0, homeworldSources: sources }));
|
||||
}
|
||||
export function invadeHomeworldOptional(game: Game, actor: string, destination: string, amount: number): Game {
|
||||
const native = nativeShipmentSources(viewGame(game, actor), amount, 0); assert.ok(native);
|
||||
const sources = Object.fromEntries(Object.entries(native).filter(([, group]) => group.normal + group.elite > 0));
|
||||
const choice = homeworldShipmentChoice(viewGame(game, actor), destination, sources); assert.ok(choice.action, choice.blocked ?? undefined);
|
||||
return settleHomeworldOptionalArrival(applyAction(game, actor, choice.action));
|
||||
}
|
||||
export function openHomeworldOptionalBattle(state: Game, territory: string, first: string, second: string): Game {
|
||||
let game = advanceHomeworldOptional(state, game => game.phase === 6 && !!game.active && optionalHomeworldClean(game));
|
||||
const actor = game.active!; assert.ok(actor === first || actor === second);
|
||||
game = applyAction(game, actor, { type: 'chooseBattle', territory, target: actor === first ? second : first });
|
||||
return advanceHomeworldOptional(game, game => !!game.battle && !game.battle.preparation && optionalHomeworldClean(game));
|
||||
}
|
||||
export function optionalHomeworldLeader(game: Game, actor: string, strongest: boolean): string {
|
||||
const leader = optionalHomeworldPlayer(game, actor).leaders.filter(disc => !disc.dead &&
|
||||
(!disc.usedAt || disc.usedAt === game.battle?.territory))
|
||||
.sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength)[0];
|
||||
assert.ok(leader); return leader.id;
|
||||
}
|
||||
export function finishHomeworldOptionalBattle(state: Game): Game {
|
||||
const territory = state.battle!.territory;
|
||||
return advanceHomeworldOptional(state, game =>
|
||||
!game.battle && !game.homeworldBattleLoss && optionalHomeworldClean(game) && game.lastBattleContext?.territory === territory);
|
||||
}
|
||||
|
||||
export type HomeworldOptionalClaim = { before: Game; step: HomeworldOptionalStep; after: Game };
|
||||
export function claimHomeworldOptional(state: Game): HomeworldOptionalClaim {
|
||||
let game = advanceHomeworldOptional(state, game => game.phase === 8 && optionalHomeworldClean(game));
|
||||
const turn = game.turn;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const before = structuredClone(game), step = nextHomeworldOptionalStep(game);
|
||||
game = actHomeworldOptional(game, step);
|
||||
if (game.turn !== turn || game.status === 'finished') return { before, step, after: game };
|
||||
if (game.strongholdCards?.claimedTurn === turn) return { before, step, after: game };
|
||||
}
|
||||
throw Error('Original end-Mentat did not settle its Stronghold Cards.');
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
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 { 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 { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import type { SukReserveDestinations } from '../game/suk-graduate';
|
||||
import {
|
||||
assertClassicHomeworldSkillsCustody, classicHomeworldSkillsPlayer,
|
||||
createClassicHomeworldSkillsSetup, nextClassicHomeworldSkillsStep,
|
||||
type ClassicHomeworldSkillsSetup, type ClassicHomeworldSkillsStep,
|
||||
} from './fixture-homeworld-classic-skills';
|
||||
|
||||
export type HomeworldSkillsDiscoveryStep = ClassicHomeworldSkillsStep;
|
||||
export type HomeworldSkillsDiscoveryOptions = {
|
||||
initial?: Game;
|
||||
advanced?: boolean;
|
||||
seats?: 2 | 3 | 4 | 5 | 6;
|
||||
roster?: readonly FactionId[];
|
||||
skill?: LeaderSkillId;
|
||||
face?: DiscoveryLocationId;
|
||||
shipForMaker?: boolean;
|
||||
};
|
||||
export type HomeworldSkillsDiscoveryFixture = ClassicHomeworldSkillsSetup & {
|
||||
game: Game;
|
||||
owner: string;
|
||||
fremen: string;
|
||||
opponent: string | null;
|
||||
trainer: string;
|
||||
token: DiscoveryOpaqueTokenId;
|
||||
face: DiscoveryLocationId;
|
||||
parent: string;
|
||||
parentSector: number;
|
||||
wormTerritory: string;
|
||||
wormSector: number;
|
||||
amount: number;
|
||||
elite: number;
|
||||
beforeShipment: Game;
|
||||
afterShipment: Game;
|
||||
};
|
||||
export type HomeworldSkillsDiscoveryBattleFixture = HomeworldSkillsDiscoveryFixture & {
|
||||
entered: Game;
|
||||
beforeReinforcement: Game;
|
||||
afterReinforcement: Game;
|
||||
beforeBattle: Game;
|
||||
plans: HomeworldSkillsDiscoveryStep[];
|
||||
};
|
||||
export const homeworldSkillsDiscoveryPlayer = classicHomeworldSkillsPlayer;
|
||||
export const homeworldSkillsDiscoveryClean = (game: Game) =>
|
||||
!game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
|
||||
|
||||
/** An original fresh lobby, never a second reserve or component model. */
|
||||
export function createHomeworldSkillsDiscoveryLobby(options: HomeworldSkillsDiscoveryOptions = {}): Game {
|
||||
if (options.initial) return structuredClone(options.initial);
|
||||
const factions: readonly FactionId[] = options.roster ?? ['emperor', 'fremen', 'guild', 'harkonnen', 'atreides', 'beneGesserit'];
|
||||
let game = createGame('HWSKILLSDISCOVERY', newPlayer(factions[0], factions[0], factions[0]), options.advanced ?? false);
|
||||
for (const faction of factions.slice(1, options.seats ?? (options.roster ? factions.length : 4))) joinGame(game, newPlayer(faction, faction, faction));
|
||||
game = applyAction(game, game.host, { type: 'homeworlds', enabled: true });
|
||||
game.discoveryEnabled = true;
|
||||
return game;
|
||||
}
|
||||
export function createHomeworldSkillsDiscoverySetup(options: HomeworldSkillsDiscoveryOptions = {}): ClassicHomeworldSkillsSetup {
|
||||
return createClassicHomeworldSkillsSetup({ initial: createHomeworldSkillsDiscoveryLobby(options), skill: options.skill ?? 'suk-graduate' });
|
||||
}
|
||||
|
||||
/** Consume only original owner-labelled queues; human plans and rescue choices
|
||||
* remain explicit boundaries. The lottery helper below never signs a window. */
|
||||
export function nextHomeworldSkillsDiscoveryStep(game: Game): HomeworldSkillsDiscoveryStep | null {
|
||||
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 } };
|
||||
}
|
||||
return nextClassicHomeworldSkillsStep(game, game.players.find(p => p.faction === 'emperor')?.id);
|
||||
}
|
||||
export function advanceHomeworldSkillsDiscovery(state: Game, until: (game: Game) => boolean,
|
||||
actions?: HomeworldSkillsDiscoveryStep[]): Game {
|
||||
let game = state;
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const next = nextHomeworldSkillsDiscoveryStep(game);
|
||||
assert.ok(next, 'A real sealed plan or physical Suk rescue choice is required.');
|
||||
actions?.push(structuredClone(next)); game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
throw Error('Original Homeworld/Skills/Discovery programme did not reach its owned window.');
|
||||
}
|
||||
export function assertHomeworldSkillsDiscoveryInventory(game: Game): void {
|
||||
assertClassicHomeworldSkillsCustody(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);
|
||||
}
|
||||
function frontUnplayedSpice(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, 'Select only an original still-unplayed physical Spice Card.');
|
||||
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
function originalTokenLottery<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 descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'), native = crypto.getRandomValues.bind(crypto);
|
||||
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 act(game: Game, step: HomeworldSkillsDiscoveryStep, actions: HomeworldSkillsDiscoveryStep[]): Game {
|
||||
actions.push(structuredClone(step)); return applyAction(game, step.actor, step.action);
|
||||
}
|
||||
|
||||
/** PDF21–23 variants together: original all14 training before faction setup,
|
||||
* original seven Spice Cards/eight tokens, and original Homeworld withdrawals.
|
||||
* Only undealt lotteries/unplayed Spice order are selected; no force staging. */
|
||||
export function createHomeworldSkillsDiscoveryFixture(options: HomeworldSkillsDiscoveryOptions = {}): HomeworldSkillsDiscoveryFixture {
|
||||
const setup = createHomeworldSkillsDiscoverySetup(options);
|
||||
let game = structuredClone(setup.afterSetup);
|
||||
const owner = game.players.find(p => p.faction === 'emperor')!.id;
|
||||
const fremen = game.players.find(p => p.faction === 'fremen')!.id;
|
||||
const opponent = game.players.find(p => p.faction === 'guild')?.id ?? null;
|
||||
const trainer = game.leaderSkills!.assignments.find(a => a.owner === owner)!.leader;
|
||||
const face = options.face ?? 'cistern';
|
||||
frontUnplayedSpice(game, card => 'territory' in card && card.discovery === 'discovery-hagga-basin', 0);
|
||||
if (game.advanced) frontUnplayedSpice(game, card => 'territory' in card && !card.discovery && card.territory !== 'hagga_basin', 1);
|
||||
const nextTurnIndex = game.advanced ? 2 : 1;
|
||||
frontUnplayedSpice(game, card => 'worm' in card && !!card.greatMaker, nextTurnIndex);
|
||||
for (let position = nextTurnIndex + 1; position < nextTurnIndex + (game.advanced ? 3 : 2); position++)
|
||||
frontUnplayedSpice(game, card => 'territory' in card && !card.discovery && card.territory !== 'hagga_basin', position);
|
||||
setup.staging.push('Selected only the conserved unplayed Hagga Discovery/next-turn Great Maker Spice order and the original Hiereg supply lottery; every force arrives through an engine action.');
|
||||
game = advanceHomeworldSkillsDiscovery(game, g => g.phase === 1 && homeworldSkillsDiscoveryClean(g), setup.actions);
|
||||
game = originalTokenLottery(game, face, () => advanceHomeworldSkillsDiscovery(game,
|
||||
g => g.discoveries!.tokens.some(t => t.face === face && t.status === 'placed'), setup.actions));
|
||||
game = advanceHomeworldSkillsDiscovery(game, g => g.phase === 5 && g.active === owner && homeworldSkillsDiscoveryClean(g), setup.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 beforeShipment = structuredClone(game), sources = nativeShipmentSources(viewGame(game, owner), amount, elite); assert.ok(sources);
|
||||
game = act(game, { actor: owner, action: { type: 'ship', territory: destination.territory,
|
||||
sector: destination.sector, amount, elite, allyPayment: 0, homeworldSources: sources } }, setup.actions);
|
||||
game = advanceHomeworldSkillsDiscovery(game, g => !g.pendingShipment && homeworldSkillsDiscoveryClean(g), setup.actions);
|
||||
const afterShipment = structuredClone(game);
|
||||
game = advanceHomeworldSkillsDiscovery(game, g => g.phase === 7 && homeworldSkillsDiscoveryClean(g), setup.actions);
|
||||
const token = game.discoveries!.tokens.find(t => t.face === face)!;
|
||||
assert.equal(game.turn, 1); assertHomeworldSkillsDiscoveryInventory(game);
|
||||
return { ...setup, game, owner, fremen, opponent, trainer, token: token.id, face,
|
||||
parent: placement.territory, parentSector: placement.sector, wormTerritory: blow.territory,
|
||||
wormSector: blow.sector, amount, elite, beforeShipment, afterShipment };
|
||||
}
|
||||
export function revealHomeworldSkillsDiscovery(fixture: HomeworldSkillsDiscoveryFixture): Game {
|
||||
let game = structuredClone(fixture.game);
|
||||
if (viewGame(game, fixture.owner).discoveries!.canInspect.includes(fixture.token))
|
||||
game = applyAction(game, fixture.owner, { type: 'discovery', token: fixture.token, reveal: false });
|
||||
assert.ok(viewGame(game, fixture.owner).discoveries!.canReveal.includes(fixture.token));
|
||||
return applyAction(game, fixture.owner, { type: 'discovery', token: fixture.token, reveal: true });
|
||||
}
|
||||
export function homeworldSkillsDiscoveryEntryWindow(fixture: HomeworldSkillsDiscoveryFixture): Game {
|
||||
return advanceHomeworldSkillsDiscovery(revealHomeworldSkillsDiscovery(fixture), game =>
|
||||
game.turn === 2 && game.decision?.kind === 'discoveryEntry' && game.decision.player === fixture.owner);
|
||||
}
|
||||
export function enterHomeworldSkillsDiscovery(fixture: HomeworldSkillsDiscoveryFixture): Game {
|
||||
const game = homeworldSkillsDiscoveryEntryWindow(fixture), view = viewGame(game, fixture.owner);
|
||||
const action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources); assert.ok(action);
|
||||
return applyAction(game, fixture.owner, action);
|
||||
}
|
||||
export function homeworldSkillsDiscoveryMakerWindow(fixture: HomeworldSkillsDiscoveryFixture): Game {
|
||||
return advanceHomeworldSkillsDiscovery(structuredClone(fixture.game), game => game.decision?.kind === 'greatMakerVote');
|
||||
}
|
||||
|
||||
/** Genuine reveal -> next-turn parent entry -> paid reinforcement and enemy
|
||||
* shipment -> nested battle. Neither physical army is placed by the fixture. */
|
||||
export function createHomeworldSkillsDiscoveryBattleFixture(options: HomeworldSkillsDiscoveryOptions = {}): HomeworldSkillsDiscoveryBattleFixture {
|
||||
const fixture = createHomeworldSkillsDiscoveryFixture(options);
|
||||
assert.ok(fixture.opponent, 'The connected nested battle needs the original Guild seat.');
|
||||
let game = enterHomeworldSkillsDiscovery(fixture);
|
||||
const entered = structuredClone(game), reinforcement = game.advanced ? 2 : 3;
|
||||
const beforeByActor = new Map<string, Game>(), afterByActor = new Map<string, Game>();
|
||||
// Original storm order determines which real shipment happens first.
|
||||
game = advanceHomeworldSkillsDiscovery(game, g => g.turn === 2 && g.phase === 5 && homeworldSkillsDiscoveryClean(g), fixture.actions);
|
||||
const remaining = new Set([fixture.owner, fixture.opponent]);
|
||||
while (remaining.size) {
|
||||
game = advanceHomeworldSkillsDiscovery(game, g => g.turn === 2 && g.phase === 5 &&
|
||||
homeworldSkillsDiscoveryClean(g) && remaining.has(g.active!), fixture.actions);
|
||||
const actor = game.active!, amount = actor === fixture.owner ? reinforcement : 2;
|
||||
beforeByActor.set(actor, structuredClone(game));
|
||||
const sources = nativeShipmentSources(viewGame(game, actor), amount, 0); assert.ok(sources);
|
||||
game = act(game, { actor, action: { type: 'ship', territory: fixture.face, sector: 0,
|
||||
amount, elite: 0, allyPayment: 0, homeworldSources: sources } }, fixture.actions);
|
||||
game = advanceHomeworldSkillsDiscovery(game, g => !g.pendingShipment && homeworldSkillsDiscoveryClean(g), fixture.actions);
|
||||
afterByActor.set(actor, structuredClone(game)); remaining.delete(actor);
|
||||
game = act(game, { actor, action: { type: 'endMovement' } }, fixture.actions);
|
||||
}
|
||||
game = advanceHomeworldSkillsDiscovery(game, g => g.phase === 6 && homeworldSkillsDiscoveryClean(g), fixture.actions);
|
||||
const beforeBattle = structuredClone(game), actor = game.active!;
|
||||
assert.ok(actor === fixture.owner || actor === fixture.opponent);
|
||||
game = act(game, { actor, action: { type: 'chooseBattle', territory: fixture.face,
|
||||
target: actor === fixture.owner ? fixture.opponent : fixture.owner } }, fixture.actions);
|
||||
game = advanceHomeworldSkillsDiscovery(game, g => !!g.battle && homeworldSkillsDiscoveryClean(g) &&
|
||||
!g.battle.preparation && g.battle.preLeader?.closed !== false, fixture.actions);
|
||||
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
|
||||
const enemy = classicHomeworldSkillsPlayer(game, fixture.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 plans: HomeworldSkillsDiscoveryStep[] = [
|
||||
{ actor: fixture.owner, action: { type: 'battlePlan', leader: fixture.trainer, dial: 4, support: game.advanced ? 2 : 0 } },
|
||||
{ actor: fixture.opponent, action: { type: 'battlePlan', leader: leader.id, dial: 0 } },
|
||||
];
|
||||
assertHomeworldSkillsDiscoveryInventory(game);
|
||||
return { ...fixture, game, entered, beforeReinforcement: beforeByActor.get(fixture.owner)!,
|
||||
afterReinforcement: afterByActor.get(fixture.owner)!, beforeBattle, plans };
|
||||
}
|
||||
export function revealHomeworldSkillsDiscoveryBattle(fixture: HomeworldSkillsDiscoveryBattleFixture): Game {
|
||||
let game = structuredClone(fixture.game);
|
||||
for (const step of fixture.plans) {
|
||||
game = applyAction(game, step.actor, step.action);
|
||||
if (!game.battle?.revealed) game = advanceHomeworldSkillsDiscovery(game,
|
||||
g => homeworldSkillsDiscoveryClean(g) && !!g.battle && !g.battle.preparation);
|
||||
}
|
||||
assert.ok(game.battle?.revealed); return game;
|
||||
}
|
||||
export function settleHomeworldSkillsDiscoveryBattle(state: Game, stop: 'suk' | 'complete' = 'complete'): Game {
|
||||
return advanceHomeworldSkillsDiscovery(state, g => stop === 'suk' && g.decision?.kind === 'sukRescue' ||
|
||||
!g.battle && homeworldSkillsDiscoveryClean(g));
|
||||
}
|
||||
export function homeworldSkillsDiscoveryRescueChoice(game: Game, kept: 'normal' | 'elite' = 'normal'): number {
|
||||
const decision = game.decision; assert.ok(decision?.kind === 'sukRescue');
|
||||
const maximum = Math.max(...decision.options.map(o => o.normal + o.elite));
|
||||
const choice = decision.options.findIndex(o => o.normal + o.elite === maximum && o.kept?.kind === kept);
|
||||
assert.ok(choice >= 0); return choice;
|
||||
}
|
||||
export function homeworldSkillsDiscoverySplitReturn(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], normal = option.normal - (option.kept?.kind === 'normal' ? 1 : 0),
|
||||
elite = option.elite - (option.kept?.kind === 'elite' ? 1 : 0);
|
||||
if (!normal && !elite) return undefined;
|
||||
// Explicit prototype placement interpretation, not printed revival placement:
|
||||
// return ordinary pieces to Salusa and Sardaukar to Kaitain, without moving a native saved counter.
|
||||
return Object.fromEntries(decision.reserveHomes.map(home => [home.id,
|
||||
home.secondary ? { normal, elite: 0 } : { normal: 0, elite }]));
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
applyAction, initializeDiscoveryGameForAudit, viewGame, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
||||
import { forceRevivalQuote } from '../game/revival';
|
||||
import {
|
||||
enterHomeworldClassicDiscovery, homeworldClassicDiscoveryInventory,
|
||||
revealHomeworldClassicDiscovery,
|
||||
} from './fixture-homeworld-classic-discovery';
|
||||
import {
|
||||
advanceHomeworldOptional as advance, claimHomeworldOptional,
|
||||
createHomeworldOptionalFixture as fixture, createHomeworldOptionalLobby,
|
||||
finishHomeworldOptionalBattle, initializeHomeworldOptional,
|
||||
invadeHomeworldOptional, nextHomeworldOptionalStep,
|
||||
openHomeworldOptionalBattle, optionalHomeworldClean as clean,
|
||||
optionalHomeworldLeader as leader, optionalHomeworldMovementWindow as movement,
|
||||
optionalHomeworldPlayer as player, settleHomeworldOptionalArrival, shipHomeworldOptional,
|
||||
} from './fixture-homeworld-optional-modules';
|
||||
|
||||
/** Authorized root rulebook physical pp.21–23: optional modules together or
|
||||
* separately, original first-Storm token assignment/phase-end collections,
|
||||
* winner transfer, end-Mentat Stronghold custody, off-planet native reserves and
|
||||
* combined Imperial shipment. Territory-local effects use the physical original
|
||||
* Arrakeen/Carthag faces (docs/STRONGHOLD_CARDS.md). All armies, casualties,
|
||||
* funding, held cards and rewards below originate in original engine actions. */
|
||||
function reject(game: Game, actor: string, action: Action): void {
|
||||
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
function legalPolicies(game: Game, actor: string): void {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, actor); view.players.find(seat => seat.id === actor)!.bot = difficulty;
|
||||
const actions = botActions(view); assert.ok(actions.length, `${difficulty} has an actual owned continuation.`);
|
||||
for (const action of actions) homeworldClassicDiscoveryInventory(applyAction(structuredClone(game), actor, action));
|
||||
}
|
||||
}
|
||||
function beforeMovementEnd(state: Game): Game {
|
||||
let game = state;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
assert.equal(game.phase, 5);
|
||||
const step = nextHomeworldOptionalStep(game);
|
||||
const after = applyAction(structuredClone(game), step.actor, step.action);
|
||||
if (after.phase !== 5) return game;
|
||||
game = after;
|
||||
}
|
||||
throw Error('Original Shipping never reached its phase-end action.');
|
||||
}
|
||||
function endMovementPhase(game: Game): Game {
|
||||
const last = nextHomeworldOptionalStep(game), after = applyAction(game, last.actor, last.action);
|
||||
assert.notEqual(after.phase, 5); return after;
|
||||
}
|
||||
function resolveToCleanup(state: Game): Game {
|
||||
return advance(state, game => game.decision?.kind === 'battleCards');
|
||||
}
|
||||
|
||||
void test('original no-Skills Homeworld Discovery admits Basic/Advanced 2–6 and original optional components only at their real seat/mode boundaries', () => {
|
||||
for (const advanced of [false, true]) for (const seats of [2, 3, 4, 5, 6] as const) {
|
||||
const options = { advanced, seats, tech: seats >= 3, strongholds: advanced };
|
||||
const f = fixture(options);
|
||||
assert.equal(f.game.players.length, seats);
|
||||
assert.equal(f.game.phase, 7); assert.equal(f.game.turn, 1);
|
||||
assert.equal(f.game.leaderSkills, undefined);
|
||||
homeworldClassicDiscoveryInventory(f.setup); homeworldClassicDiscoveryInventory(f.game);
|
||||
if (seats >= 3) {
|
||||
assert.equal(f.beforeFirstStorm.techTokens!.production.owner, f.fremen);
|
||||
assert.equal(f.beforeFirstStorm.techTokens!.axlotl.owner, null);
|
||||
assert.equal(f.beforeFirstStorm.techTokens!.heighliners.owner, null);
|
||||
const assigned = Object.values(f.afterFirstStorm.techTokens!);
|
||||
assert.ok(assigned.every(token => token.owner));
|
||||
assert.equal(new Set(assigned.map(token => token.owner)).size, 3);
|
||||
assert.ok(assigned.every(token => token.spice === 0));
|
||||
}
|
||||
if (advanced) {
|
||||
const claim = claimHomeworldOptional(f.game);
|
||||
assert.equal(claim.before.strongholdCards!.claimedTurn, 0);
|
||||
assert.ok(Object.values(claim.before.strongholdCards!.owners).every(owner => owner === null));
|
||||
assert.equal(claim.after.strongholdCards!.claimedTurn, 1);
|
||||
if (seats >= 4) assert.equal(claim.after.strongholdCards!.owners.arrakeen, 'atreides');
|
||||
if (seats >= 5) assert.equal(claim.after.strongholdCards!.owners.carthag, 'harkonnen');
|
||||
assert.equal(claim.after.strongholdCards!.owners.hidden_mobile_stronghold, null);
|
||||
homeworldClassicDiscoveryInventory(claim.after);
|
||||
}
|
||||
}
|
||||
assert.throws(() => initializeHomeworldOptional({ seats: 2, tech: true }));
|
||||
assert.throws(() => initializeHomeworldOptional({ advanced: false, strongholds: true }));
|
||||
// Invalid ORIGINAL component admission is checked by the actual initializer,
|
||||
// not by mirroring the profile predicate in an expectation.
|
||||
const original = createHomeworldOptionalLobby({ tech: true, strongholds: true });
|
||||
for (const corrupt of [
|
||||
(game: Game) => { game.techTokens!.axlotl.owner = game.host; },
|
||||
(game: Game) => { game.techTokens!.heighliners.spice = 1; },
|
||||
(game: Game) => { game.strongholdCards!.owners.arrakeen = game.host; },
|
||||
(game: Game) => { game.strongholdCards!.claimedTurn = 1; },
|
||||
]) {
|
||||
let changed = structuredClone(original); corrupt(changed);
|
||||
for (const seat of changed.players) changed = applyAction(changed, seat.id, { type: 'ready' });
|
||||
assert.throws(() => initializeDiscoveryGameForAudit(changed));
|
||||
}
|
||||
});
|
||||
|
||||
void test('combined Imperial off-planet shipment and another actual invoice accrue one Heighliner payment, collected once at original Shipping end', () => {
|
||||
const f = fixture({ tech: true, strongholds: true });
|
||||
let game = advance(revealHomeworldClassicDiscovery(f), g => g.turn === 2 && g.phase === 5 && clean(g));
|
||||
const owner = game.techTokens!.heighliners.owner!;
|
||||
const remaining = new Set([f.collector, 'harkonnen']);
|
||||
while (remaining.size) {
|
||||
game = advance(game, g => g.turn === 2 && g.phase === 5 && clean(g) && remaining.has(g.active!));
|
||||
const actor = game.active!;
|
||||
if (actor === f.collector) {
|
||||
const sources = { 'homeworld:emperor': { normal: 1, elite: 0 }, 'homeworld:emperor:salusa': { normal: 1, elite: 0 } };
|
||||
const ownBefore = structuredClone(player(game, actor));
|
||||
const homesBefore = structuredClone(viewGame(game, actor).homeworlds!.worlds!);
|
||||
const target = homesBefore.find(home => home.native === f.fremen)!;
|
||||
const choice = homeworldShipmentChoice(viewGame(game, actor), target.id, sources); assert.ok(choice.action);
|
||||
legalPolicies(game, actor);
|
||||
game = settleHomeworldOptionalArrival(applyAction(game, actor, choice.action));
|
||||
assert.equal(player(game, actor).spice, ownBefore.spice - 2);
|
||||
assert.equal(player(game, actor).reserves, ownBefore.reserves - 2);
|
||||
const homesAfter = viewGame(game, actor).homeworlds!.worlds!;
|
||||
for (const id of Object.keys(sources)) {
|
||||
assert.equal(homesAfter.find(home => home.id === id)!.forces[actor].normal,
|
||||
homesBefore.find(home => home.id === id)!.forces[actor].normal - 1);
|
||||
}
|
||||
assert.deepEqual(homesAfter.find(home => home.id === target.id)!.forces[actor], { normal: 2, elite: 0 });
|
||||
} else {
|
||||
game = shipHomeworldOptional(game, actor, 'cistern', 0, 1);
|
||||
}
|
||||
assert.equal(game.techTokens!.heighliners.spice, 1, 'Two real paid invoices still accrue one industry event.');
|
||||
assert.equal(game.techTokens!.heighliners.triggeredTurn, 2);
|
||||
remaining.delete(actor);
|
||||
if (remaining.size) game = applyAction(game, actor, { type: 'endMovement' });
|
||||
}
|
||||
const last = beforeMovementEnd(game), balance = player(last, owner).spice;
|
||||
const paid = endMovementPhase(last);
|
||||
assert.equal(player(paid, owner).spice, balance + 1);
|
||||
assert.equal(paid.techTokens!.heighliners.spice, 0);
|
||||
assert.equal(paid.techTokens!.axlotl.spice, 0); assert.equal(paid.techTokens!.production.spice, 0);
|
||||
homeworldClassicDiscoveryInventory(paid);
|
||||
});
|
||||
|
||||
void test('Guild-only actual Homeworld transport is excluded, while a combined Imperial nested shipment pays the printed single tariff and triggers Heighliners', () => {
|
||||
const f = fixture({ tech: true, strongholds: true });
|
||||
let guild = movement(f.game, 'guild');
|
||||
const guildBefore = player(guild, 'guild').spice;
|
||||
guild = invadeHomeworldOptional(guild, 'guild', 'homeworld:emperor', 1);
|
||||
assert.equal(player(guild, 'guild').spice, guildBefore - 1);
|
||||
assert.equal(guild.techTokens!.heighliners.spice, 0);
|
||||
assert.equal(guild.techTokens!.heighliners.triggeredTurn, 1);
|
||||
const end = beforeMovementEnd(guild), owner = end.techTokens!.heighliners.owner!, balance = player(end, owner).spice;
|
||||
const excluded = endMovementPhase(end);
|
||||
assert.equal(player(excluded, owner).spice, balance);
|
||||
assert.equal(excluded.techTokens!.heighliners.spice, 0);
|
||||
|
||||
let nested = movement(enterHomeworldClassicDiscovery(f), f.collector);
|
||||
const before = structuredClone(player(nested, f.collector));
|
||||
nested = shipHomeworldOptional(nested, f.collector, 'cistern', 0, 2, 0,
|
||||
{ 'homeworld:emperor': { normal: 1, elite: 0 }, 'homeworld:emperor:salusa': { normal: 1, elite: 0 } });
|
||||
assert.equal(player(nested, f.collector).spice, before.spice - 2);
|
||||
assert.equal(player(nested, f.collector).forces['cistern:0'], before.forces['cistern:0'] + 2);
|
||||
assert.equal(nested.techTokens!.heighliners.spice, 1);
|
||||
homeworldClassicDiscoveryInventory(nested);
|
||||
});
|
||||
|
||||
void test('real low-Caladan poverty claim and three free force returns trigger distinct phase-end industries without charging or recursively paying', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const setup = initializeHomeworldOptional({ advanced, tech: true, strongholds: advanced });
|
||||
let game = advance(setup, game => game.status === 'playing');
|
||||
// Select only original unplayed ordinary blows, avoiding incidental income
|
||||
// at the future expedition site. No phase, wallet or force is assigned.
|
||||
for (let position = 0; position < 4; position++) {
|
||||
const index = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card &&
|
||||
!card.discovery && card.territory !== 'imperial_basin');
|
||||
assert.ok(index >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
game = movement(game, 'emperor', 1);
|
||||
game = shipHomeworldOptional(game, 'emperor', 'habbanya_ridge_sietch', 17, 1);
|
||||
game = movement(game, 'atreides', 1);
|
||||
game = shipHomeworldOptional(game, 'atreides', 'habbanya_ridge_sietch', 17, 9);
|
||||
game = applyAction(game, 'atreides', { type: 'move', from: 'arrakeen:10', territory: 'imperial_basin', sector: 10, amount: 10 });
|
||||
assert.equal(player(game, 'atreides').spice, 1);
|
||||
assert.equal(player(game, 'atreides').reserves, 1);
|
||||
game = openHomeworldOptionalBattle(game, 'habbanya_ridge_sietch', 'atreides', 'emperor');
|
||||
game = applyAction(game, 'atreides', { type: 'battlePlan', leader: leader(game, 'atreides', false), dial: 0, support: 0 });
|
||||
game = applyAction(game, 'emperor', { type: 'battlePlan', leader: leader(game, 'emperor', true), dial: 0, support: 0 });
|
||||
game = finishHomeworldOptionalBattle(game);
|
||||
assert.equal(player(game, 'atreides').tanks, 9);
|
||||
game = advance(game, game => game.turn === 2 && game.phase === 2 && clean(game));
|
||||
assert.equal(player(game, 'atreides').spice, 1);
|
||||
assert.equal(viewGame(game, 'atreides').charity.amount, 2);
|
||||
const productionOwner = game.techTokens!.production.owner!, prodBefore = player(game, productionOwner).spice;
|
||||
game = applyAction(game, 'atreides', { type: 'charity' });
|
||||
assert.equal(player(game, 'atreides').spice, 3);
|
||||
assert.equal(player(game, productionOwner).spice, prodBefore);
|
||||
assert.equal(game.techTokens!.production.spice, ownedTech(game.techTokens, productionOwner).length);
|
||||
reject(game, 'atreides', { type: 'charity' });
|
||||
const beforeCharityEnd = advance(game, game => game.phase === 2 && clean(game) &&
|
||||
game.ready.length === game.players.length - 1);
|
||||
const productionIncome = beforeCharityEnd.techTokens!.production.spice;
|
||||
const productionBalance = player(beforeCharityEnd, productionOwner).spice;
|
||||
const afterCharity = advance(beforeCharityEnd, game => game.phase === 3 && clean(game));
|
||||
assert.equal(player(afterCharity, productionOwner).spice, productionBalance + productionIncome);
|
||||
assert.equal(afterCharity.techTokens!.production.spice, 0);
|
||||
game = advance(afterCharity, game => game.phase === 4 && clean(game));
|
||||
const returning = structuredClone(player(game, 'atreides'));
|
||||
assert.equal(forceRevivalQuote(game, returning, 3).cost, 0, 'Low home adds the third free counter to Atreides printed two.');
|
||||
legalPolicies(game, 'atreides');
|
||||
game = applyAction(game, 'atreides', { type: 'revive', amount: 3, elite: 0 });
|
||||
game = advance(game, game => !game.pendingRevival && clean(game));
|
||||
assert.equal(player(game, 'atreides').spice, returning.spice);
|
||||
assert.equal(player(game, 'atreides').tanks, returning.tanks - 3);
|
||||
assert.equal(player(game, 'atreides').reserves, returning.reserves + 3);
|
||||
const axlotlOwner = game.techTokens!.axlotl.owner!, accrued = game.techTokens!.axlotl.spice;
|
||||
assert.equal(accrued, ownedTech(game.techTokens, axlotlOwner).length);
|
||||
assert.equal(game.techTokens!.production.spice, 0);
|
||||
const beforeRevivalEnd = advance(game, game => game.phase === 4 && clean(game) && game.ready.length === game.players.length - 1);
|
||||
const axlotlBalance = player(beforeRevivalEnd, axlotlOwner).spice;
|
||||
const afterRevival = advance(beforeRevivalEnd, game => game.phase === 5 && clean(game));
|
||||
assert.equal(player(afterRevival, axlotlOwner).spice, axlotlBalance + accrued);
|
||||
assert.equal(afterRevival.techTokens!.axlotl.spice, 0);
|
||||
assert.equal(afterRevival.techTokens!.heighliners.spice, 0);
|
||||
homeworldClassicDiscoveryInventory(afterRevival);
|
||||
}
|
||||
});
|
||||
|
||||
void test('actually held Arrakeen subsidy and Carthag Shield apply at their printed Arrakis sites, never at native Homeworld or Cistern battles', () => {
|
||||
for (const kind of ['support', 'defense'] as const) for (const location of ['printed', 'homeworld', 'nested'] as const) {
|
||||
const f = fixture({ tech: true, strongholds: true });
|
||||
const owner = kind === 'support' ? 'atreides' : 'harkonnen';
|
||||
const printed = kind === 'support' ? 'arrakeen' : 'carthag';
|
||||
const territory = location === 'printed' ? printed : location === 'homeworld' ? `homeworld:${owner}` : 'cistern';
|
||||
let game = location === 'nested' ? enterHomeworldClassicDiscovery(f) : f.game;
|
||||
assert.equal(game.strongholdCards?.owners[printed], location === 'nested' ? owner : null);
|
||||
if (location === 'nested') {
|
||||
game = movement(game, owner);
|
||||
game = shipHomeworldOptional(game, owner, territory, 0, kind === 'support' ? 3 : 2);
|
||||
} else {
|
||||
game = movement(game, f.collector);
|
||||
assert.equal(game.strongholdCards!.owners[printed], owner);
|
||||
game = location === 'homeworld' ? invadeHomeworldOptional(game, f.collector, territory, 1)
|
||||
: shipHomeworldOptional(game, f.collector, territory, printed === 'arrakeen' ? 10 : 11, 1);
|
||||
}
|
||||
game = openHomeworldOptionalBattle(game, territory, owner, f.collector);
|
||||
const view = viewGame(game, owner), ownBefore = structuredClone(player(game, owner));
|
||||
assert.equal(view.battle!.strongholdEffects[owner], location === 'printed' ? printed : null);
|
||||
const trainer = leader(game, owner, true), invader = leader(game, f.collector, kind === 'defense');
|
||||
const defense = player(game, owner).hand.find(card => card.kind === 'shield')!; assert.ok(defense);
|
||||
const weapon = player(game, f.collector).hand.find(card => card.kind === 'poison')!; assert.ok(weapon);
|
||||
legalPolicies(game, owner);
|
||||
game = applyAction(game, owner, { type: 'battlePlan', leader: trainer, defense: defense.id,
|
||||
dial: kind === 'support' ? 2 : 0, support: kind === 'support' ? 2 : 0 });
|
||||
game = applyAction(game, f.collector, { type: 'battlePlan', leader: invader, dial: 0, support: 0,
|
||||
...(kind === 'defense' ? { weapon: weapon.id } : {}) });
|
||||
game = resolveToCleanup(game);
|
||||
assert.equal(game.lastBattleContext!.territory, territory);
|
||||
if (kind === 'support') {
|
||||
assert.equal(player(game, owner).spice, ownBefore.spice - (location === 'printed' ? 0 : 2));
|
||||
assert.equal(game.lastBattleContext!.winner, owner);
|
||||
} else {
|
||||
assert.equal(player(game, owner).leaders.find(disc => disc.id === trainer)!.dead, location !== 'printed');
|
||||
assert.equal(game.lastBattleContext!.winner, location === 'printed' ? owner : f.collector);
|
||||
assert.equal(game.discard.some(card => card.id === defense.id), location !== 'printed');
|
||||
}
|
||||
assert.equal(game.strongholdCards!.owners[printed], owner, 'Combat never silently transfers physical Stronghold card custody.');
|
||||
homeworldClassicDiscoveryInventory(game);
|
||||
}
|
||||
});
|
||||
|
||||
void test('real native Homeworld victory earns a second token, then real visitor victory offers original winner choice only after physical cleanup', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const f = fixture({ advanced, tech: true, strongholds: advanced });
|
||||
let game = movement(f.game, f.collector);
|
||||
game = invadeHomeworldOptional(game, f.collector, 'homeworld:fremen', 1);
|
||||
game = openHomeworldOptionalBattle(game, 'homeworld:fremen', f.collector, f.fremen);
|
||||
assert.equal(ownedTech(game.techTokens, f.collector).length, 1);
|
||||
const nativeBefore = player(game, f.fremen).reserves;
|
||||
game = applyAction(game, f.collector, { type: 'battlePlan', leader: leader(game, f.collector, false), dial: 0, support: 0 });
|
||||
game = applyAction(game, f.fremen, { type: 'battlePlan', leader: leader(game, f.fremen, true), dial: 0, support: 0 });
|
||||
game = finishHomeworldOptionalBattle(game);
|
||||
assert.equal(game.lastBattleContext!.winner, f.fremen);
|
||||
assert.equal(player(game, f.fremen).reserves, nativeBefore);
|
||||
assert.equal(player(game, f.collector).tanks, 1);
|
||||
assert.equal(ownedTech(game.techTokens, f.fremen).length, 2);
|
||||
game = movement(game, f.collector, 3);
|
||||
game = invadeHomeworldOptional(game, f.collector, 'homeworld:fremen', 2);
|
||||
game = openHomeworldOptionalBattle(game, 'homeworld:fremen', f.collector, f.fremen);
|
||||
const tokens = ownedTech(game.techTokens, f.fremen), originalTech = structuredClone(game.techTokens);
|
||||
const visitorBefore = structuredClone(player(game, f.collector));
|
||||
const nativeBeforeSecond = structuredClone(player(game, f.fremen));
|
||||
const weapon = visitorBefore.hand.find(card => card.kind === 'poison')!; assert.ok(weapon);
|
||||
game = applyAction(game, f.collector, { type: 'battlePlan', leader: leader(game, f.collector, true), dial: 1,
|
||||
support: advanced ? 1 : 0, weapon: weapon.id });
|
||||
game = applyAction(game, f.fremen, { type: 'battlePlan', leader: leader(game, f.fremen, false), dial: 0, support: 0 });
|
||||
const cleanup = resolveToCleanup(game);
|
||||
assert.equal(cleanup.lastBattleContext!.winner, f.collector);
|
||||
assert.deepEqual(cleanup.techTokens, originalTech);
|
||||
assert.equal(cleanup.decision!.player, f.collector);
|
||||
assert.equal(player(cleanup, f.collector).tanks, visitorBefore.tanks + 1);
|
||||
assert.equal(player(cleanup, f.fremen).tanks, nativeBeforeSecond.tanks + nativeBeforeSecond.reserves);
|
||||
game = applyAction(cleanup, f.collector, { type: 'decision', discard: [] });
|
||||
game = advance(game, game => game.decision?.kind === 'techToken');
|
||||
assert.ok(game.decision?.kind === 'techToken');
|
||||
assert.equal(game.decision.player, f.collector);
|
||||
assert.deepEqual([...game.decision.choices].sort(), [...tokens].sort());
|
||||
assert.deepEqual(game.techTokens, originalTech);
|
||||
assert.equal(player(game, f.collector).hand.some(card => card.id === weapon.id), true);
|
||||
const physicalHome = viewGame(game, f.collector).homeworlds!.worlds!.find(home => home.native === f.fremen)!;
|
||||
assert.deepEqual(physicalHome.forces[f.collector], { normal: 1, elite: 0 });
|
||||
assert.deepEqual(physicalHome.forces[f.fremen], { normal: 0, elite: 0 });
|
||||
legalPolicies(game, f.collector);
|
||||
for (const token of tokens) {
|
||||
reject(game, f.fremen, { type: 'decision', token });
|
||||
const chosen = applyAction(structuredClone(game), f.collector, { type: 'decision', token });
|
||||
assert.equal(chosen.techTokens![token].owner, f.collector);
|
||||
assert.equal(ownedTech(chosen.techTokens, f.fremen).length, 1);
|
||||
assert.equal(chosen.lastBattleContext!.winner, f.collector);
|
||||
const chosenHome = viewGame(chosen, f.collector).homeworlds!.worlds!.find(home => home.id === physicalHome.id)!;
|
||||
assert.deepEqual(chosenHome.forces, physicalHome.forces, 'Tech selection changes token custody, never the actual winner army.');
|
||||
homeworldClassicDiscoveryInventory(chosen);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
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 { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||
import { gameTerritories, validGameLocation } from '../game/board';
|
||||
import { discoveryEntryDeclineAction, discoveryEntryMoveAction } from '../game/discovery-entry-options';
|
||||
import { greatMakerRideAction } from '../game/great-maker-options';
|
||||
import { homeworldContext } from '../game/homeworld-game';
|
||||
import { homeworldForceGroups } from '../game/homeworld-custody';
|
||||
import type { HomeworldCombatLossContext } from '../game/homeworld-combat-loss';
|
||||
import { quoteSukPhysicalRescue } from '../game/suk-graduate';
|
||||
import {
|
||||
classicHomeworldSkillsBotOffers, createClassicHomeworldSkillsFixture,
|
||||
quoteClassicHomeworldSkillsBattle, revealClassicHomeworldSkillsBattle,
|
||||
settleClassicHomeworldSkillsBattle,
|
||||
} from './fixture-homeworld-classic-skills';
|
||||
import {
|
||||
advanceHomeworldSkillsDiscovery as advance, assertHomeworldSkillsDiscoveryInventory as inventory,
|
||||
createHomeworldSkillsDiscoveryBattleFixture as battleFixture, createHomeworldSkillsDiscoveryFixture as fixture,
|
||||
createHomeworldSkillsDiscoveryLobby as lobby, createHomeworldSkillsDiscoverySetup as setup,
|
||||
homeworldSkillsDiscoveryClean as clean, homeworldSkillsDiscoveryEntryWindow as entryWindow,
|
||||
homeworldSkillsDiscoveryMakerWindow as makerWindow, homeworldSkillsDiscoveryPlayer as player,
|
||||
homeworldSkillsDiscoveryRescueChoice as rescueChoice, homeworldSkillsDiscoverySplitReturn as splitReturn,
|
||||
revealHomeworldSkillsDiscovery as reveal, revealHomeworldSkillsDiscoveryBattle as revealBattle,
|
||||
settleHomeworldSkillsDiscoveryBattle as settle,
|
||||
} from './fixture-homeworld-skills-discovery';
|
||||
|
||||
/** Authorized root revision physical PDF21–23: original variants may compose.
|
||||
* Programmes select original undealt offers and unplayed Spice/token lotteries;
|
||||
* actions alone establish native withdrawals, casualties, reveal, entry and wins.
|
||||
* External Emperor split return remains the documented prototype placement
|
||||
* interpretation, not the PDF22 revival rule or a publisher allocation ruling. */
|
||||
function legalPolicies(game: Game, actor: string): void {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const offers = classicHomeworldSkillsBotOffers(game, actor, difficulty);
|
||||
assert.ok(offers.length, `${difficulty} needs an owned original continuation.`);
|
||||
inventory(applyAction(structuredClone(game), actor, offers[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 } };
|
||||
}) };
|
||||
}
|
||||
|
||||
void test('original combined Basic/Advanced two-through-six setup deals all14 before faction choices and preserves DS7/8 and original hands', () => {
|
||||
for (const advanced of [false, true]) for (const seats of [2, 3, 4, 5, 6] as const) {
|
||||
const f = setup({ advanced, seats }), physical = f.offered.leaderSkills!;
|
||||
assert.equal(f.afterSetup.players.length, seats);
|
||||
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());
|
||||
assert.equal(f.offered.setupStage, 'leaderSkills');
|
||||
assert.equal(f.offered.players.flatMap(p => p.traitors).length, 0);
|
||||
assert.equal(f.afterSetup.leaderSkills!.assignments.length, seats);
|
||||
for (const p of f.afterSetup.players) {
|
||||
assert.equal(p.hand.length, p.faction === 'harkonnen' ? 2 : 1);
|
||||
assert.ok(f.afterSetup.leaderSkills!.assignments.some(a => a.owner === p.id && p.leaders.some(l => l.id === a.leader && !l.dead)));
|
||||
}
|
||||
for (const p of f.offered.players) legalPolicies(f.offered, p.id);
|
||||
inventory(f.offered); inventory(f.afterSetup);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all fourteen original skills remain physically selectable through combined Emperor offers in both rules modes', () => {
|
||||
for (const advanced of [false, true]) for (const card of LEADER_SKILL_CARDS) {
|
||||
const f = setup({ advanced, seats: 2, skill: card.id });
|
||||
assert.equal(f.afterSetup.leaderSkills!.assignments.find(a => a.owner === 'emperor')!.skill, card.id);
|
||||
legalPolicies(f.offered, 'emperor'); inventory(f.afterSetup);
|
||||
}
|
||||
});
|
||||
|
||||
void test('real Maker losses precede actual storm-order majority or tie votes and a free typed native-reserve ride without losing training', () => {
|
||||
for (const advanced of [false, true]) for (const majority of [false, true]) {
|
||||
const f = fixture({ advanced, shipForMaker: true });
|
||||
const beforeArmy = player(f.game, f.owner), key = `${f.wormTerritory}:${f.wormSector}`;
|
||||
assert.equal(beforeArmy.forces[key], f.amount);
|
||||
assert.equal(beforeArmy.elites?.forces[key] ?? 0, f.elite);
|
||||
const originalTraining = structuredClone(f.game.leaderSkills!.assignments);
|
||||
const originalHomes = structuredClone(f.game.homeworlds!.custody);
|
||||
let game = makerWindow(f);
|
||||
const devoured = player(game, f.owner);
|
||||
assert.equal(game.turn, 2); assert.equal(game.phase, 1);
|
||||
assert.equal(game.greatMaker!.territory, f.wormTerritory);
|
||||
assert.equal(devoured.forces[key] ?? 0, 0);
|
||||
assert.equal(devoured.tanks, beforeArmy.tanks + f.amount);
|
||||
assert.equal(devoured.elites?.tanks ?? 0, (beforeArmy.elites?.tanks ?? 0) + f.elite);
|
||||
assert.equal(devoured.reserves, beforeArmy.reserves);
|
||||
assert.deepEqual(game.homeworlds!.custody, originalHomes);
|
||||
assert.deepEqual(game.leaderSkills!.assignments, originalTraining);
|
||||
assert.equal(game.discoveries!.tokens.find(t => t.id === f.token)!.revealedTurn, null);
|
||||
const order = [...game.order];
|
||||
for (const [index, actor] of order.entries()) {
|
||||
assert.ok(game.decision?.kind === 'greatMakerVote'); assert.equal(game.decision.player, actor);
|
||||
legalPolicies(game, actor);
|
||||
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, actor, vote);
|
||||
}
|
||||
assert.equal(game.nexus, majority, 'Three of four opens Nexus; two of four does not.');
|
||||
assert.ok(game.decision?.kind === 'greatMakerRide'); assert.equal(game.decision.player, f.fremen);
|
||||
legalPolicies(game, f.fremen);
|
||||
const view = viewGame(game, f.fremen), target = view.greatMaker!.ride!.destinations.find(d => d.territory === 'polar_sink'); assert.ok(target);
|
||||
const before = structuredClone(player(game, f.fremen)), training = structuredClone(game.leaderSkills!.assignments);
|
||||
const elite = advanced ? 1 : 0, action = greatMakerRideAction(view, target.territory, target.sector, 2, elite); assert.ok(action);
|
||||
reject(game, f.fremen, { ...action, event: 'stale-maker-ride' });
|
||||
reject(game, f.fremen, { ...action, territory: 'homeworld:fremen', sector: 0 });
|
||||
reject(game, f.fremen, { ...action, elite: 4 });
|
||||
game = applyAction(game, f.fremen, action);
|
||||
const after = player(game, f.fremen), destination = `${target.territory}:${target.sector}`;
|
||||
assert.equal(after.reserves, before.reserves - 2);
|
||||
assert.equal(after.elites?.reserves ?? 0, (before.elites?.reserves ?? 0) - elite);
|
||||
assert.equal(after.forces[destination], (before.forces[destination] ?? 0) + 2);
|
||||
assert.equal(after.elites?.forces[destination] ?? 0, (before.elites?.forces[destination] ?? 0) + elite);
|
||||
assert.equal(after.spice, before.spice); assert.equal(after.moved, before.moved); assert.equal(after.shipped, before.shipped);
|
||||
const native = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!).find(h => h.native === f.fremen)!;
|
||||
assert.deepEqual(native.forces[f.fremen], { normal: after.reserves - (after.elites?.reserves ?? 0), elite: after.elites?.reserves ?? 0 });
|
||||
assert.deepEqual(game.leaderSkills!.assignments, training); inventory(game);
|
||||
}
|
||||
});
|
||||
|
||||
void test('actual inspection/reveal and later entry move only real parent forces, retaining native custody, training and normal movement allowances', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const f = fixture({ advanced }), revealed = reveal(f);
|
||||
assert.equal(validGameLocation(f.game, f.face, 0), false);
|
||||
assert.equal(validGameLocation(revealed, f.face, 0), true);
|
||||
assert.equal(revealed.discoveries!.tokens.find(t => t.id === f.token)!.revealedTurn, 1);
|
||||
assert.equal(player(revealed, f.owner).forces[`${f.face}:0`] ?? 0, 0, 'Reveal itself is not physical entry.');
|
||||
const game = entryWindow(f), view = viewGame(game, f.owner), sources = view.discoveryEntry!.sources;
|
||||
assert.equal(game.turn, 2); assert.equal(game.phase, 0);
|
||||
assert.deepEqual(sources, [{ source: `${f.parent}:${f.parentSector}`, normal: f.amount - f.elite, elite: f.elite }]);
|
||||
legalPolicies(game, f.owner);
|
||||
const action = discoveryEntryMoveAction(view, sources), decline = discoveryEntryDeclineAction(view); assert.ok(action && decline);
|
||||
const before = structuredClone(player(game, f.owner)), custody = structuredClone(game.homeworlds!.custody), training = structuredClone(game.leaderSkills!.assignments);
|
||||
assert.deepEqual(player(applyAction(structuredClone(game), f.owner, decline), f.owner).forces, before.forces);
|
||||
reject(game, f.owner, { ...action, event: 'stale-discovery-entry' });
|
||||
reject(game, f.fremen, action);
|
||||
for (const source of ['homeworld:emperor', 'homeworld:emperor:salusa', `${f.face}:0`]) {
|
||||
const groups = [{ source, normal: 1, elite: 0 }];
|
||||
assert.equal(discoveryEntryMoveAction(view, groups), null); reject(game, f.owner, { ...action, groups });
|
||||
}
|
||||
const entered = applyAction(JSON.parse(JSON.stringify(game)), f.owner, action), after = player(entered, f.owner);
|
||||
assert.equal(after.forces[`${f.face}:0`], f.amount); assert.equal(after.elites?.forces[`${f.face}:0`] ?? 0, f.elite);
|
||||
assert.equal(after.forces[`${f.parent}:${f.parentSector}`] ?? 0, 0);
|
||||
assert.deepEqual(entered.homeworlds!.custody, custody); assert.deepEqual(entered.leaderSkills!.assignments, training);
|
||||
assert.equal(after.reserves, before.reserves); assert.equal(after.elites?.reserves, before.elites?.reserves);
|
||||
assert.equal(after.spice, before.spice); assert.equal(after.moved, before.moved); assert.equal(after.shipped, before.shipped);
|
||||
assert.ok(gameTerritories(entered).every(t => !t.id.startsWith('homeworld:'))); inventory(entered);
|
||||
}
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} actual trained Suk nested win has no native bonus and returns only typed rescued pieces to real homes`, () => {
|
||||
const f = battleFixture({ advanced });
|
||||
const owner = f.owner, opponent = f.opponent!;
|
||||
const reinforcedBefore = player(f.beforeReinforcement, owner), reinforcedAfter = player(f.afterReinforcement, owner), reinforcement = advanced ? 2 : 3;
|
||||
assert.equal(reinforcedAfter.spice, reinforcedBefore.spice - reinforcement, 'One spice per real nested reinforcement, not a Homeworld invasion price.');
|
||||
assert.equal(reinforcedAfter.reserves, reinforcedBefore.reserves - reinforcement);
|
||||
assert.equal(reinforcedAfter.forces[`${f.face}:0`], 5);
|
||||
assert.equal(player(f.game, opponent).forces[`${f.face}:0`], 2);
|
||||
for (const actor of [owner, opponent]) legalPolicies(f.game, actor);
|
||||
const revealed = revealBattle(f), quote = quoteClassicHomeworldSkillsBattle(revealed), own = revealed.battle!.attacker === owner ? 'attacker' : 'defender';
|
||||
assert.equal(quote.winner, owner);
|
||||
assert.equal(quote.scores![own], 4 + player(revealed, owner).leaders.find(l => l.id === f.trainer)!.strength);
|
||||
assert.equal(quote.leaderSkillBonuses[own].bonus, 0, 'Suk rescue is not native Homeworld strength or a battle-strength skill.');
|
||||
assert.deepEqual(viewGame(revealed, owner).battle!.traitorVoters.sort(), [owner, opponent].sort(), 'Nested Arrakis battle retains both ordinary traitor-call owners.');
|
||||
const pending = settle(revealed, 'suk'); assert.ok(pending.decision?.kind === 'sukRescue');
|
||||
const receipt = pending.pendingSukRescue!, choice = rescueChoice(pending), option = pending.decision.options[choice], destinations = splitReturn(pending, choice);
|
||||
assert.equal(receipt.territory, f.face); assert.equal(receipt.skill.mode, 'skilled');
|
||||
assert.deepEqual(receipt.pool, [{ key: `${f.face}:0`, normal: advanced ? 4 : 5, elite: advanced ? 1 : 0 }]);
|
||||
assert.equal(receipt.losses!.normal, advanced ? 2 : 4); assert.equal(receipt.losses!.elite, advanced ? 1 : 0);
|
||||
assert.equal(option.normal + option.elite, 3); assert.deepEqual(option.kept, { key: `${f.face}:0`, kind: 'normal' });
|
||||
legalPolicies(pending, owner);
|
||||
const action: Action = { type: 'decision', event: pending.decision.event, choice, ...(destinations ? { destinations } : {}) };
|
||||
reject(pending, owner, { ...action, event: 'stale-suk-rescue' });
|
||||
reject(pending, opponent, action);
|
||||
reject(pending, owner, { ...action, destinations: { [`${f.face}:0`]: { normal: 2, elite: 0 } } });
|
||||
reject(pending, owner, { ...action, destinations: { 'homeworld:guild': { normal: 2, elite: 0 } } });
|
||||
if (advanced) {
|
||||
assert.equal(pending.decision.reserveHomes?.length, 2);
|
||||
assert.deepEqual(destinations, { 'homeworld:emperor': { normal: 0, elite: 1 }, 'homeworld:emperor:salusa': { normal: 1, elite: 0 } });
|
||||
reject(pending, owner, { type: 'decision', event: pending.decision.event, choice });
|
||||
reject(pending, owner, { ...action, destinations: { 'homeworld:emperor': { normal: 1, elite: 0 } } });
|
||||
reject(pending, owner, { ...action, destinations: { 'homeworld:emperor': { normal: 0, elite: 2 }, 'homeworld:emperor:salusa': { normal: 0, elite: 0 } } });
|
||||
}
|
||||
const physical = quoteSukPhysicalRescue(physicalContext(pending), pending.homeworlds!.custody!, {
|
||||
player: owner, territory: f.face, skill: receipt.skill, pool: receipt.pool, losses: receipt.losses!, option, destinations,
|
||||
});
|
||||
assert.equal(physical.receipt.source, 'arrakis');
|
||||
const before = structuredClone(player(pending, owner)), homeBefore = homeworldForceGroups(homeworldContext(pending), pending.homeworlds!.custody!);
|
||||
const done = settle(applyAction(JSON.parse(JSON.stringify(pending)), owner, action)), after = player(done, owner);
|
||||
assert.equal(done.lastBattleContext!.winner, owner);
|
||||
assert.equal(after.forces[`${f.face}:0`], advanced ? 3 : 2, 'Only undialed pieces plus the one physically kept ordinary counter remain.');
|
||||
assert.equal(after.elites?.forces[`${f.face}:0`] ?? 0, 0);
|
||||
assert.equal(after.reserves, before.reserves + 2); assert.equal(after.elites?.reserves ?? 0, (before.elites?.reserves ?? 0) + (advanced ? 1 : 0));
|
||||
assert.equal(after.tanks, before.tanks + (advanced ? 0 : 1));
|
||||
assert.equal(before.spice, reinforcedAfter.spice - (advanced ? 2 : 0), 'Actual support is paid before rescue and independent Collection.');
|
||||
assert.equal(player(done, opponent).forces[`${f.face}:0`] ?? 0, 0);
|
||||
assert.equal(player(done, opponent).tanks, player(f.game, opponent).tanks + 2);
|
||||
assert.deepEqual(done.homeworlds!.custody, physical.custody);
|
||||
const homeAfter = homeworldForceGroups(homeworldContext(done), done.homeworlds!.custody!);
|
||||
if (advanced) for (const home of homeBefore.filter(h => h.native === owner)) {
|
||||
const afterHome = homeAfter.find(h => h.id === home.id)!, returned = destinations![home.id];
|
||||
assert.equal(afterHome.forces[owner].normal, home.forces[owner].normal + returned.normal);
|
||||
assert.equal(afterHome.forces[owner].elite, home.forces[owner].elite + returned.elite);
|
||||
}
|
||||
assert.equal(player(done, owner).leaders.find(l => l.id === f.trainer)!.dead, false);
|
||||
assert.ok(done.leaderSkills!.assignments.some(a => a.owner === owner && a.leader === f.trainer && a.skill === 'suk-graduate'));
|
||||
assert.equal(done.pendingSukRescue ?? null, null); inventory(done);
|
||||
const later = advance(done, g => g.phase === 7 && clean(g));
|
||||
assert.equal(player(later, owner).spice, before.spice + 2, 'The surviving nested army receives printed Cistern income, not a support refund.');
|
||||
assert.equal(player(later, owner).forces[`${f.face}:0`], after.forces[`${f.face}:0`]); inventory(later);
|
||||
});
|
||||
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} original native and visitor Homeworld Suk rescues still use their proper physical pools with Discovery retained`, () => {
|
||||
for (const kind of ['native-suk', 'visitor-suk'] as const) {
|
||||
const initial = lobby({ advanced, roster: ['emperor', 'guild', 'atreides', 'harkonnen'] });
|
||||
const f = createClassicHomeworldSkillsFixture({ initial, advanced, kind }), pending = settleClassicHomeworldSkillsBattle(revealClassicHomeworldSkillsBattle(f), 'suk');
|
||||
assert.ok(pending.decision?.kind === 'sukRescue');
|
||||
const choice = rescueChoice(pending, kind === 'native-suk' && advanced ? 'elite' : 'normal'), option = pending.decision.options[choice], receipt = pending.pendingSukRescue!;
|
||||
const destinations = splitReturn(pending, choice);
|
||||
if (kind === 'native-suk') {
|
||||
assert.equal(pending.decision.reserveHomes, undefined);
|
||||
reject(pending, f.owner, { type: 'decision', event: pending.decision.event, choice, destinations: { 'homeworld:emperor': { normal: 1, elite: 0 } } });
|
||||
}
|
||||
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 done = settleClassicHomeworldSkillsBattle(applyAction(pending, f.owner,
|
||||
{ type: 'decision', event: pending.decision.event, choice, ...(destinations ? { destinations } : {}) }));
|
||||
assert.deepEqual(done.homeworlds!.custody, physical.custody);
|
||||
if (kind === 'native-suk' && advanced) assert.deepEqual(done.homeworlds!.custody!.salusa, pending.homeworlds!.custody!.salusa, 'Saved native Sardaukar never leave their original Salusa pool.');
|
||||
assert.ok(done.leaderSkills!.assignments.some(a => a.leader === f.trainer && a.skill === 'suk-graduate'));
|
||||
inventory(done);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user