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