Compose native Homeworld skill and Discovery modules
This commit is contained in:
1 parent
a8758bc6b5
commit
3fb047c93d
23 files changed
+1732
-107
No files matched your search
@@ -45,10 +45,9 @@ void test('Discovery cannot redeal started components or silently remove another
|
||||
const initial = lobby(['ixians', 'tleilaxu', 'guild'], ['ix']);
|
||||
const started = initializeDiscoveryGameForAudit(initial);
|
||||
assert.throws(() => initializeDiscoveryGameForAudit(started), RuleError);
|
||||
const overlays: Game[] = [structuredClone(initial), structuredClone(initial), structuredClone(initial)];
|
||||
overlays[0].homeworlds = { custody: null };
|
||||
overlays[1].nexusCards = { cards: null, phase: null };
|
||||
overlays[2].advanced = false; overlays[2].strongholdCards = createStrongholdCards();
|
||||
const overlays: Game[] = [structuredClone(initial), structuredClone(initial)];
|
||||
overlays[0].nexusCards = { cards: null, phase: null };
|
||||
overlays[1].advanced = false; overlays[1].strongholdCards = createStrongholdCards();
|
||||
for (const game of overlays) assert.throws(() => initializeDiscoveryGameForAudit(game), RuleError);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
applyAction, createGame, initializeDiscoveryGameForAudit, joinGame, newPlayer,
|
||||
viewGame, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { classicHomeworldDiscoveryProfile } from '../game/discovery-module-profile';
|
||||
import { homeworldDiscoveryProfile } 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';
|
||||
@@ -42,6 +42,8 @@ export const homeworldClassicDiscoveryPlayer = (game: Game, actor: string) => {
|
||||
export function nextHomeworldClassicDiscoveryStep(game: Game): HomeworldClassicDiscoveryStep {
|
||||
if (!game.phaseOpening && !game.response) {
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'homeworldShipmentGuild')
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event, allow: true } };
|
||||
if (decision?.kind === 'discoveryEntry' || decision?.kind === 'greatMakerRide')
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event, accept: false } };
|
||||
if (decision?.kind === 'greatMakerVote')
|
||||
@@ -118,7 +120,7 @@ export function createHomeworldClassicDiscoveryFixture(options: HomeworldClassic
|
||||
game.discoveryEnabled = true;
|
||||
}
|
||||
assert.ok(game.status === 'lobby' || (game.status === 'setup' && game.turn === 1 && game.phase === 0));
|
||||
assert.ok(classicHomeworldDiscoveryProfile(game));
|
||||
assert.ok(homeworldDiscoveryProfile(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);
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeDiscoveryGameForAudit, initializeLeaderSkillsGameForAudit,
|
||||
joinGame, newPlayer, viewGame, type Action, type Game, type Player,
|
||||
} from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { treacheryDeck, type Card, type SpiceCard } from '../game/cards';
|
||||
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 { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
||||
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_TOKEN_BY_ID, validateDiscoveryState,
|
||||
type DiscoveryLocationId, type DiscoveryOpaqueTokenId } from '../game/discoveries';
|
||||
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
|
||||
import { nextDiscoveryNativeE2SkillsStep } from './fixture-discovery-native-e2-skills';
|
||||
import { nextHomeworldSkillsDiscoveryStep } from './fixture-homeworld-skills-discovery';
|
||||
|
||||
export type NativeHomeworldModuleStep = { actor: string; action: Action };
|
||||
export type NativeHomeworldModuleOptions = {
|
||||
advanced?: boolean;
|
||||
expansions?: readonly string[];
|
||||
roster?: readonly FactionId[];
|
||||
skills?: boolean;
|
||||
discovery?: boolean;
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
skill?: LeaderSkillId;
|
||||
/** Select original undealt deck faces, never a held hand or physical owner. */
|
||||
startingKinds?: readonly Card['kind'][];
|
||||
};
|
||||
export type NativeHomeworldModuleSetup = {
|
||||
initial: Game;
|
||||
offered: Game | null;
|
||||
afterSetup: Game;
|
||||
actions: NativeHomeworldModuleStep[];
|
||||
};
|
||||
export type NativeHomeworldSukProgramme = NativeHomeworldModuleSetup & {
|
||||
game: Game;
|
||||
beforeShipment: Game;
|
||||
afterShipment: Game;
|
||||
owner: string;
|
||||
opponent: string;
|
||||
trainer: string;
|
||||
territory: string;
|
||||
band: 'normal' | 'skilled';
|
||||
plans: NativeHomeworldModuleStep[];
|
||||
};
|
||||
export type NativeHomeworldDiscoveryProgramme = NativeHomeworldModuleSetup & {
|
||||
game: Game;
|
||||
owner: string;
|
||||
token: DiscoveryOpaqueTokenId;
|
||||
face: DiscoveryLocationId;
|
||||
parent: string;
|
||||
parentSector: number;
|
||||
beforeShipment: Game;
|
||||
afterShipment: Game;
|
||||
};
|
||||
export const nativeHomeworldClean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
|
||||
export function nativeHomeworldPlayer(game: Game, actor: string): Player {
|
||||
const player = game.players.find(p => p.id === actor); assert.ok(player); return player;
|
||||
}
|
||||
export function nativeHomeworldArmy(game: Game, actor: string, location: string) {
|
||||
const home = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!).find(h => h.id === location);
|
||||
assert.ok(home); return home.forces[actor] ?? { normal: 0, elite: 0 };
|
||||
}
|
||||
export function nativeHomeworldInventory(game: Game): void {
|
||||
homeworldGameIntegrity(game);
|
||||
if (game.leaderSkills) validateLeaderSkills(game.leaderSkills, game.players);
|
||||
if (game.discoveries) validateDiscoveryState(game.discoveries);
|
||||
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
|
||||
...(game.ixSetupCards ?? []), ...(game.auction?.cards.slice(game.auction.index) ?? [])];
|
||||
const ordinary = cards.filter(c => !c.id.startsWith('richese-'));
|
||||
assert.deepEqual(ordinary.map(c => c.id).sort(), treacheryDeck(game.expansions).map(c => c.id).sort());
|
||||
const homes = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!);
|
||||
for (const p of game.players) {
|
||||
const visitors = homes.filter(h => h.native !== p.id).map(h => h.forces[p.id] ?? { normal: 0, elite: 0 });
|
||||
const board = Object.values(p.forces).reduce((n, count) => n + count, 0);
|
||||
const visitorCount = visitors.reduce((n, group) => n + group.normal + group.elite, 0);
|
||||
assert.equal(p.reserves + p.tanks + board + visitorCount, 20, p.faction);
|
||||
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks +
|
||||
Object.values(p.elites.forces).reduce((n, count) => n + count, 0) + visitors.reduce((n, g) => n + g.elite, 0),
|
||||
p.faction === 'ixians' ? 7 : p.faction === 'emperor' ? 5 : 3, p.faction);
|
||||
}
|
||||
}
|
||||
function permutationDraws<T>(source: readonly T[], desired: readonly T[]): number[] {
|
||||
const current = [...source], rolls: number[] = [];
|
||||
for (let i = current.length - 1; i > 0; i--) {
|
||||
const j = current.indexOf(desired[i]); assert.ok(j >= 0 && j <= i);
|
||||
rolls.push(Math.floor((j + 0.5) / (i + 1) * 0x100000000));
|
||||
[current[i], current[j]] = [current[j], current[i]];
|
||||
}
|
||||
assert.deepEqual(current, desired); return rolls;
|
||||
}
|
||||
/** Entropy selection is confined to original setup or an unused supply lottery.
|
||||
* Every offer, hand, traitor, Face Dancer and native inventory is engine-created. */
|
||||
function originalLottery<T>(rolls: readonly number[], operation: () => T): T {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const native = crypto.getRandomValues.bind(crypto); let draw = 0;
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[draw++] ?? 0xffffffff;
|
||||
else Reflect.apply(native, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return operation(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
export function nextNativeHomeworldModuleStep(game: Game, hideOwner = 'ixians'): NativeHomeworldModuleStep {
|
||||
if (!game.response && !game.phaseOpening && game.decision) {
|
||||
const d = game.decision;
|
||||
if (d.kind === 'discoveryEntry' || d.kind === 'greatMakerRide')
|
||||
return { actor: d.player, action: { type: 'decision', event: d.event, accept: false } };
|
||||
if (d.kind === 'greatMakerVote')
|
||||
return { actor: d.player, action: { type: 'decision', event: d.event, yes: false } };
|
||||
if (d.kind === 'leaderSkillVisibility') return { actor: d.player, action: {
|
||||
type: 'leaderSkillVisibility', event: d.event, hide: d.player === hideOwner,
|
||||
} };
|
||||
if (d.kind === 'faceDance') return { actor: d.player, action: { type: 'decision', reveal: false } };
|
||||
if (d.kind === 'ixSubstitution') return { actor: d.player, action: { type: 'decision', decline: true } };
|
||||
if (d.kind === 'sukRescue') throw Error('Choose the original physical Suk rescue explicitly.');
|
||||
if (d.kind === 'battleLosses' && game.pendingSukRescue) {
|
||||
const maximum = Math.max(...d.options.map(o => o.elite));
|
||||
return { actor: d.player, action: { type: 'decision', choice: d.options.findIndex(o => o.elite === maximum) } };
|
||||
}
|
||||
if (d.kind === 'techToken') return { actor: d.player, action: { type: 'decision', token: d.choices[0] } };
|
||||
}
|
||||
if (game.players.some(p => p.faction === 'richese')) return nextDiscoveryNativeE2SkillsStep(game, true);
|
||||
const next = nextHomeworldSkillsDiscoveryStep(game); assert.ok(next, 'Original sealed plans are explicit.'); return next;
|
||||
}
|
||||
export function actNativeHomeworld(game: Game, actor: string, action: Action, actions?: NativeHomeworldModuleStep[]): Game {
|
||||
actions?.push({ actor, action: structuredClone(action) }); return applyAction(game, actor, action);
|
||||
}
|
||||
export function advanceNativeHomeworld(state: Game, until: (game: Game) => boolean,
|
||||
actions?: NativeHomeworldModuleStep[], hideOwner = 'ixians'): Game {
|
||||
let game = state;
|
||||
for (let count = 0; count < 2000; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextNativeHomeworldModuleStep(game, hideOwner); game = actNativeHomeworld(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('Original native Homeworld programme did not reach its requested window.');
|
||||
}
|
||||
export function createNativeHomeworldModuleSetup(options: NativeHomeworldModuleOptions = {}): NativeHomeworldModuleSetup {
|
||||
const roster = options.roster ?? ['ixians', 'tleilaxu', 'guild', 'fremen'];
|
||||
const skills = options.skills ?? true, discovery = options.discovery ?? true;
|
||||
assert.ok(skills || discovery);
|
||||
let game = createGame('NATIVEHWMODULES', newPlayer(roster[0], roster[0], roster[0]), options.advanced ?? false,
|
||||
[...(options.expansions ?? ['ix'])]);
|
||||
for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
|
||||
game = applyAction(game, game.host, { type: 'homeworlds', enabled: true });
|
||||
if (options.tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
|
||||
if (options.strongholds) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true });
|
||||
if (discovery) game.discoveryEnabled = true;
|
||||
for (const p of game.players) game = applyAction(game, p.id, { type: 'ready' });
|
||||
const initial = structuredClone(game), actions: NativeHomeworldModuleStep[] = [];
|
||||
const selected: LeaderSkillId[] = [options.skill ?? 'suk-graduate'];
|
||||
const passive: LeaderSkillId[] = ['prana-bindu-adept', 'swordmaster-of-ginaz', 'killer-medic', 'warmaster', 'sandmaster'];
|
||||
for (let i = 1; i < roster.length; i++) selected.push(passive.find(id => !selected.includes(id))!);
|
||||
const canonical = LEADER_SKILL_CARDS.map(c => c.id), rest = canonical.filter(id => !selected.includes(id));
|
||||
const desired = selected.flatMap(id => [id, rest.shift()!]).concat(rest);
|
||||
const cards = treacheryDeck(game.expansions), remaining = [...cards];
|
||||
const prefix = (options.startingKinds ?? roster.map((_, i) => i < 5 ? 'worthless' as const : 'shield' as const)).map(kind => {
|
||||
const index = remaining.findIndex(c => c.kind === kind); assert.ok(index >= 0); return remaining.splice(index, 1)[0];
|
||||
});
|
||||
const rolls = [...(skills ? permutationDraws(canonical, desired) : []),
|
||||
...(discovery ? Array.from({ length: 7 }, () => 0xffffffff) : []),
|
||||
...permutationDraws(cards.map(c => c.id), [...prefix, ...remaining].map(c => c.id))];
|
||||
game = originalLottery(rolls, () => skills ? initializeLeaderSkillsGameForAudit(game) : initializeDiscoveryGameForAudit(game));
|
||||
let offered: Game | null = null;
|
||||
for (let count = 0; game.status === 'setup' && count < 250; count++) {
|
||||
let step: NativeHomeworldModuleStep;
|
||||
if (game.setupStage === 'leaderSkills' && !game.decision) {
|
||||
offered ??= structuredClone(game);
|
||||
const actor = Object.keys(game.leaderSkills!.offers)[0], view = viewGame(game, actor).leaderSkills!;
|
||||
const skill = selected[game.players.findIndex(p => p.id === actor)], offer = view.offer!;
|
||||
assert.ok(offer.cards.includes(skill) && !view.unavailableSkills?.[skill]);
|
||||
const eligible = new Set(view.eligibleLeaders.map(l => l.id));
|
||||
const leader = nativeHomeworldPlayer(game, actor).leaders.filter(l => eligible.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
|
||||
assert.ok(leader); step = { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } };
|
||||
} else if (game.decision?.kind === 'ixSetup') {
|
||||
const actor = game.decision.player, card = viewGame(game, actor).ixTechnology!.setup!.find(c => c.kind === 'worthless') ?? viewGame(game, actor).ixTechnology!.setup![0];
|
||||
step = { actor, action: { type: 'decision', card: card.id } };
|
||||
} else if (game.setupStage === 'traitors' && game.players.some(p => p.traitorChoices.length)) {
|
||||
const p = game.players.find(p => p.traitorChoices.length)!;
|
||||
const leader = p.traitorChoices.find(id => !['ixians-0', 'ixians-1', 'ixians-2'].includes(id)) ?? p.traitorChoices[0];
|
||||
step = { actor: p.id, action: { type: 'traitor', leader } };
|
||||
} else step = nextNativeHomeworldModuleStep(game);
|
||||
game = originalLottery([], () => actNativeHomeworld(game, step.actor, step.action, actions));
|
||||
}
|
||||
assert.equal(game.status, 'playing'); nativeHomeworldInventory(game);
|
||||
return { initial, offered, afterSetup: structuredClone(game), actions };
|
||||
}
|
||||
export function nativeHomeworldMovement(state: Game, actor: string, turn = 1): Game {
|
||||
return advanceNativeHomeworld(state, game => game.turn === turn && game.phase === 5 && game.active === actor && nativeHomeworldClean(game));
|
||||
}
|
||||
export function settleNativeHomeworldArrival(state: Game): Game {
|
||||
return advanceNativeHomeworld(state, game => nativeHomeworldClean(game) && !game.pendingShipment && !game.pendingHomeworldShipment);
|
||||
}
|
||||
export function invadeNativeHomeworld(game: Game, actor: string, destination: string, normal: number, elite = 0): Game {
|
||||
const sources = nativeShipmentSources(viewGame(game, actor), normal + elite, elite); assert.ok(sources);
|
||||
const choice = homeworldShipmentChoice(viewGame(game, actor), destination,
|
||||
Object.fromEntries(Object.entries(sources).filter(([, g]) => g.normal + g.elite > 0)));
|
||||
assert.ok(choice.action, choice.blocked ?? undefined);
|
||||
return settleNativeHomeworldArrival(applyAction(game, actor, choice.action));
|
||||
}
|
||||
export function openNativeHomeworldBattle(state: Game, first: string, second: string, territory: string, hideOwner = 'ixians'): Game {
|
||||
let game = advanceNativeHomeworld(state, g => g.phase === 6 && nativeHomeworldClean(g) && !!g.active, undefined, hideOwner);
|
||||
const actor = game.active!; assert.ok(actor === first || actor === second);
|
||||
game = applyAction(game, actor, { type: 'chooseBattle', territory, target: actor === first ? second : first });
|
||||
return advanceNativeHomeworld(game, g => !!g.battle && nativeHomeworldClean(g) && !g.battle.preparation && g.battle.preLeader?.closed !== false, undefined, hideOwner);
|
||||
}
|
||||
export function nativeHomeworldLeader(game: Game, actor: string, strongest = false): string {
|
||||
const trained = new Set(game.leaderSkills?.assignments.map(a => a.leader));
|
||||
const leader = nativeHomeworldPlayer(game, actor).leaders.filter(l => !l.dead && !trained.has(l.id) && !l.usedAt)
|
||||
.sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength)[0]; assert.ok(leader); return leader.id;
|
||||
}
|
||||
export function createNativeHomeworldSukProgramme(options: NativeHomeworldModuleOptions & {
|
||||
visitor?: boolean; band?: 'normal' | 'skilled';
|
||||
} = {}): NativeHomeworldSukProgramme {
|
||||
const setup = createNativeHomeworldModuleSetup({ ...options, skill: 'suk-graduate' });
|
||||
const owner = 'ixians', opponent = 'guild', band = options.band ?? 'skilled';
|
||||
const territory = options.visitor ? 'homeworld:guild' : 'homeworld:ixians';
|
||||
const actor = options.visitor ? owner : opponent;
|
||||
let game = nativeHomeworldMovement(setup.afterSetup, actor);
|
||||
const beforeShipment = structuredClone(game);
|
||||
game = invadeNativeHomeworld(game, actor, territory, options.visitor ? 4 : 3, options.visitor ? 3 : 0);
|
||||
const afterShipment = structuredClone(game);
|
||||
game = openNativeHomeworldBattle(game, owner, opponent, territory, band === 'skilled' ? owner : '');
|
||||
const trainer = game.leaderSkills!.assignments.find(a => a.owner === owner)!.leader;
|
||||
// The real visibility choice must be completed before either sealed plan.
|
||||
if (game.battle!.leaderSkillHidden?.[owner] !== (band === 'skilled')) {
|
||||
throw Error('Native continuation must select the requested Suk visibility before plans.');
|
||||
}
|
||||
return { ...setup, game, beforeShipment, afterShipment, owner, opponent, trainer, territory, band,
|
||||
plans: [{ actor: owner, action: { type: 'battlePlan', leader: band === 'skilled' ? trainer : nativeHomeworldLeader(game, owner, true),
|
||||
dial: game.advanced ? 3 : 6, support: 0, weapon: nativeHomeworldPlayer(game, owner).hand.find(c => c.kind === 'worthless')!.id } },
|
||||
{ actor: opponent, action: { type: 'battlePlan', leader: nativeHomeworldLeader(game, opponent), dial: 0, support: 0 } }] };
|
||||
}
|
||||
export function revealNativeHomeworldPlans(state: Game, plans: NativeHomeworldModuleStep[]): Game {
|
||||
let game = state;
|
||||
for (const plan of plans) {
|
||||
game = applyAction(game, plan.actor, plan.action);
|
||||
if (!game.battle?.revealed) game = advanceNativeHomeworld(game, g => !!g.battle && nativeHomeworldClean(g) && !g.battle.preparation);
|
||||
}
|
||||
assert.ok(game.battle?.revealed); return game;
|
||||
}
|
||||
export function frontNativeHomeworldSpice(game: Game, choose: (card: SpiceCard) => boolean, position: number): void {
|
||||
const index = game.spiceDeck.findIndex((c, i) => i >= position && choose(c)); assert.ok(index >= position);
|
||||
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
export function createNativeHomeworldDiscoveryProgramme(options: NativeHomeworldModuleOptions & {
|
||||
face?: DiscoveryLocationId; collector?: string; maker?: boolean;
|
||||
} = {}): NativeHomeworldDiscoveryProgramme {
|
||||
const setup = createNativeHomeworldModuleSetup({ ...options, discovery: true });
|
||||
let game = structuredClone(setup.afterSetup);
|
||||
const owner = options.collector ?? game.players[0].id, face = options.face ?? 'cistern';
|
||||
const card = DISCOVERY_TOKEN_BY_ID[face].type === 'hiereg' ? 'discovery-hagga-basin' : 'discovery-wind-pass-north';
|
||||
const placement = DISCOVERY_CARD_PLACEMENTS[card];
|
||||
frontNativeHomeworldSpice(game, c => 'territory' in c && c.discovery === card, 0);
|
||||
const index = game.advanced ? 2 : 1;
|
||||
if (game.advanced) frontNativeHomeworldSpice(game, c => 'territory' in c && !c.discovery, 1);
|
||||
if (options.maker) frontNativeHomeworldSpice(game, c => 'worm' in c && !!c.greatMaker, index);
|
||||
for (let i = index + (options.maker ? 1 : 0); i < index + 3; i++)
|
||||
frontNativeHomeworldSpice(game, c => 'territory' in c && !c.discovery, i);
|
||||
game = advanceNativeHomeworld(game, g => g.phase === 1 && nativeHomeworldClean(g), setup.actions);
|
||||
const pool = game.discoveries!.tokens.filter(t => t.status === 'supply' && t.type === DISCOVERY_TOKEN_BY_ID[face].type);
|
||||
const selected = pool.findIndex(t => t.face === face); assert.ok(selected >= 0);
|
||||
game = originalLottery([Math.floor((selected + 0.5) / pool.length * 0x100000000)], () =>
|
||||
advanceNativeHomeworld(game, g => g.discoveries!.tokens.some(t => t.face === face && t.status === 'placed'), setup.actions));
|
||||
game = nativeHomeworldMovement(game, owner);
|
||||
const beforeShipment = structuredClone(game), sources = nativeShipmentSources(viewGame(game, owner), 2, owner === 'ixians' ? 1 : 0); assert.ok(sources);
|
||||
game = settleNativeHomeworldArrival(applyAction(game, owner, { type: 'ship', territory: placement.territory,
|
||||
sector: placement.sector, amount: 2, elite: owner === 'ixians' ? 1 : 0, homeworldSources: sources, allyPayment: 0 }));
|
||||
const afterShipment = structuredClone(game);
|
||||
game = advanceNativeHomeworld(game, g => g.phase === 7 && nativeHomeworldClean(g));
|
||||
const token = game.discoveries!.tokens.find(t => t.face === face && t.status === 'placed')!; assert.ok(token);
|
||||
return { ...setup, game, owner, token: token.id, face, parent: placement.territory, parentSector: placement.sector, beforeShipment, afterShipment };
|
||||
}
|
||||
export function revealNativeHomeworldDiscovery(f: NativeHomeworldDiscoveryProgramme): Game {
|
||||
let game = structuredClone(f.game);
|
||||
if (viewGame(game, f.owner).discoveries!.canInspect.includes(f.token))
|
||||
game = applyAction(game, f.owner, { type: 'discovery', token: f.token, reveal: false });
|
||||
return applyAction(game, f.owner, { type: 'discovery', token: f.token, reveal: true });
|
||||
}
|
||||
export function enterNativeHomeworldDiscovery(f: NativeHomeworldDiscoveryProgramme): Game {
|
||||
const game = advanceNativeHomeworld(revealNativeHomeworldDiscovery(f), g =>
|
||||
g.turn === 2 && g.decision?.kind === 'discoveryEntry' && g.decision.player === f.owner);
|
||||
const view = viewGame(game, f.owner), action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources); assert.ok(action);
|
||||
return applyAction(game, f.owner, action);
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeDiscoveryGameForAudit, initializeLeaderSkillsGameForAudit,
|
||||
joinGame, newPlayer, viewGame, type Action, type Game, type Player,
|
||||
} from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { leaders, treacheryDeck } from '../game/cards';
|
||||
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
|
||||
import { validateLeaderSkills } from '../game/leader-skills';
|
||||
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_TOKEN_BY_ID, validateDiscoveryState } from '../game/discoveries';
|
||||
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
|
||||
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
import { homeworldForceGroups, type HomeworldForces } from '../game/homeworld-custody';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
||||
import { validateAmbassadors } from '../game/ecaz-ambassadors';
|
||||
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { nextEcazSkillsModulesStep } from './fixture-ecaz-skills-modules';
|
||||
import { nextMoritaniSkillsModulesStep, moritaniSkillsModulesPolicy } from './fixture-moritani-skills-modules';
|
||||
|
||||
export type NativeE3HomeworldStep = { actor: string; action: Action };
|
||||
export type NativeE3HomeworldOptions = {
|
||||
/** Original undealt lobby: preserve seat IDs and order, without implying HTTP authentication. */
|
||||
initial?: Game;
|
||||
faction?: 'ecaz' | 'moritani';
|
||||
advanced?: boolean;
|
||||
roster?: readonly FactionId[];
|
||||
skills?: boolean;
|
||||
discovery?: boolean;
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
skill?: LeaderSkillId;
|
||||
/** Choose the original Guild offer/Traitor lottery for the after-loss proof. */
|
||||
assassination?: boolean;
|
||||
lethal?: boolean;
|
||||
};
|
||||
export type NativeE3HomeworldSetup = {
|
||||
initial: Game; offered: Game; afterSetup: Game;
|
||||
owner: string; opponent: string; trainer: string | null;
|
||||
actions: NativeE3HomeworldStep[];
|
||||
};
|
||||
export type NativeE3HomeworldBattle = NativeE3HomeworldSetup & {
|
||||
visitor: boolean; location: string;
|
||||
beforeShipment: Game; shipment: NativeE3HomeworldStep; afterShipment: Game;
|
||||
beforeBattle: Game; game: Game; plans: NativeE3HomeworldStep[];
|
||||
};
|
||||
export type NativeE3HomeworldDeath = {
|
||||
before: Game; after: Game; leader: string; weapon: string;
|
||||
};
|
||||
export type NativeE3HomeworldRevival = NativeE3HomeworldSetup & {
|
||||
deaths: NativeE3HomeworldDeath[]; revivalWindow: Game; revival: NativeE3HomeworldStep; offeredRevival: Game;
|
||||
};
|
||||
export type NativeE3HomeworldDiscovery = NativeE3HomeworldSetup & {
|
||||
beforeShipment: Game; shipment: NativeE3HomeworldStep; afterShipment: Game;
|
||||
beforeReveal: Game; afterReveal: Game; token: string;
|
||||
entryWindow: Game; entry: NativeE3HomeworldStep; entered: Game;
|
||||
};
|
||||
export const nativeE3HomeworldClean = (game: Game) =>
|
||||
!game.response && !game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard;
|
||||
export function nativeE3HomeworldPlayer(game: Game, actor: string): Player {
|
||||
const player = game.players.find(p => p.id === actor); assert.ok(player); return player;
|
||||
}
|
||||
export const nativeE3HomeworldPolicy = moritaniSkillsModulesPolicy;
|
||||
export function nativeE3HomeworldPool(game: Game, actor: string, location: string): HomeworldForces {
|
||||
const home = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!).find(h => h.id === location);
|
||||
assert.ok(home); return home.forces[actor] ?? { normal: 0, elite: 0 };
|
||||
}
|
||||
export function assertNativeE3HomeworldCustody(game: Game): void {
|
||||
homeworldGameIntegrity(game);
|
||||
if (game.leaderSkills) validateLeaderSkills(game.leaderSkills, game.players);
|
||||
if (game.discoveries) validateDiscoveryState(game.discoveries);
|
||||
const homes = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!);
|
||||
for (const p of game.players) {
|
||||
const visitors = homes.filter(h => h.native !== p.id).map(h => h.forces[p.id] ?? { normal: 0, elite: 0 });
|
||||
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0) +
|
||||
visitors.reduce((a, b) => a + b.normal + b.elite, 0), 20, p.faction);
|
||||
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks +
|
||||
Object.values(p.elites.forces).reduce((a, b) => a + b, 0) + visitors.reduce((a, b) => a + b.elite, 0),
|
||||
p.faction === 'fremen' ? 3 : 5, p.faction);
|
||||
assert.deepEqual(p.leaders.map(l => l.id).sort(), leaders(p.faction).map(l => l.id).sort());
|
||||
}
|
||||
assert.equal(game.players.find(p => ['ecaz', 'moritani'].includes(p.faction))!.leaders.length, 5);
|
||||
assert.equal(game.dukeVidal!.leader.id, DUKE_VIDAL_ID);
|
||||
assert.ok(!game.leaderSkills?.assignments.some(a => a.leader === DUKE_VIDAL_ID));
|
||||
if (game.ecazAmbassadors) validateAmbassadors(game.ecazAmbassadors);
|
||||
const retired = game.moritaniAssassinate?.opportunities.filter(r => r.stage === 'replaced').map(r => r.card!) ?? [];
|
||||
const traitors = [...game.traitorReserve!, ...game.players.flatMap(p => [...p.traitors, ...p.traitorChoices]),
|
||||
...retired, ...(game.ecazLoyalty?.card ? [game.ecazLoyalty.card] : [])];
|
||||
assert.deepEqual(traitors.sort(), traitorDeck(game.players).sort());
|
||||
const physical = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index) ?? [])];
|
||||
assert.deepEqual(physical.map(c => c.id).sort(), treacheryDeck(['ecaz']).map(c => c.id).sort());
|
||||
}
|
||||
|
||||
/** Fisher-Yates draw selection affects only components that have not been dealt. */
|
||||
function permutation<T>(original: readonly T[], desired: readonly T[]): number[] {
|
||||
const current = [...original], 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) * 0x100000000));
|
||||
[current[i], current[j]] = [current[j], current[i]];
|
||||
}
|
||||
assert.deepEqual(current, desired); return draws;
|
||||
}
|
||||
function lottery<T>(draws: readonly number[], operation: () => T): T {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const original = crypto.getRandomValues.bind(crypto); let index = 0;
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1 && index < draws.length) array[0] = draws[index++];
|
||||
else Reflect.apply(original, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return operation(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
export function actNativeE3Homeworld(game: Game, step: NativeE3HomeworldStep,
|
||||
actions?: NativeE3HomeworldStep[]): Game {
|
||||
actions?.push(structuredClone(step)); return applyAction(game, step.actor, step.action);
|
||||
}
|
||||
/** Explicit rescue and sealed plans remain boundaries; optional assassination defaults to decline. */
|
||||
export function nextNativeE3HomeworldStep(game: Game): NativeE3HomeworldStep | null {
|
||||
if (!game.response && !game.phaseOpening) {
|
||||
const d = game.decision;
|
||||
if (d?.kind === 'sukRescue') return null;
|
||||
if (d?.kind === 'moritaniAssassinate')
|
||||
return { actor: d.player, action: { type: 'decision', event: d.event, decline: true } };
|
||||
if (d?.kind === 'discoveryEntry' || d?.kind === 'greatMakerRide')
|
||||
return { actor: d.player, action: { type: 'decision', event: d.event, accept: false } };
|
||||
if (d?.kind === 'greatMakerVote') return { actor: d.player, action: { type: 'decision', event: d.event, yes: false } };
|
||||
if (d?.kind === 'wormProtection') return { actor: d.player, action: { type: 'decision', accept: true } };
|
||||
if (d?.kind === 'wormRide') return { actor: d.player, action: { type: 'decision', accept: false } };
|
||||
if (d?.kind === 'homeworldDefense') return { actor: d.player, action: { type: 'decision', event: d.event, use: false } };
|
||||
if (d?.kind === 'homeworldRevivalDeployment') return { actor: d.player, action: { type: 'decision', event: d.event, decline: true } };
|
||||
}
|
||||
const next = game.players.some(p => p.faction === 'ecaz')
|
||||
? nextEcazSkillsModulesStep(game) : nextMoritaniSkillsModulesStep(game);
|
||||
if (next?.action.type === 'stormDial') next.action.amount = game.turn === 1 ? 0 : 1;
|
||||
return next;
|
||||
}
|
||||
export function advanceNativeE3Homeworld(state: Game, until: (game: Game) => boolean,
|
||||
actions?: NativeE3HomeworldStep[]): Game {
|
||||
let game = state;
|
||||
for (let n = 0; n < 2600; n++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const next = nextNativeE3HomeworldStep(game); assert.ok(next, 'An actual human-owned native window needs its owner.');
|
||||
game = actNativeE3Homeworld(game, next, actions);
|
||||
}
|
||||
throw Error('Original native Homeworld programme did not reach its requested window.');
|
||||
}
|
||||
|
||||
/** Reuse the original native initializer; no parallel game configuration or force staging. */
|
||||
export function createNativeE3HomeworldSetup(options: NativeE3HomeworldOptions = {}): NativeE3HomeworldSetup {
|
||||
const faction = options.faction ?? options.initial?.players.find(p => ['ecaz', 'moritani'].includes(p.faction))?.faction ?? 'ecaz';
|
||||
const advanced = options.advanced ?? options.initial?.advanced ?? true;
|
||||
const skills = options.skills ?? true, discovery = options.discovery ?? false;
|
||||
const roster = options.roster ?? [faction, 'guild', 'emperor'] as FactionId[];
|
||||
let game = options.initial ? structuredClone(options.initial)
|
||||
: createGame('NATIVEHWE3MODULES', newPlayer(roster[0], roster[0], roster[0]), advanced, ['ecaz']);
|
||||
if (!options.initial) for (const f of roster.slice(1)) joinGame(game, newPlayer(f, f, f));
|
||||
assert.equal(game.status, 'lobby'); assert.equal(game.advanced, advanced); assert.deepEqual(game.expansions, ['ecaz']);
|
||||
assert.equal(game.players.filter(p => ['ecaz', 'moritani'].includes(p.faction)).length, 1);
|
||||
assert.ok(game.players.some(p => p.faction === faction));
|
||||
assert.ok(game.players.every(p => p.faction === faction || ['guild', 'emperor', 'atreides', 'fremen', 'beneGesserit',
|
||||
...(advanced ? [] : ['harkonnen'])].includes(p.faction)));
|
||||
assert.ok(skills || discovery); assert.ok(!game.nexusCards && !game.ecazTreachery);
|
||||
if (!game.homeworlds) game = applyAction(game, game.host, { type: 'homeworlds', enabled: true });
|
||||
if (!!game.techTokens !== !!options.tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: !!options.tech });
|
||||
if (!!game.strongholdCards !== !!options.strongholds) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: !!options.strongholds });
|
||||
game.discoveryEnabled = discovery;
|
||||
const initial = structuredClone(game), actions: NativeE3HomeworldStep[] = [];
|
||||
const owner = game.players.find(p => p.faction === faction)!.id;
|
||||
const opponent = game.players.find(p => p.faction === 'guild')?.id ?? game.players.find(p => p.id !== owner)!.id;
|
||||
const requested = options.skill ?? 'suk-graduate';
|
||||
const preferences: LeaderSkillId[] = game.players.map(p => p.id === owner ? options.assassination ? 'warmaster' : requested
|
||||
: p.id === opponent && options.assassination ? 'suk-graduate'
|
||||
: p.faction === 'atreides' ? 'bureaucrat' : p.faction === 'fremen' ? 'killer-medic'
|
||||
: p.faction === 'beneGesserit' ? 'prana-bindu-adept' : p.faction === 'harkonnen' ? 'smuggler'
|
||||
: p.faction === 'guild' ? 'swordmaster-of-ginaz' : 'master-of-assassins');
|
||||
const canonical = LEADER_SKILL_CARDS.map(c => c.id), remainder = canonical.filter(s => !preferences.includes(s));
|
||||
const desiredSkills = preferences.flatMap(s => [s, remainder.shift()!]).concat(remainder);
|
||||
const cards = treacheryDeck(['ecaz']), remaining = [...cards], prefix = game.players.flatMap(p => {
|
||||
const kinds = [p.id === opponent && options.lethal ? 'projectile' : 'worthless', ...(p.faction === 'harkonnen' ? ['shield'] : [])];
|
||||
return kinds.map(kind => {
|
||||
const match = remaining.findIndex(c => c.kind === kind);
|
||||
const i = match >= 0 ? match : remaining.findIndex(c => c.kind === 'shield' || c.kind === 'snooper');
|
||||
assert.ok(i >= 0); return remaining.splice(i, 1)[0].id;
|
||||
});
|
||||
});
|
||||
for (const p of game.players) if (!p.ready) game = actNativeE3Homeworld(game, { actor: p.id, action: { type: 'ready' } }, actions);
|
||||
const draws = [...(skills ? permutation(canonical, desiredSkills) : []), ...(discovery ? Array<number>(7).fill(0xffffffff) : []),
|
||||
...permutation(cards.map(c => c.id), [...prefix, ...remaining.map(c => c.id)])];
|
||||
game = lottery(draws, () => skills ? initializeLeaderSkillsGameForAudit(game) : initializeDiscoveryGameForAudit(game));
|
||||
const offered = structuredClone(game);
|
||||
for (let n = 0; game.status === 'setup' && n < 240; n++) {
|
||||
let step: NativeE3HomeworldStep;
|
||||
if (game.setupStage === 'leaderSkills') {
|
||||
const actor = Object.keys(game.leaderSkills!.offers)[0], own = viewGame(game, actor).leaderSkills!;
|
||||
const skill = preferences[game.players.findIndex(p => p.id === actor)];
|
||||
assert.ok(own.offer!.cards.includes(skill) && !own.unavailableSkills?.[skill]);
|
||||
const eligible = own.eligibleLeaders.map(l => nativeE3HomeworldPlayer(game, actor).leaders.find(d => d.id === l.id)!)
|
||||
.sort((a, b) => b.strength - a.strength);
|
||||
const leader = options.assassination && actor === opponent ? eligible.find(l => l.name === 'Master Bewt') : eligible[0];
|
||||
assert.ok(leader);
|
||||
step = { actor, action: { type: 'leaderSkill', event: own.offer!.event, skill, leader: leader.id } };
|
||||
if (options.assassination && Object.keys(game.leaderSkills!.offers).length === 1) {
|
||||
const target = leaders('guild').find(l => l.name === 'Master Bewt')!.id;
|
||||
const traitors = traitorDeck(game.players), desired = [target, ...traitors.filter(t => t !== target)];
|
||||
const ownerIndex = game.players.findIndex(p => p.id === owner);
|
||||
[desired[0], desired[ownerIndex * 4]] = [desired[ownerIndex * 4], desired[0]];
|
||||
game = lottery([...Array<number>(game.leaderSkills!.deck.length).fill(0xffffffff),
|
||||
...permutation(traitors, desired)], () => actNativeE3Homeworld(game, step, actions));
|
||||
continue;
|
||||
}
|
||||
} else if (game.setupStage === 'traitors' && options.assassination && nativeE3HomeworldPlayer(game, owner).traitorChoices.length) {
|
||||
const target = leaders('guild').find(l => l.name === 'Master Bewt')!.id;
|
||||
assert.ok(nativeE3HomeworldPlayer(game, owner).traitorChoices.includes(target));
|
||||
step = { actor: owner, action: { type: 'traitor', leader: target } };
|
||||
} else { const next = nextNativeE3HomeworldStep(game); assert.ok(next); step = next; }
|
||||
game = actNativeE3Homeworld(game, step, actions);
|
||||
}
|
||||
assert.equal(game.status, 'playing'); assertNativeE3HomeworldCustody(game);
|
||||
return { initial, offered, afterSetup: structuredClone(game), owner, opponent,
|
||||
trainer: game.leaderSkills?.assignments.find(a => a.owner === owner)?.leader ?? null, actions };
|
||||
}
|
||||
function movement(game: Game, actor: string, actions: NativeE3HomeworldStep[]): Game {
|
||||
return advanceNativeE3Homeworld(game, g => g.phase === 5 && g.active === actor && nativeE3HomeworldClean(g), actions);
|
||||
}
|
||||
function invasion(game: Game, actor: string, destination: string, amount: number): NativeE3HomeworldStep {
|
||||
const sources = nativeShipmentSources(viewGame(game, actor), amount, 0); assert.ok(sources);
|
||||
const selected = Object.fromEntries(Object.entries(sources).filter(([, group]) => group.normal + group.elite > 0));
|
||||
const choice = homeworldShipmentChoice(viewGame(game, actor), destination, selected);
|
||||
assert.ok(choice.action, choice.blocked ?? 'Original invasion is unavailable.'); return { actor, action: choice.action };
|
||||
}
|
||||
function arrive(game: Game, actions: NativeE3HomeworldStep[]): Game {
|
||||
return advanceNativeE3Homeworld(game, g => !g.pendingShipment && !g.pendingHomeworldShipment && nativeE3HomeworldClean(g), actions);
|
||||
}
|
||||
export function openNativeE3HomeworldBattle(state: Game, location: string, first: string, second: string,
|
||||
actions: NativeE3HomeworldStep[] = [], hide = true): Game {
|
||||
let game = advanceNativeE3Homeworld(state, g => g.phase === 6 && nativeE3HomeworldClean(g) && !!g.active, actions);
|
||||
assert.ok(game.active === first || game.active === second);
|
||||
game = actNativeE3Homeworld(game, { actor: game.active!, action: { type: 'chooseBattle', territory: location,
|
||||
target: game.active === first ? second : first } }, actions);
|
||||
for (let n = 0; n < 180; n++) {
|
||||
if (game.battle && nativeE3HomeworldClean(game) && !game.battle.preparation && game.battle.preLeader?.closed !== false) return game;
|
||||
const next = nextNativeE3HomeworldStep(game); assert.ok(next);
|
||||
if (next.action.type === 'leaderSkillVisibility') next.action.hide = hide;
|
||||
game = actNativeE3Homeworld(game, next, actions);
|
||||
}
|
||||
throw Error('Original Homeworld Battle Plan window did not open.');
|
||||
}
|
||||
export function createNativeE3HomeworldBattle(options: NativeE3HomeworldOptions & { visitor?: boolean; battleTurn?: 1 | 2 } = {}): NativeE3HomeworldBattle {
|
||||
const setup = createNativeE3HomeworldSetup(options), visitor = options.visitor ?? false;
|
||||
const location = `homeworld:${nativeE3HomeworldPlayer(setup.afterSetup, visitor ? setup.opponent : setup.owner).faction}`;
|
||||
const actor = visitor ? setup.owner : setup.opponent;
|
||||
let game = structuredClone(setup.afterSetup);
|
||||
for (let position = 0; position < (game.advanced ? 4 : 2); position++)
|
||||
frontSpice(game, c => 'territory' in c && !c.discovery, position);
|
||||
if (options.battleTurn === 2) game = advanceNativeE3Homeworld(game,
|
||||
g => g.turn === 2 && g.phase === 0 && nativeE3HomeworldClean(g), setup.actions);
|
||||
game = movement(game, actor, setup.actions);
|
||||
const beforeShipment = structuredClone(game), shipment = invasion(game, actor, location, 5);
|
||||
game = arrive(actNativeE3Homeworld(game, shipment, setup.actions), setup.actions);
|
||||
const afterShipment = structuredClone(game);
|
||||
game = advanceNativeE3Homeworld(game, g => g.phase === 6 && nativeE3HomeworldClean(g), setup.actions);
|
||||
const beforeBattle = structuredClone(game);
|
||||
game = openNativeE3HomeworldBattle(game, location, setup.owner, setup.opponent, setup.actions, !options.assassination);
|
||||
const trained = new Set(game.leaderSkills?.assignments.map(a => a.leader) ?? []);
|
||||
const enemy = nativeE3HomeworldPlayer(game, setup.opponent).leaders.filter(l => !l.dead && !trained.has(l.id))
|
||||
.sort((a, b) => options.lethal || options.assassination ? b.strength - a.strength : a.strength - b.strength)[0];
|
||||
const ownerLeader = options.assassination ? nativeE3HomeworldPlayer(game, setup.owner).leaders.filter(l => !trained.has(l.id))
|
||||
.sort((a, b) => a.strength - b.strength)[0].id : setup.trainer ?? nativeE3HomeworldPlayer(game, setup.owner).leaders[0].id;
|
||||
const weapon = options.lethal ? nativeE3HomeworldPlayer(game, setup.opponent).hand.find(c => c.kind === 'projectile')?.id : undefined;
|
||||
if (options.lethal) assert.ok(weapon);
|
||||
const worthless = nativeE3HomeworldPlayer(game, setup.opponent).hand.find(c => c.kind === 'worthless')?.id;
|
||||
const dial = options.lethal || options.assassination ? 0 : 4;
|
||||
const plans: NativeE3HomeworldStep[] = [
|
||||
{ actor: setup.owner, action: { type: 'battlePlan', leader: ownerLeader, dial, support: game.advanced ? dial : 0 } },
|
||||
{ actor: setup.opponent, action: { type: 'battlePlan', leader: enemy.id, dial: options.assassination ? 3 : 0,
|
||||
support: game.advanced && options.assassination ? 3 : 0, weapon: weapon ?? worthless } },
|
||||
];
|
||||
return { ...setup, visitor, location, beforeShipment, shipment, afterShipment, beforeBattle, game, plans };
|
||||
}
|
||||
export function revealNativeE3HomeworldPlans(state: Game, plans: NativeE3HomeworldStep[],
|
||||
actions?: NativeE3HomeworldStep[]): Game {
|
||||
let game = structuredClone(state);
|
||||
for (const step of plans) {
|
||||
game = actNativeE3Homeworld(game, step, actions);
|
||||
if (!game.battle?.revealed) game = advanceNativeE3Homeworld(game,
|
||||
g => nativeE3HomeworldClean(g) && !!g.battle && !g.battle.preparation && g.battle.preLeader?.closed !== false, actions);
|
||||
}
|
||||
assert.ok(game.battle?.revealed); return game;
|
||||
}
|
||||
export function settleNativeE3HomeworldBattle(state: Game, stop: 'suk' | 'assassination' | 'tech' | 'complete' = 'complete',
|
||||
actions?: NativeE3HomeworldStep[]): Game {
|
||||
return advanceNativeE3Homeworld(state, g => !g.response && !g.phaseOpening && (
|
||||
stop === 'suk' && g.decision?.kind === 'sukRescue' ||
|
||||
stop === 'assassination' && g.decision?.kind === 'moritaniAssassinate' ||
|
||||
stop === 'tech' && g.decision?.kind === 'techToken' || !g.battle && nativeE3HomeworldClean(g)), actions);
|
||||
}
|
||||
export function rescueNativeE3Homeworld(state: Game): Game {
|
||||
const d = state.decision; assert.ok(d?.kind === 'sukRescue');
|
||||
const maximum = Math.max(...d.options.map(o => o.normal + o.elite));
|
||||
const choice = d.options.findIndex(o => o.normal + o.elite === maximum && o.kept?.kind === 'normal');
|
||||
assert.ok(choice >= 0); return applyAction(state, d.player, { type: 'decision', event: d.event, choice });
|
||||
}
|
||||
|
||||
/** All five deaths and revival are played. Each later visitor arrives by a new
|
||||
* paid invasion; the original native Guild retains its real winning projectile. */
|
||||
export function createNativeE3HomeworldRevival(options: NativeE3HomeworldOptions = {}): NativeE3HomeworldRevival {
|
||||
const setup = createNativeE3HomeworldSetup({ ...options, skills: true, lethal: true, skill: 'mentat' });
|
||||
let game = structuredClone(setup.afterSetup);
|
||||
for (let position = 0; position < 12; position++)
|
||||
frontSpice(game, c => 'territory' in c && !c.discovery, position);
|
||||
const location = 'homeworld:guild', deaths: NativeE3HomeworldDeath[] = [];
|
||||
const discs = nativeE3HomeworldPlayer(game, setup.owner).leaders.map(l => l.id);
|
||||
const weapon = nativeE3HomeworldPlayer(game, setup.opponent).hand.find(c => c.kind === 'projectile')!.id;
|
||||
const winner = nativeE3HomeworldPlayer(game, setup.opponent).leaders.filter(l =>
|
||||
!game.leaderSkills!.assignments.some(a => a.leader === l.id)).sort((a, b) => b.strength - a.strength)[0].id;
|
||||
for (const leader of discs) {
|
||||
game = movement(game, setup.owner, setup.actions);
|
||||
game = arrive(actNativeE3Homeworld(game, invasion(game, setup.owner, location, 1), setup.actions), setup.actions);
|
||||
game = openNativeE3HomeworldBattle(game, location, setup.owner, setup.opponent, setup.actions);
|
||||
const before = structuredClone(game);
|
||||
game = revealNativeE3HomeworldPlans(game, [
|
||||
{ actor: setup.owner, action: { type: 'battlePlan', leader, dial: 0, support: 0 } },
|
||||
{ actor: setup.opponent, action: { type: 'battlePlan', leader: winner, dial: 0, support: 0, weapon } },
|
||||
], setup.actions);
|
||||
game = advanceNativeE3Homeworld(game, g => !g.battle && nativeE3HomeworldClean(g) || g.decision?.kind === 'moritaniAssassinate', setup.actions);
|
||||
if (game.decision?.kind === 'moritaniAssassinate') {
|
||||
game = actNativeE3Homeworld(game, { actor: game.decision.player,
|
||||
action: { type: 'decision', event: game.decision.event, decline: true } }, setup.actions);
|
||||
game = settleNativeE3HomeworldBattle(game, 'complete', setup.actions);
|
||||
}
|
||||
deaths.push({ before, after: structuredClone(game), leader, weapon });
|
||||
const turn = game.turn;
|
||||
game = advanceNativeE3Homeworld(game, g => g.turn === turn + 1 && g.phase === 4 && nativeE3HomeworldClean(g), setup.actions);
|
||||
}
|
||||
const revivalWindow = structuredClone(game), leader = setup.trainer!;
|
||||
const revival: NativeE3HomeworldStep = { actor: setup.owner, action: { type: 'reviveLeader', leader } };
|
||||
game = actNativeE3Homeworld(game, revival, setup.actions);
|
||||
game = advanceNativeE3Homeworld(game, g => !!g.leaderSkills!.offers[setup.owner], setup.actions);
|
||||
return { ...setup, deaths, revivalWindow, revival, offeredRevival: game };
|
||||
}
|
||||
function frontSpice(game: Game, predicate: (card: Game['spiceDeck'][number]) => boolean, position: number): void {
|
||||
const i = game.spiceDeck.findIndex((c, n) => n >= position && predicate(c)); assert.ok(i >= position);
|
||||
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(i, 1)[0]);
|
||||
}
|
||||
/** Only unplayed Spice order and the original supply lottery are selected.
|
||||
* Reveal is followed by next-turn parent entry, not a direct nested placement. */
|
||||
export function createNativeE3HomeworldDiscovery(options: NativeE3HomeworldOptions = {}): NativeE3HomeworldDiscovery {
|
||||
const setup = createNativeE3HomeworldSetup({ ...options, discovery: true });
|
||||
let game = structuredClone(setup.afterSetup);
|
||||
frontSpice(game, c => 'territory' in c && c.discovery === 'discovery-hagga-basin', 0);
|
||||
for (let position = 1; position < (game.advanced ? 4 : 2); position++)
|
||||
frontSpice(game, c => 'territory' in c && !c.discovery && c.territory !== 'hagga_basin', position);
|
||||
const face = 'cistern', type = DISCOVERY_TOKEN_BY_ID[face].type;
|
||||
const pool = game.discoveries!.tokens.filter(t => t.status === 'supply' && t.type === type);
|
||||
const index = pool.findIndex(t => t.face === face); assert.ok(index >= 0);
|
||||
game = advanceNativeE3Homeworld(game, g => g.phase === 1 && nativeE3HomeworldClean(g), setup.actions);
|
||||
game = lottery([Math.floor((index + 0.5) / pool.length * 0x100000000)], () =>
|
||||
advanceNativeE3Homeworld(game, g => g.discoveries!.tokens.some(t => t.face === face && t.status === 'placed'), setup.actions));
|
||||
game = movement(game, setup.owner, setup.actions);
|
||||
const beforeShipment = structuredClone(game), destination = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
|
||||
const sources = nativeShipmentSources(viewGame(game, setup.owner), 2, 0); assert.ok(sources);
|
||||
const shipment: NativeE3HomeworldStep = { actor: setup.owner, action: { type: 'ship', territory: destination.territory,
|
||||
sector: destination.sector, amount: 2, elite: 0, allyPayment: 0, homeworldSources: sources } };
|
||||
game = arrive(actNativeE3Homeworld(game, shipment, setup.actions), setup.actions);
|
||||
const afterShipment = structuredClone(game);
|
||||
game = advanceNativeE3Homeworld(game, g => g.phase === 7 && nativeE3HomeworldClean(g), setup.actions);
|
||||
const beforeReveal = structuredClone(game), token = game.discoveries!.tokens.find(t => t.face === face)!.id;
|
||||
if (viewGame(game, setup.owner).discoveries!.canInspect.includes(token))
|
||||
game = actNativeE3Homeworld(game, { actor: setup.owner, action: { type: 'discovery', token, reveal: false } }, setup.actions);
|
||||
game = actNativeE3Homeworld(game, { actor: setup.owner, action: { type: 'discovery', token, reveal: true } }, setup.actions);
|
||||
const afterReveal = structuredClone(game);
|
||||
game = advanceNativeE3Homeworld(game, g => g.turn === 2 && !g.response && !g.phaseOpening &&
|
||||
g.decision?.kind === 'discoveryEntry' && g.decision.player === setup.owner, setup.actions);
|
||||
const entryWindow = structuredClone(game), view = viewGame(game, setup.owner);
|
||||
const action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources); assert.ok(action);
|
||||
const entry: NativeE3HomeworldStep = { actor: setup.owner, action };
|
||||
game = actNativeE3Homeworld(game, entry, setup.actions);
|
||||
return { ...setup, beforeShipment, shipment, afterShipment, beforeReveal, afterReveal, token, entryWindow, entry, entered: game };
|
||||
}
|
||||
|
||||
export type NativeE3HomeworldArrival = { before: Game; step: NativeE3HomeworldStep; after: Game };
|
||||
/** Reusable original human shipment boundary, including noncombatant BG entry. */
|
||||
export function invadeNativeE3Homeworld(state: Game, actor: string, destination: string,
|
||||
amount: number): NativeE3HomeworldArrival {
|
||||
const actions: NativeE3HomeworldStep[] = [], before = movement(state, actor, actions);
|
||||
const step = invasion(before, actor, destination, amount);
|
||||
const after = arrive(actNativeE3Homeworld(before, step, actions), actions);
|
||||
return { before, step, after };
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { TERRITORIES, territory } from '../game/board';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
|
||||
import { greatMakerRideAction } from '../game/great-maker-options';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import {
|
||||
advanceNativeHomeworld as advance, createNativeHomeworldModuleSetup as setup,
|
||||
createNativeHomeworldSukProgramme as sukProgramme, createNativeHomeworldDiscoveryProgramme as discoveryProgramme,
|
||||
enterNativeHomeworldDiscovery as enter, revealNativeHomeworldDiscovery as reveal,
|
||||
invadeNativeHomeworld as invade, nativeHomeworldArmy as army, nativeHomeworldClean as clean,
|
||||
nativeHomeworldInventory as inventory, nativeHomeworldLeader as leader, nativeHomeworldMovement as movement,
|
||||
nativeHomeworldPlayer as player, openNativeHomeworldBattle as openBattle,
|
||||
revealNativeHomeworldPlans as plans, settleNativeHomeworldArrival as arrival,
|
||||
type NativeHomeworldModuleOptions,
|
||||
} from './fixture-homeworld-native-e1e2-modules';
|
||||
|
||||
/** Authorized revision PDF21–23 composes variants; exact original native
|
||||
* component contracts supply abilities. These programmes choose only fresh
|
||||
* undealt lotteries/unplayed Spice order. No force, wallet, hand, owner, phase,
|
||||
* signed frame or casualty is staged. Emperor return rulings remain untouched. */
|
||||
function reject(game: Game, actor: string, action: Action): void {
|
||||
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function policies(game: Game, actor: string, relevant: (action: Action) => boolean): void {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, actor); view.players.find(p => p.id === actor)!.bot = difficulty;
|
||||
const action = botActions(view).find(relevant); assert.ok(action, `${difficulty} consumes an owned relevant offer`);
|
||||
const after = applyAction(structuredClone(game), actor, action);
|
||||
if (after.status === 'playing') inventory(after);
|
||||
}
|
||||
}
|
||||
const e1Rosters: readonly (readonly FactionId[])[] = [
|
||||
['ixians', 'guild'], ['tleilaxu', 'guild'], ['ixians', 'tleilaxu', 'guild', 'fremen'],
|
||||
];
|
||||
const e2Rosters: readonly (readonly FactionId[])[] = [
|
||||
['choam', 'guild'], ['richese', 'guild'], ['choam', 'richese', 'guild', 'fremen'],
|
||||
];
|
||||
|
||||
void test('original Basic/Advanced E1/E2 single and paired rosters compose either or both modules without changing selected 47/35 decks or private native inventories', () => {
|
||||
for (const advanced of [false, true]) for (const expansions of [['ix'], ['choam']])
|
||||
for (const roster of expansions[0] === 'ix' ? e1Rosters : e2Rosters)
|
||||
for (const [skills, discovery] of [[true, false], [false, true], [true, true]]) {
|
||||
const f = setup({ advanced, expansions, roster, skills, discovery });
|
||||
assert.equal(f.afterSetup.leaderSkills?.assignments.length ?? 0, skills ? roster.length : 0);
|
||||
assert.equal(f.afterSetup.discoveries?.tokens.length ?? 0, discovery ? 8 : 0);
|
||||
for (const p of f.afterSetup.players) {
|
||||
assert.equal(p.hand.length, 1);
|
||||
assert.equal(viewGame(f.afterSetup, p.id).players.find(q => q.id !== p.id)!.hand, undefined);
|
||||
}
|
||||
if (roster.includes('tleilaxu')) {
|
||||
assert.equal(player(f.afterSetup, 'tleilaxu').faceDancers!.length, 3);
|
||||
assert.equal(new Set(player(f.afterSetup, 'tleilaxu').faceDancers!.map(c => c.leader)).size, 3);
|
||||
}
|
||||
if (roster.includes('richese')) {
|
||||
assert.deepEqual(f.afterSetup.richeseCache!.map(c => c.id).sort(), richeseCards().map(c => c.id).sort());
|
||||
assert.deepEqual(player(f.afterSetup, 'richese').noField!.tokens.map(t => t.value).sort((a, b) => a - b), [0, 3, 5]);
|
||||
}
|
||||
if (roster.includes('choam')) assert.equal(player(f.afterSetup, 'choam').leaders.filter(l => l.id === 'choam-auditor').length, advanced ? 1 : 0);
|
||||
if (f.offered) policies(f.offered, roster[0], a => a.type === 'leaderSkill');
|
||||
inventory(f.afterSetup);
|
||||
}
|
||||
});
|
||||
|
||||
void test('supported original Advanced two-deck native rosters retain Ix-selected deck precedence and optional Tech3+/Stronghold2+ components', () => {
|
||||
const rosters: readonly (readonly FactionId[])[] = [
|
||||
['ixians', 'choam', 'guild'], ['tleilaxu', 'richese', 'guild'],
|
||||
['ixians', 'tleilaxu', 'choam', 'richese', 'guild', 'fremen'],
|
||||
];
|
||||
for (const roster of rosters) for (const [skills, discovery] of [[true, false], [false, true], [true, true]]) {
|
||||
const f = setup({ advanced: true, expansions: ['ix', 'choam'], roster, skills, discovery, tech: true, strongholds: true });
|
||||
assert.ok(f.afterSetup.techTokens && f.afterSetup.strongholdCards);
|
||||
assert.equal(f.afterSetup.techTokens!.axlotl.owner, roster.includes('tleilaxu') ? 'tleilaxu' : null);
|
||||
inventory(f.afterSetup);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native and visiting trained/normal Ixian Suk preserve physical saved sources and allow only the real residual Cyborg exchange after rescue', () => {
|
||||
for (const advanced of [false, true]) for (const visitor of [false, true]) for (const band of ['normal', 'skilled'] as const) {
|
||||
const f = sukProgramme({ advanced, visitor, band, tech: true, strongholds: advanced });
|
||||
const revealed = plans(f.game, f.plans);
|
||||
let game = advance(revealed, g => g.decision?.kind === (band === 'normal' ? 'ixSubstitution' : 'sukRescue'));
|
||||
let before = structuredClone(revealed);
|
||||
if (band === 'skilled') {
|
||||
const decision = game.decision; assert.ok(decision?.kind === 'sukRescue');
|
||||
assert.equal(Math.max(...decision.options.map(o => o.normal + o.elite)), 3);
|
||||
assert.deepEqual(army(game, f.owner, f.territory), army(f.game, f.owner, f.territory),
|
||||
'Own casualty selection has not transported or killed a counter yet.');
|
||||
policies(game, f.owner, a => a.type === 'decision' && a.choice !== undefined);
|
||||
const choice = decision.options.findIndex(o => o.normal === 0 && o.elite === 1 && o.kept?.kind === 'elite');
|
||||
assert.ok(choice >= 0);
|
||||
before = structuredClone(game);
|
||||
reject(game, f.opponent, { type: 'decision', event: decision.event, choice });
|
||||
reject(game, f.owner, { type: 'decision', event: 'stale-suk', choice });
|
||||
reject(game, f.owner, { type: 'decision', event: decision.event, choice,
|
||||
destinations: { [f.territory]: { normal: 1, elite: 1 } } });
|
||||
game = applyAction(game, f.owner, { type: 'decision', event: decision.event, choice });
|
||||
game = advance(game, g => g.decision?.kind === 'ixSubstitution');
|
||||
}
|
||||
const nativeBefore = army(before, f.owner, 'homeworld:ixians');
|
||||
const physical = game.lastBattleContext!.sukRescue!.physical!;
|
||||
assert.equal(physical.source, visitor ? 'visitor-homeworld' : 'native-homeworld');
|
||||
assert.deepEqual(physical.saved, { normal: 0, elite: 1 });
|
||||
assert.deepEqual(physical.tanks, { normal: 0, elite: 2 });
|
||||
assert.equal(player(game, f.owner).tanks, player(before, f.owner).tanks + 2);
|
||||
assert.equal(player(game, f.owner).elites!.tanks, player(before, f.owner).elites!.tanks + 2);
|
||||
if (visitor && band === 'normal') {
|
||||
assert.deepEqual(physical.destinations, { 'homeworld:ixians': { normal: 0, elite: 1 } });
|
||||
assert.deepEqual(army(game, f.owner, 'homeworld:ixians'), { ...nativeBefore, elite: nativeBefore.elite + 1 });
|
||||
} else assert.deepEqual(physical.destinations, {});
|
||||
assert.deepEqual(game.pendingIxSubstitution!.losses, { [f.territory]: 2 });
|
||||
const substitution = structuredClone(game), beforeArmy = army(game, f.owner, f.territory);
|
||||
const action: Action = { type: 'decision', sources: { [f.territory]: 1 }, recover: { [f.territory]: 1 } };
|
||||
reject(game, f.owner, { ...action, recover: { [f.territory]: 3 } });
|
||||
reject(game, f.owner, { ...action, sources: { reserves: 1 } });
|
||||
reject(game, f.owner, { ...action, recover: { 'hidden_mobile_stronghold:0': 1 } });
|
||||
policies(game, f.owner, a => a.type === 'decision' && (a.decline === true || a.sources !== undefined));
|
||||
game = applyAction(game, f.owner, action);
|
||||
game = advance(game, g => !g.pendingIxSubstitution);
|
||||
assert.deepEqual(army(game, f.owner, f.territory), { normal: beforeArmy.normal - 1, elite: beforeArmy.elite + 1 });
|
||||
assert.equal(player(game, f.owner).tanks, player(substitution, f.owner).tanks);
|
||||
assert.equal(player(game, f.owner).elites!.tanks, player(substitution, f.owner).elites!.tanks - 1);
|
||||
assert.equal(player(game, f.owner).battleLosses, player(substitution, f.owner).battleLosses);
|
||||
assert.equal(player(game, f.owner).spice, player(substitution, f.owner).spice);
|
||||
assert.deepEqual(game.players.map(p => [p.id, p.spice]), substitution.players.map(p => [p.id, p.spice]),
|
||||
'Substitution creates neither a second support payment nor a bounty for any faction.');
|
||||
assert.deepEqual(player(game, f.owner).hand, player(substitution, f.owner).hand);
|
||||
assert.deepEqual(game.discard, substitution.discard);
|
||||
assert.deepEqual(game.leaderSkills!.assignments, substitution.leaderSkills!.assignments);
|
||||
if (visitor) assert.deepEqual(army(game, f.owner, 'homeworld:ixians'), army(substitution, f.owner, 'homeworld:ixians'));
|
||||
reject(game, f.owner, action); inventory(game);
|
||||
game = advance(game, g => !g.battle && !g.pendingIxSubstitution && !g.decision && !g.response && !g.pendingTreacheryDiscard);
|
||||
assert.equal(game.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(game.lastBattleContext!.sukRescue!.completed, true);
|
||||
assert.equal(game.lastBattleContext!.ixSubstitution!.completed, true);
|
||||
assert.ok(ownedTech(game.techTokens!, f.owner).includes('heighliners'));
|
||||
inventory(game);
|
||||
}
|
||||
});
|
||||
|
||||
void test('genuine maximum trained Cyborg rescue opens no exchange for saved casualties and cannot be claimed twice', () => {
|
||||
const f = sukProgramme({ advanced: true, visitor: true, band: 'skilled', tech: true });
|
||||
let game = advance(plans(f.game, f.plans), g => g.decision?.kind === 'sukRescue');
|
||||
const d = game.decision; assert.ok(d?.kind === 'sukRescue');
|
||||
const choice = d.options.findIndex(o => o.elite === 3 && o.normal === 0 && o.kept?.kind === 'elite'); assert.ok(choice >= 0);
|
||||
const action: Action = { type: 'decision', event: d.event, choice };
|
||||
game = applyAction(game, f.owner, action);
|
||||
assert.equal(player(game, f.owner).elites!.tanks, 0);
|
||||
assert.deepEqual(army(game, f.owner, f.territory), { normal: 4, elite: 1 });
|
||||
assert.deepEqual(army(game, f.owner, 'homeworld:ixians'), { normal: 6, elite: 3 });
|
||||
reject(game, f.owner, action); inventory(game);
|
||||
});
|
||||
|
||||
void test('native E1/E2 Discovery inspection and later typed parent entry move only original shipped counters without borrowing native reserves', () => {
|
||||
for (const advanced of [false, true]) for (const family of ['ix', 'choam']) for (const skills of [false, true]) {
|
||||
const options: NativeHomeworldModuleOptions = family === 'ix'
|
||||
? { roster: ['ixians', 'tleilaxu', 'guild', 'fremen'], expansions: ['ix'] }
|
||||
: { roster: ['richese', 'choam', 'guild', 'fremen'], expansions: ['choam'], skill: 'planetologist' };
|
||||
const f = discoveryProgramme({ ...options, advanced, skills, face: 'cistern', tech: true, strongholds: advanced });
|
||||
const key = `${f.parent}:${f.parentSector}`;
|
||||
assert.equal(player(f.game, f.owner).forces[key], 2);
|
||||
assert.equal(player(f.game, f.owner).reserves, player(f.beforeShipment, f.owner).reserves - 2);
|
||||
const unveiled = reveal(f);
|
||||
assert.equal(unveiled.discoveries!.tokens.find(t => t.id === f.token)!.revealedTurn, 1);
|
||||
assert.equal(player(unveiled, f.owner).forces['cistern:0'] ?? 0, 0, 'Reveal never creates an army.');
|
||||
const window = advance(unveiled, g => g.turn === 2 && g.decision?.kind === 'discoveryEntry' && g.decision.player === f.owner);
|
||||
policies(window, f.owner, a => a.type === 'decision');
|
||||
const view = viewGame(window, f.owner), action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources); assert.ok(action);
|
||||
reject(window, f.owner, { ...action, sources: { [`homeworld:${f.owner}`]: { normal: 2, elite: 0 } } });
|
||||
const homes = structuredClone(window.homeworlds!.custody), before = structuredClone(player(window, f.owner));
|
||||
const after = applyAction(window, f.owner, action);
|
||||
assert.equal(player(after, f.owner).forces[key] ?? 0, 0);
|
||||
assert.equal(player(after, f.owner).forces['cistern:0'], 2);
|
||||
assert.equal(player(after, f.owner).elites?.forces['cistern:0'] ?? 0, family === 'ix' ? 1 : 0);
|
||||
assert.deepEqual(after.homeworlds!.custody, homes);
|
||||
assert.equal(player(after, f.owner).spice, before.spice);
|
||||
assert.equal(player(after, f.owner).moved, before.moved);
|
||||
assert.equal(player(after, f.owner).shipped, before.shipped);
|
||||
assert.deepEqual(after.leaderSkills, window.leaderSkills); inventory(after);
|
||||
}
|
||||
});
|
||||
|
||||
void test('original native Discovery Great Maker votes precede a free typed Fremen reserve ride with original Homeworld custody', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const f = discoveryProgramme({ advanced, maker: true, face: 'cistern', tech: true });
|
||||
let game = advance(f.game, g => g.decision?.kind === 'greatMakerVote');
|
||||
for (const actor of game.order) {
|
||||
assert.ok(game.decision?.kind === 'greatMakerVote'); assert.equal(game.decision.player, actor);
|
||||
policies(game, actor, a => a.type === 'decision');
|
||||
game = applyAction(game, actor, { type: 'decision', event: game.decision.event, yes: false });
|
||||
}
|
||||
assert.ok(game.decision?.kind === 'greatMakerRide'); assert.equal(game.decision.player, 'fremen');
|
||||
const view = viewGame(game, 'fremen'), destination = view.greatMaker!.ride!.destinations.find(d => d.territory === 'polar_sink'); assert.ok(destination);
|
||||
policies(game, 'fremen', a => a.type === 'decision');
|
||||
const elite = advanced ? 1 : 0, action = greatMakerRideAction(view, destination.territory, destination.sector, 2, elite); assert.ok(action);
|
||||
reject(game, 'fremen', { ...action, territory: 'homeworld:ixians', sector: 0 });
|
||||
const before = structuredClone(player(game, 'fremen')), training = structuredClone(game.leaderSkills);
|
||||
game = applyAction(game, 'fremen', action);
|
||||
assert.equal(player(game, 'fremen').reserves, before.reserves - 2);
|
||||
assert.equal(player(game, 'fremen').elites!.reserves, before.elites!.reserves - elite);
|
||||
assert.equal(player(game, 'fremen').forces['polar_sink:0'], (before.forces['polar_sink:0'] ?? 0) + 2);
|
||||
assert.equal(player(game, 'fremen').spice, before.spice);
|
||||
assert.deepEqual(game.leaderSkills, training); inventory(game);
|
||||
}
|
||||
});
|
||||
|
||||
void test('original Richese marker retains its signed token, charges one invoice and materializes only real native reserves at a revealed nested Discovery', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const f = discoveryProgramme({ advanced, expansions: ['choam'], roster: ['richese', 'choam', 'guild', 'fremen'],
|
||||
collector: 'guild', skill: 'planetologist', face: 'shrine', tech: true, strongholds: advanced });
|
||||
const entered = enter(f);
|
||||
let game = movement(entered, 'richese', 2);
|
||||
const p = player(game, 'richese'), marker = p.noField!.tokens.find(t => t.value === 5)!;
|
||||
const before = structuredClone(game), invoice: Action = { type: 'ship', territory: 'shrine', sector: 0,
|
||||
noField: marker.id, event: p.noFieldEvent, allyPayment: 0 };
|
||||
reject(game, 'richese', { ...invoice, event: 'stale-marker' });
|
||||
game = arrival(applyAction(game, 'richese', invoice));
|
||||
assert.equal(player(game, 'richese').reserves, player(before, 'richese').reserves);
|
||||
assert.equal(player(game, 'richese').forces['shrine:0'] ?? 0, 0);
|
||||
assert.equal(player(game, 'richese').noField!.deployed!.tokenId, marker.id);
|
||||
assert.equal(player(before, 'richese').spice - player(game, 'richese').spice, 1);
|
||||
assert.equal(player(game, 'guild').spice - player(before, 'guild').spice, 1);
|
||||
reject(game, 'richese', invoice);
|
||||
const deployed = structuredClone(game), event = player(game, 'richese').noFieldEvent;
|
||||
game = applyAction(game, 'richese', { type: 'revealNoField', token: marker.id, event });
|
||||
assert.equal(player(game, 'richese').forces['shrine:0'], 5);
|
||||
assert.equal(player(game, 'richese').reserves, player(deployed, 'richese').reserves - 5);
|
||||
assert.equal(player(game, 'richese').spice, player(deployed, 'richese').spice);
|
||||
assert.equal(player(game, 'guild').spice, player(deployed, 'guild').spice);
|
||||
assert.equal(player(game, 'richese').noField!.lastShipped, marker.id);
|
||||
assert.equal(player(game, 'richese').noField!.deployed, null);
|
||||
inventory(game);
|
||||
}
|
||||
});
|
||||
|
||||
void test('real repeated original Richese marker shipments cap the final printed five at the four physical reserves left, never inventing a fifth counter', () => {
|
||||
let game = setup({ advanced: true, skills: false, expansions: ['choam'], roster: ['richese', 'choam', 'guild'] }).afterSetup;
|
||||
for (const [index, value] of [5, 3, 5, 3, 5].entries()) {
|
||||
game = movement(game, 'richese', index + 1);
|
||||
const t = ['sietch_tabr', 'habbanya_ridge_sietch'].map(id => territory(id))
|
||||
.find(candidate => !candidate.sectors.includes(game.storm)); assert.ok(t);
|
||||
const sector = t.sectors[0];
|
||||
const before = structuredClone(player(game, 'richese')), token = before.noField!.tokens.find(t => t.value === value)!;
|
||||
game = arrival(applyAction(game, 'richese', { type: 'ship', territory: t.id, sector,
|
||||
noField: token.id, event: before.noFieldEvent, allyPayment: 0 }));
|
||||
const deployed = structuredClone(player(game, 'richese'));
|
||||
game = applyAction(game, 'richese', { type: 'revealNoField', token: token.id, event: deployed.noFieldEvent });
|
||||
const physical = Math.min(value, before.reserves);
|
||||
assert.equal(player(game, 'richese').forces[`${t.id}:${sector}`], (before.forces[`${t.id}:${sector}`] ?? 0) + physical);
|
||||
assert.equal(player(game, 'richese').reserves, before.reserves - physical);
|
||||
assert.equal(player(game, 'richese').spice, deployed.spice);
|
||||
if (index === 4) { assert.equal(physical, 4); assert.equal(player(game, 'richese').reserves, 0); }
|
||||
inventory(game);
|
||||
}
|
||||
});
|
||||
|
||||
void test('real CHOAM low-home shipment and Advanced paid battle support charge once without bypassing original income or reserve pools', () => {
|
||||
const f = setup({ advanced: true, expansions: ['choam'], roster: ['choam', 'richese', 'guild', 'fremen'],
|
||||
skill: 'smuggler', tech: true, strongholds: true });
|
||||
let game = advance(f.afterSetup, g => g.turn === 1 && g.phase === 5 && clean(g));
|
||||
const destination = TERRITORIES.find(t => t.type === 'stronghold' && t.id !== 'tueks_sietch' &&
|
||||
!t.sectors.includes(game.storm) && t.id !== 'sietch_tabr'); assert.ok(destination);
|
||||
const remaining = new Set(['choam', 'guild']);
|
||||
while (remaining.size) {
|
||||
game = advance(game, g => g.turn === 1 && g.phase === 5 && clean(g) && remaining.has(g.active!));
|
||||
const actor = game.active!, amount = actor === 'choam' ? 10 : 2;
|
||||
const before = structuredClone(game), sources = nativeShipmentSources(viewGame(game, actor), amount, 0); assert.ok(sources);
|
||||
game = arrival(applyAction(game, actor, { type: 'ship', territory: destination.id, sector: destination.sectors[0],
|
||||
amount, elite: 0, allyPayment: 0, homeworldSources: sources }));
|
||||
if (actor === 'choam') {
|
||||
assert.equal(player(game, actor).reserves, 10);
|
||||
assert.equal(player(before, actor).spice - player(game, actor).spice, 10);
|
||||
assert.equal(player(game, 'guild').spice - player(before, 'guild').spice, 10);
|
||||
assert.deepEqual(army(game, actor, 'homeworld:choam'), { normal: 10, elite: 0 });
|
||||
}
|
||||
remaining.delete(actor);
|
||||
if (remaining.size) game = applyAction(game, actor, { type: 'endMovement' });
|
||||
}
|
||||
game = openBattle(game, 'choam', 'guild', destination.id, '');
|
||||
const beforePlans = structuredClone(game);
|
||||
game = plans(game, [{ actor: 'choam', action: { type: 'battlePlan', leader: leader(game, 'choam', true), dial: 1, support: 1 } },
|
||||
{ actor: 'guild', action: { type: 'battlePlan', leader: leader(game, 'guild'), dial: 0, support: 0 } }]);
|
||||
game = advance(game, g => !g.battle && clean(g) && !g.pendingTreacheryDiscard);
|
||||
assert.equal(game.lastBattleContext!.winner, 'choam');
|
||||
assert.equal(player(game, 'choam').tanks, player(beforePlans, 'choam').tanks + 1);
|
||||
assert.equal(player(game, 'choam').forces[`${destination.id}:${destination.sectors[0]}`], 9);
|
||||
assert.equal(player(game, 'choam').leaders.some(l => l.id === 'choam-auditor'), true);
|
||||
inventory(game);
|
||||
});
|
||||
|
||||
void test('original Tleilaxu native death returns training once, real revival offers native discs only, and actual foreign ghola pays without moving the physical owner', () => {
|
||||
for (const nativeTrainer of [false, true]) {
|
||||
const f = setup({ advanced: true, expansions: ['ix'], roster: ['tleilaxu', 'guild', 'fremen'],
|
||||
skill: 'prana-bindu-adept', startingKinds: ['projectile', 'projectile', 'worthless'], tech: true });
|
||||
let game = movement(f.afterSetup, 'guild');
|
||||
game = invade(game, 'guild', 'homeworld:tleilaxu', 3);
|
||||
game = openBattle(game, 'tleilaxu', 'guild', 'homeworld:tleilaxu', nativeTrainer ? 'tleilaxu' : '');
|
||||
const trained = game.leaderSkills!.assignments.find(a => a.owner === 'tleilaxu')!;
|
||||
const own = nativeTrainer ? trained.leader : leader(game, 'tleilaxu', true), foreign = leader(game, 'guild');
|
||||
const ownWeapon = player(game, 'tleilaxu').hand.find(c => c.kind === 'projectile')!.id;
|
||||
const foreignWeapon = player(game, 'guild').hand.find(c => c.kind === 'projectile')!.id;
|
||||
game = plans(game, [{ actor: 'tleilaxu', action: { type: 'battlePlan', leader: own, dial: 1, support: 1, weapon: ownWeapon } },
|
||||
{ actor: 'guild', action: { type: 'battlePlan', leader: foreign, dial: 0, support: 0, weapon: foreignWeapon } }]);
|
||||
game = advance(game, g => !g.battle && clean(g) && !g.pendingTreacheryDiscard);
|
||||
assert.equal(player(game, 'tleilaxu').leaders.find(l => l.id === own)!.dead, true);
|
||||
assert.equal(player(game, 'guild').leaders.find(l => l.id === foreign)!.dead, true);
|
||||
assert.equal(game.leaderSkills!.assignments.some(a => a.leader === trained.leader), !nativeTrainer);
|
||||
game = advance(game, g => g.turn === 2 && g.phase === 4 && clean(g));
|
||||
if (nativeTrainer) {
|
||||
const before = structuredClone(player(game, 'tleilaxu'));
|
||||
game = applyAction(game, 'tleilaxu', { type: 'reviveLeader', leader: own });
|
||||
game = advance(game, g => !!g.leaderSkills!.offers.tleilaxu && !g.response);
|
||||
assert.equal(player(game, 'tleilaxu').leaders.find(l => l.id === own)!.dead, false);
|
||||
assert.ok(player(game, 'tleilaxu').spice < before.spice);
|
||||
policies(game, 'tleilaxu', a => a.type === 'leaderSkill');
|
||||
assert.ok(viewGame(game, 'tleilaxu').leaderSkills!.eligibleLeaders.some(l => l.id === own));
|
||||
} else {
|
||||
const before = structuredClone(game);
|
||||
reject(game, 'guild', { type: 'reviveForeignGhola', leader: foreign });
|
||||
game = applyAction(game, 'tleilaxu', { type: 'reviveForeignGhola', leader: foreign });
|
||||
game = advance(game, g => !g.pendingRevival && !g.response && !g.decision);
|
||||
const ghola = player(game, 'guild').leaders.find(l => l.id === foreign)!;
|
||||
assert.equal(ghola.dead, false); assert.equal(ghola.gholaBy, 'tleilaxu');
|
||||
assert.equal(player(game, 'tleilaxu').leaders.some(l => l.id === foreign), false);
|
||||
assert.ok(player(game, 'tleilaxu').spice < player(before, 'tleilaxu').spice);
|
||||
assert.deepEqual(game.leaderSkills, before.leaderSkills);
|
||||
assert.ok(!viewGame(game, 'tleilaxu').leaderSkills!.eligibleLeaders.some(l => l.id === foreign));
|
||||
assert.deepEqual(game.homeworlds!.custody, before.homeworlds!.custody);
|
||||
}
|
||||
inventory(game);
|
||||
}
|
||||
});
|
||||
|
||||
void test('actual native Homeworld forbids a visiting Tleilaxu Face Dance even with the genuinely drawn matching trainer identity', () => {
|
||||
const f = sukProgramme({ advanced: true, visitor: false, band: 'skilled', tech: true });
|
||||
assert.ok(player(f.afterSetup, 'tleilaxu').faceDancers!.some(c => c.leader === f.trainer && !c.revealed));
|
||||
let game = advance(plans(f.game, f.plans), g => g.decision?.kind === 'sukRescue');
|
||||
const d = game.decision; assert.ok(d?.kind === 'sukRescue');
|
||||
game = applyAction(game, f.owner, { type: 'decision', event: d.event, choice: 0 });
|
||||
game = advance(game, g => !g.battle && clean(g) && g.phase === 7);
|
||||
assert.notEqual(game.decision?.kind, 'faceDance');
|
||||
assert.equal(player(game, 'tleilaxu').faceDancers!.find(c => c.leader === f.trainer)!.revealed, false);
|
||||
const protectedArmy = army(game, f.owner, f.territory);
|
||||
reject(game, 'tleilaxu', { type: 'decision', reveal: true, sources: { reserves: 1 }, sector: 0 });
|
||||
assert.equal(player(game, f.owner).leaders.find(l => l.id === f.trainer)!.dead, false);
|
||||
assert.deepEqual(army(game, f.owner, f.territory), protectedArmy);
|
||||
inventory(game);
|
||||
});
|
||||
|
||||
void test('genuine Shrine armies retain earned Stronghold ownership and complete original card cleanup and Tech reward before a matching Face Dance', () => {
|
||||
const f = discoveryProgramme({ advanced: true, face: 'shrine', tech: true, strongholds: true });
|
||||
let game = movement(enter(f), 'tleilaxu', 2);
|
||||
const sources = nativeShipmentSources(viewGame(game, 'tleilaxu'), 2, 0); assert.ok(sources);
|
||||
game = arrival(applyAction(game, 'tleilaxu', { type: 'ship', territory: 'shrine', sector: 0,
|
||||
amount: 2, elite: 0, allyPayment: 0, homeworldSources: sources }));
|
||||
game = openBattle(game, 'ixians', 'tleilaxu', 'shrine');
|
||||
const trainer = game.leaderSkills!.assignments.find(a => a.owner === 'ixians')!.leader;
|
||||
assert.ok(player(game, 'tleilaxu').faceDancers!.some(c => c.leader === trainer && !c.revealed));
|
||||
assert.equal(game.strongholdCards!.owners.hidden_mobile_stronghold, 'ixians');
|
||||
const weapon = player(game, 'ixians').hand.find(c => c.kind === 'worthless')!.id;
|
||||
game = plans(game, [{ actor: 'ixians', action: { type: 'battlePlan', leader: trainer, dial: 0, support: 0, weapon } },
|
||||
{ actor: 'tleilaxu', action: { type: 'battlePlan', leader: leader(game, 'tleilaxu'), dial: 0, support: 0 } }]);
|
||||
game = advance(game, g => g.decision?.kind === 'battleCards');
|
||||
assert.equal(game.techTokens!.axlotl.owner, 'tleilaxu', 'The actual card cleanup still precedes the reward.');
|
||||
policies(game, 'ixians', a => a.type === 'decision' && a.discard !== undefined);
|
||||
game = applyAction(game, 'ixians', { type: 'decision', discard: [] });
|
||||
game = advance(game, g => g.decision?.kind === 'faceDance');
|
||||
assert.equal(game.techTokens!.axlotl.owner, 'ixians');
|
||||
const before = structuredClone(game), prior = structuredClone(player(game, 'ixians'));
|
||||
const action: Action = { type: 'decision', reveal: true, sources: { reserves: 1 }, sector: 0 };
|
||||
reject(game, 'ixians', action);
|
||||
reject(game, 'tleilaxu', { ...action, sources: { reserves: 3 } });
|
||||
policies(game, 'tleilaxu', a => a.type === 'decision' && a.reveal !== undefined);
|
||||
game = applyAction(game, 'tleilaxu', action);
|
||||
assert.equal(player(game, 'ixians').forces['shrine:0'] ?? 0, 0);
|
||||
assert.equal(player(game, 'ixians').reserves, prior.reserves + 2);
|
||||
assert.equal(player(game, 'ixians').elites!.reserves, prior.elites!.reserves + 1);
|
||||
assert.equal(player(game, 'tleilaxu').forces['shrine:0'], 1);
|
||||
assert.equal(player(game, 'tleilaxu').reserves, player(before, 'tleilaxu').reserves - 1);
|
||||
assert.equal(player(game, 'ixians').leaders.find(l => l.id === trainer)!.dead, true);
|
||||
assert.equal(game.leaderSkills!.assignments.some(a => a.leader === trainer), false);
|
||||
assert.equal(game.leaderSkills!.deck.filter(id => id === 'suk-graduate').length, 1);
|
||||
assert.deepEqual(game.techTokens, before.techTokens);
|
||||
assert.deepEqual(player(game, 'ixians').hand, prior.hand);
|
||||
assert.equal(player(game, 'ixians').spice, prior.spice);
|
||||
assert.equal(player(game, 'ixians').tanks, prior.tanks);
|
||||
assert.equal(player(game, 'tleilaxu').spice, player(before, 'tleilaxu').spice);
|
||||
assert.equal(player(game, 'tleilaxu').faceDancers!.find(c => c.leader === trainer)!.revealed, true);
|
||||
inventory(game);
|
||||
});
|
||||
|
||||
void test('original trained Richese ordinary arrival retains one nested invoice and original marker custody', () => {
|
||||
const f = discoveryProgramme({ advanced: true, expansions: ['choam'], roster: ['richese', 'choam', 'guild', 'fremen'],
|
||||
collector: 'richese', face: 'cistern', skill: 'planetologist', tech: true, strongholds: true });
|
||||
let game = movement(enter(f), 'richese', 2);
|
||||
const before = structuredClone(game), sources = nativeShipmentSources(viewGame(game, 'richese'), 1, 0); assert.ok(sources);
|
||||
assert.equal(player(game, 'richese').forces['cistern:0'], 2);
|
||||
const action: Action = { type: 'ship', territory: 'cistern', sector: 0, amount: 1, elite: 0,
|
||||
allyPayment: 0, homeworldSources: sources };
|
||||
game = arrival(applyAction(game, 'richese', action));
|
||||
assert.equal(player(game, 'richese').forces['cistern:0'], 3);
|
||||
assert.equal(player(game, 'richese').reserves, player(before, 'richese').reserves - 1);
|
||||
assert.equal(player(game, 'richese').spice, player(before, 'richese').spice - 1);
|
||||
assert.equal(player(game, 'guild').spice, player(before, 'guild').spice + 1);
|
||||
assert.equal(player(game, 'richese').noField!.deployed, null);
|
||||
assert.deepEqual(player(game, 'richese').noField!.tokens, player(before, 'richese').noField!.tokens);
|
||||
reject(game, 'richese', action); inventory(game);
|
||||
});
|
||||
|
||||
void test('actually earned Arrakeen support subsidy applies only at its held source, not a low native CHOAM Homeworld battle', () => {
|
||||
const balanceChanges: number[] = [];
|
||||
for (const nativeBattle of [false, true]) {
|
||||
const f = setup({ advanced: true, expansions: ['choam'], roster: ['choam', 'richese', 'guild', 'fremen'],
|
||||
skill: 'smuggler', tech: true, strongholds: true });
|
||||
let game = movement(f.afterSetup, 'choam');
|
||||
const sources = nativeShipmentSources(viewGame(game, 'choam'), 8, 0); assert.ok(sources);
|
||||
game = arrival(applyAction(game, 'choam', { type: 'ship', territory: 'arrakeen', sector: 10,
|
||||
amount: 8, elite: 0, smuggler: true, allyPayment: 0, homeworldSources: sources }));
|
||||
assert.equal(game.strongholdCards!.owners.arrakeen, null);
|
||||
game = advance(game, g => g.turn === 2 && g.phase === 5 && clean(g));
|
||||
assert.equal(game.strongholdCards!.owners.arrakeen, 'choam');
|
||||
assert.equal(game.strongholdCards!.claimedTurn, 1, 'Only original end-Mentat claims the physical card.');
|
||||
const destination = nativeBattle ? 'homeworld:choam' : 'arrakeen';
|
||||
const remaining = new Set(['choam', 'guild']);
|
||||
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 === 'choam') {
|
||||
const outward = nativeShipmentSources(viewGame(game, actor), 3, 0); assert.ok(outward);
|
||||
game = arrival(applyAction(game, actor, { type: 'ship', territory: 'carthag', sector: 11,
|
||||
amount: 3, elite: 0, smuggler: true, allyPayment: 0, homeworldSources: outward }));
|
||||
assert.equal(player(game, actor).reserves, 9);
|
||||
} else if (nativeBattle) game = invade(game, actor, destination, 2);
|
||||
else {
|
||||
const enemy = nativeShipmentSources(viewGame(game, actor), 2, 0); assert.ok(enemy);
|
||||
game = arrival(applyAction(game, actor, { type: 'ship', territory: destination, sector: 10,
|
||||
amount: 2, elite: 0, allyPayment: 0, homeworldSources: enemy }));
|
||||
}
|
||||
remaining.delete(actor);
|
||||
if (remaining.size) game = applyAction(game, actor, { type: 'endMovement' });
|
||||
}
|
||||
game = openBattle(game, 'choam', 'guild', destination, '');
|
||||
const before = structuredClone(game);
|
||||
game = plans(game, [{ actor: 'choam', action: { type: 'battlePlan', leader: leader(game, 'choam', true), dial: 1, support: 1 } },
|
||||
{ actor: 'guild', action: { type: 'battlePlan', leader: leader(game, 'guild'), dial: 0, support: 0 } }]);
|
||||
game = advance(game, g => !g.battle && clean(g) && !g.pendingTreacheryDiscard);
|
||||
assert.equal(game.lastBattleContext!.winner, 'choam');
|
||||
balanceChanges.push(player(game, 'choam').spice - player(before, 'choam').spice);
|
||||
assert.equal(player(game, 'choam').tanks, player(before, 'choam').tanks + 1);
|
||||
assert.equal(game.strongholdCards!.owners.arrakeen, 'choam');
|
||||
assert.equal(player(game, 'choam').forces['arrakeen:10'], nativeBattle ? 8 : 7);
|
||||
inventory(game);
|
||||
}
|
||||
assert.equal(balanceChanges[0] - balanceChanges[1], 1, 'Only the held Arrakeen source subsidizes support; identical later stronghold income is independent.');
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
applyAction, createGame, initializeDiscoveryGameForAudit, initializeLeaderSkillsGameForAudit,
|
||||
joinGame, newPlayer, viewGame, 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 { ecazOccupancyRelation } from '../game/ecaz-occupy';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
|
||||
import {
|
||||
advanceNativeE3Homeworld as advance, assertNativeE3HomeworldCustody as custody,
|
||||
createNativeE3HomeworldBattle as battle, createNativeE3HomeworldDiscovery as discovery,
|
||||
createNativeE3HomeworldRevival as revival, createNativeE3HomeworldSetup as setup,
|
||||
invadeNativeE3Homeworld as invade,
|
||||
nativeE3HomeworldClean as clean, nativeE3HomeworldPlayer as player,
|
||||
nativeE3HomeworldPolicy as policy, nativeE3HomeworldPool as pool,
|
||||
rescueNativeE3Homeworld as rescue, revealNativeE3HomeworldPlans as reveal,
|
||||
settleNativeE3HomeworldBattle as settle, type NativeE3HomeworldOptions,
|
||||
} from './fixture-homeworld-native-e3-modules';
|
||||
|
||||
function legalPolicies(game: Game, actor: string): void {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const actions = policy(game, actor, difficulty); assert.ok(actions.length, `${difficulty} has an actual owned offer`);
|
||||
for (const action of actions) {
|
||||
const next = applyAction(structuredClone(game), actor, action);
|
||||
if (next.homeworlds?.custody) custody(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
function reject(game: Game, actor: string, action: Action, reason?: RegExp): void {
|
||||
const before = structuredClone(game);
|
||||
if (reason) assert.throws(() => applyAction(game, actor, action), reason);
|
||||
else assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
void test('original standalone Ecaz or Moritani Homeworld module envelopes use native Ecaz33, five discs, all14 before faction setup and DS7+8 at supported seat boundaries', () => {
|
||||
const modules: Pick<NativeE3HomeworldOptions, 'skills' | 'discovery'>[] = [
|
||||
{ skills: true, discovery: false }, { skills: false, discovery: true }, { skills: true, discovery: true },
|
||||
];
|
||||
for (const faction of ['ecaz', 'moritani'] as const) for (const advanced of [false, true]) {
|
||||
for (const composition of modules) for (const tech of [false, true]) for (const strongholds of advanced ? [false, true] : [false]) {
|
||||
for (const count of [tech ? 3 : 2, 6]) {
|
||||
const roster: FactionId[] = [faction, 'guild', 'emperor', 'atreides', 'fremen', 'beneGesserit'];
|
||||
const lobby = createGame('ORIGINALNATIVEHWE3', newPlayer('native-original', 'Original native', faction), advanced, ['ecaz']);
|
||||
for (const [i, f] of roster.slice(1, count).entries()) joinGame(lobby, newPlayer(`original-${i}`, `Original ${f}`, f));
|
||||
const f = setup({ initial: lobby, faction, advanced, ...composition, tech, strongholds });
|
||||
const own = player(f.afterSetup, f.owner);
|
||||
assert.equal(own.reserves, 14); assert.equal(Object.values(own.forces).reduce((a, b) => a + b, 0), 6);
|
||||
assert.equal(pool(f.afterSetup, f.owner, `homeworld:${faction}`).normal, 14);
|
||||
assert.equal(own.leaders.length, 5); assert.equal(f.afterSetup.dukeVidal!.leader.id, DUKE_VIDAL_ID);
|
||||
if (composition.skills) {
|
||||
assert.equal(f.afterSetup.leaderSkills!.assignments.length, count);
|
||||
assert.deepEqual([...f.afterSetup.leaderSkills!.deck, ...f.afterSetup.leaderSkills!.assignments.map(a => a.skill)].sort(),
|
||||
LEADER_SKILL_CARDS.map(c => c.id).sort());
|
||||
} else assert.equal(f.afterSetup.leaderSkills, undefined);
|
||||
if (composition.discovery) {
|
||||
const spice = [...f.afterSetup.spiceDeck, ...f.afterSetup.spiceDiscard.flat()];
|
||||
assert.equal(spice.filter(c => 'territory' in c && !!c.discovery).length, 6);
|
||||
assert.equal(spice.filter(c => 'worm' in c && c.greatMaker).length, 1);
|
||||
assert.equal(f.afterSetup.discoveries!.tokens.length, 8);
|
||||
} else assert.equal(f.afterSetup.discoveries, undefined);
|
||||
if (strongholds) assert.ok(Object.values(f.afterSetup.strongholdCards!.owners).every(o => o === null));
|
||||
assert.equal(!!f.afterSetup.moritaniAssassinate, advanced && faction === 'moritani');
|
||||
if (faction === 'ecaz') {
|
||||
assert.equal(f.afterSetup.ecazAmbassadors!.tokens.length, 11);
|
||||
assert.equal(f.afterSetup.ecazAmbassadors!.tokens.filter(t => t.zone === 'supply').length, 6);
|
||||
} else assert.equal(f.afterSetup.moritaniTerror!.tokens.length, 6);
|
||||
custody(f.afterSetup);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const faction of ['ecaz', 'moritani'] as const) for (const advanced of [false, true]) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} ${faction} native and visitor Suk consume real casualties and preserve owner-labelled native/visitor pools`, () => {
|
||||
for (const visitor of [false, true]) {
|
||||
const f = battle({ faction, advanced, visitor, skills: true, discovery: true, tech: true, strongholds: advanced });
|
||||
const shipper = visitor ? f.owner : f.opponent, native = visitor ? f.opponent : f.owner;
|
||||
assert.equal(player(f.afterShipment, shipper).reserves, player(f.beforeShipment, shipper).reserves - 5);
|
||||
assert.equal(pool(f.afterShipment, shipper, f.location).normal, 5);
|
||||
assert.deepEqual(pool(f.afterShipment, native, f.location), pool(f.beforeShipment, native, f.location));
|
||||
assert.ok(player(f.afterShipment, shipper).spice < player(f.beforeShipment, shipper).spice);
|
||||
assert.ok(!Object.keys(player(f.afterShipment, shipper).forces).some(key => key.startsWith('homeworld:')));
|
||||
legalPolicies(f.beforeShipment, shipper); legalPolicies(f.game, f.owner);
|
||||
const revealed = reveal(f.game, f.plans), pending = settle(revealed, 'suk');
|
||||
assert.ok(pending.decision?.kind === 'sukRescue');
|
||||
assert.equal(pending.decision.player, f.owner); assert.equal(pending.pendingSukRescue!.skill.mode, 'skilled');
|
||||
assert.equal(pending.pendingSukRescue!.territory, f.location);
|
||||
legalPolicies(pending, f.owner);
|
||||
const beforePool = pool(pending, f.owner, f.location), beforeReserve = player(pending, f.owner).reserves;
|
||||
const beforeTanks = player(pending, f.owner).tanks;
|
||||
const maximum = Math.max(...pending.decision.options.map(o => o.normal + o.elite));
|
||||
const choice = pending.decision.options.findIndex(o => o.normal + o.elite === maximum && o.kept?.kind === 'normal');
|
||||
const option = pending.decision.options[choice], losses = pending.pendingSukRescue!.losses!;
|
||||
reject(pending, f.owner, { type: 'decision', event: pending.decision.event, choice,
|
||||
destinations: { 'homeworld:emperor': { normal: option.normal, elite: 0 } } });
|
||||
const done = settle(rescue(structuredClone(pending)));
|
||||
assert.equal(done.lastBattleContext?.winner, f.owner);
|
||||
assert.equal(player(done, f.owner).tanks - beforeTanks, losses.normal + losses.elite - option.normal - option.elite);
|
||||
const afterPool = pool(done, f.owner, f.location);
|
||||
if (visitor) {
|
||||
assert.equal(afterPool.normal, beforePool.normal - losses.normal + 1);
|
||||
assert.equal(player(done, f.owner).reserves, beforeReserve + option.normal - 1);
|
||||
assert.equal(pool(done, f.owner, `homeworld:${faction}`).normal, player(done, f.owner).reserves);
|
||||
} else assert.equal(afterPool.normal, beforePool.normal - losses.normal + option.normal);
|
||||
assert.equal(done.pendingSukRescue ?? null, null); custody(done);
|
||||
}
|
||||
});
|
||||
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} ${faction} lethal native and visitor Homeworld plans kill the trained disc and return exactly one physical skill`, () => {
|
||||
for (const visitor of [false, true]) {
|
||||
const f = battle({ faction, advanced, visitor, lethal: true, skill: 'mentat', discovery: true });
|
||||
const original = player(f.game, f.owner).leaders.find(l => l.id === f.trainer)!;
|
||||
const done = settle(reveal(f.game, f.plans));
|
||||
assert.equal(player(done, f.owner).leaders.find(l => l.id === f.trainer)!.dead, true);
|
||||
assert.equal(player(done, f.owner).leaders.find(l => l.id === f.trainer)!.deaths, original.deaths + 1);
|
||||
assert.equal(done.leaderSkills!.assignments.some(a => a.leader === f.trainer), false);
|
||||
assert.equal(done.leaderSkills!.deck.filter(s => s === 'mentat').length, 1);
|
||||
const window = advance(done, g => g.turn === 2 && g.phase === 4 && clean(g));
|
||||
reject(window, f.owner, { type: 'reviveLeader', leader: f.trainer }, /All leaders must die/);
|
||||
assert.equal(window.leaderSkills!.deck.filter(s => s === 'mentat').length, 1); custody(window);
|
||||
}
|
||||
});
|
||||
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} ${faction} original parent shipment, reveal and later Discovery entry retain native reserves and printed free-entry payment`, () => {
|
||||
for (const skills of [false, true]) {
|
||||
const f = discovery({ faction, advanced, skills, tech: true, strongholds: advanced });
|
||||
assert.equal(player(f.afterShipment, f.owner).reserves, player(f.beforeShipment, f.owner).reserves - 2);
|
||||
assert.ok(player(f.afterShipment, f.owner).spice < player(f.beforeShipment, f.owner).spice);
|
||||
assert.equal(player(f.beforeReveal, f.owner).forces['gara_kulon:8'], 2);
|
||||
assert.equal(f.afterReveal.discoveries!.tokens.find(t => t.id === f.token)!.revealedTurn, 1);
|
||||
assert.equal(player(f.entered, f.owner).forces['gara_kulon:8'] ?? 0, 0);
|
||||
assert.equal(player(f.entered, f.owner).forces['cistern:0'], 2);
|
||||
assert.equal(player(f.entered, f.owner).spice, player(f.entryWindow, f.owner).spice);
|
||||
assert.equal(player(f.entered, f.owner).reserves, player(f.entryWindow, f.owner).reserves);
|
||||
assert.deepEqual(f.entered.homeworlds!.custody, f.entryWindow.homeworlds!.custody);
|
||||
legalPolicies(f.entryWindow, f.owner); custody(f.entered);
|
||||
}
|
||||
});
|
||||
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} ${faction} five paid original visitor invasions establish all-five deaths before one real own revival and private replacement training`, () => {
|
||||
const f = revival({ faction, advanced, discovery: true, tech: true, strongholds: advanced });
|
||||
assert.equal(new Set(f.deaths.map(d => d.leader)).size, 5);
|
||||
for (const [i, death] of f.deaths.entries()) {
|
||||
assert.equal(player(death.after, f.owner).leaders.filter(l => l.dead).length, i + 1);
|
||||
assert.equal(player(death.after, f.owner).leaders.find(l => l.id === death.leader)!.deaths,
|
||||
player(death.before, f.owner).leaders.find(l => l.id === death.leader)!.deaths + 1);
|
||||
assert.equal(death.after.lastBattleContext?.winner, f.opponent);
|
||||
assert.equal(player(death.after, f.owner).tanks, player(death.before, f.owner).tanks + 1);
|
||||
assert.equal(pool(death.after, f.owner, 'homeworld:guild').normal, 0);
|
||||
assert.equal(player(death.after, f.opponent).hand.filter(c => c.id === death.weapon).length, 1);
|
||||
assert.equal(death.after.discard.some(c => c.id === death.weapon), false); custody(death.after);
|
||||
}
|
||||
const trainer = player(f.revivalWindow, f.owner).leaders.find(l => l.id === f.trainer)!;
|
||||
assert.equal(player(f.offeredRevival, f.owner).spice, player(f.revivalWindow, f.owner).spice - trainer.strength);
|
||||
assert.equal(player(f.offeredRevival, f.owner).leaders.find(l => l.id === f.trainer)!.dead, false);
|
||||
assert.equal(player(f.offeredRevival, f.owner).leaders.filter(l => l.dead).length, 4);
|
||||
assert.equal(viewGame(f.offeredRevival, f.opponent).leaderSkills!.offer, null);
|
||||
assert.deepEqual(viewGame(f.offeredRevival, f.owner).leaderSkills!.offer!.cards, []);
|
||||
legalPolicies(f.offeredRevival, f.owner);
|
||||
const event = f.offeredRevival.leaderSkills!.offers[f.owner].event;
|
||||
const drawn = applyAction(structuredClone(f.offeredRevival), f.owner, { type: 'leaderSkill', event, mode: 'draw' });
|
||||
const own = viewGame(drawn, f.owner).leaderSkills!, skill = own.offer!.cards.find(s => !own.unavailableSkills?.[s]);
|
||||
assert.ok(skill); assert.equal(own.offer!.cards.length, 2);
|
||||
const assigned = applyAction(drawn, f.owner, { type: 'leaderSkill', event, skill, leader: f.trainer });
|
||||
assert.equal(assigned.leaderSkills!.assignments.find(a => a.owner === f.owner)!.leader, f.trainer);
|
||||
assert.equal(player(assigned, f.owner).spice, player(f.offeredRevival, f.owner).spice); custody(assigned);
|
||||
});
|
||||
}
|
||||
|
||||
void test('Advanced native Moritani resolves Guild Suk BEFORE assassination, returns the exact trainer card, pays its printed bounty, then draws one private Mentat replacement', () => {
|
||||
const f = battle({ faction: 'moritani', advanced: true, assassination: true, discovery: true,
|
||||
tech: true, strongholds: true, battleTurn: 2 });
|
||||
assert.equal(f.game.strongholdCards!.owners.tueks_sietch, f.opponent);
|
||||
const revealed = reveal(f.game, f.plans), pending = settle(revealed, 'assassination');
|
||||
assert.deepEqual(pending.lastBattleContext!.sukRescue!.physical!.saved, { normal: 1, elite: 0 });
|
||||
assert.equal(pending.lastBattleContext?.sukRescue?.completed, true);
|
||||
assert.equal(player(pending, f.opponent).spice, player(revealed, f.opponent).spice - 3,
|
||||
'Holding Tuek never applies its Worthless income to a Homeworld battle.');
|
||||
const target = pending.leaderSkills!.assignments.find(a => a.owner === f.opponent)!.leader;
|
||||
const leader = player(pending, f.opponent).leaders.find(l => l.id === target)!;
|
||||
assert.equal(leader.name, 'Master Bewt'); assert.ok(player(pending, f.owner).traitors.includes(target));
|
||||
assert.notEqual(revealed.battle!.plans[f.opponent].leader, target);
|
||||
const reserve = [...pending.traitorReserve!], ownTraitors = [...player(pending, f.owner).traitors];
|
||||
legalPolicies(pending, f.owner);
|
||||
const decision = pending.decision; assert.ok(decision?.kind === 'moritaniAssassinate');
|
||||
let game = applyAction(structuredClone(pending), f.owner, { type: 'decision', event: decision.event, card: target });
|
||||
assert.equal(player(game, f.opponent).leaders.find(l => l.id === target)!.dead, true);
|
||||
assert.equal(player(game, f.owner).spice, player(pending, f.owner).spice + leader.strength);
|
||||
assert.equal(game.leaderSkills!.assignments.some(a => a.leader === target), false);
|
||||
assert.equal(game.leaderSkills!.deck.filter(s => s === 'suk-graduate').length, 1);
|
||||
assert.equal(player(game, f.opponent).tanks, player(pending, f.opponent).tanks);
|
||||
assert.deepEqual(game.homeworlds!.custody, pending.homeworlds!.custody);
|
||||
const lost = ownedTech(pending.techTokens, f.owner); assert.ok(lost.length);
|
||||
game = settle(game, 'tech');
|
||||
let token = lost[0];
|
||||
if (game.decision?.kind === 'techToken') {
|
||||
assert.equal(game.decision.player, f.opponent);
|
||||
legalPolicies(game, f.opponent);
|
||||
reject(game, f.opponent, { type: 'decision', decline: true });
|
||||
token = game.decision.choices[0];
|
||||
game = applyAction(game, f.opponent, { type: 'decision', token });
|
||||
game = settle(game);
|
||||
}
|
||||
assert.equal(game.techTokens![token].owner, f.opponent);
|
||||
assert.equal(player(game, f.opponent).tanks, player(pending, f.opponent).tanks);
|
||||
game = advance(game, g => g.phase === 8);
|
||||
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.stage, 'replaced');
|
||||
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.replacement, reserve[0]);
|
||||
assert.deepEqual(game.traitorReserve, reserve.slice(1));
|
||||
assert.deepEqual(player(game, f.owner).traitors, [...ownTraitors.filter(c => c !== target), reserve[0]]);
|
||||
assert.equal('replacement' in viewGame(game, f.opponent).moritaniAssassinate!.history.at(-1)!, false);
|
||||
game = advance(game, g => g.turn === 3 && g.phase === 4 && clean(g));
|
||||
reject(game, f.opponent, { type: 'reviveLeader', leader: target }, /All leaders must die/); custody(game);
|
||||
});
|
||||
|
||||
void test('Basic native Moritani never acquires Advanced assassination after the same original Homeworld loss and normal Guild rescue', () => {
|
||||
const f = battle({ faction: 'moritani', advanced: false, assassination: true, discovery: true, tech: true });
|
||||
const game = settle(reveal(f.game, f.plans));
|
||||
const target = game.leaderSkills!.assignments.find(a => a.owner === f.opponent)!.leader;
|
||||
assert.deepEqual(game.lastBattleContext!.sukRescue!.physical!.saved, { normal: 1, elite: 0 });
|
||||
assert.equal(game.lastBattleContext?.winner, f.opponent); assert.equal(game.moritaniAssassinate, undefined);
|
||||
assert.equal(player(game, f.opponent).leaders.find(l => l.id === target)!.dead, false);
|
||||
assert.equal(game.leaderSkills!.assignments.find(a => a.leader === target)!.skill, 'suk-graduate'); custody(game);
|
||||
});
|
||||
|
||||
void test('Homeworlds remain distinct seats rather than Ecaz Occupy territories, and Duke/Harkonnen/E3 pair exclusions remain original guards', () => {
|
||||
const seats = [{ id: 'ecaz', faction: 'ecaz' as const, ally: 'guild' },
|
||||
{ id: 'guild', faction: 'guild' as const, ally: 'ecaz' }, { id: 'bg', faction: 'beneGesserit' as const, ally: null }];
|
||||
assert.equal(ecazOccupancyRelation(seats, 'ecaz', 'guild', { kind: 'territory', id: 'arrakeen' }), 'ecazAlliance');
|
||||
assert.equal(ecazOccupancyRelation(seats, 'ecaz', 'guild', { kind: 'homeworld', id: 'homeworld:ecaz' }), 'different');
|
||||
const f = setup({ faction: 'ecaz', advanced: true });
|
||||
legalPolicies(f.offered, f.owner);
|
||||
const offer = viewGame(f.offered, f.owner).leaderSkills!.offer!;
|
||||
reject(f.offered, f.owner, { type: 'leaderSkill', event: offer.event, skill: offer.cards[0], leader: DUKE_VIDAL_ID });
|
||||
for (const roster of [['ecaz', 'moritani', 'guild'], ['ecaz', 'harkonnen', 'guild'], ['moritani', 'harkonnen', 'guild']] as FactionId[][]) {
|
||||
let game = createGame('NATIVEHWGUARDS', newPlayer(roster[0], roster[0], roster[0]), true, ['ecaz']);
|
||||
for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
|
||||
game = applyAction(game, game.host, { type: 'homeworlds', enabled: true }); game.discoveryEnabled = true;
|
||||
for (const p of game.players) game = applyAction(game, p.id, { type: 'ready' });
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => initializeLeaderSkillsGameForAudit(game)); assert.deepEqual(game, before);
|
||||
if (roster[0] !== 'ecaz' || roster[1] !== 'harkonnen')
|
||||
assert.throws(() => initializeDiscoveryGameForAudit(game));
|
||||
assert.deepEqual(game, before);
|
||||
assert.equal(game.homeworlds!.custody, null);
|
||||
}
|
||||
const basic = setup({ faction: 'ecaz', advanced: false, roster: ['ecaz', 'harkonnen', 'guild'], discovery: true });
|
||||
assert.equal(player(basic.afterSetup, 'harkonnen').hand.length, 2); custody(basic.afterSetup);
|
||||
});
|
||||
|
||||
void test('actual Ecaz arrival at native Wallach IX never opens BG Intrusion or a territory Occupy battle', () => {
|
||||
const f = setup({ faction: 'ecaz', advanced: true, roster: ['ecaz', 'guild', 'beneGesserit'], discovery: true });
|
||||
const arrival = invade(f.afterSetup, f.owner, 'homeworld:beneGesserit', 3);
|
||||
assert.equal(pool(arrival.after, f.owner, 'homeworld:beneGesserit').normal, 3);
|
||||
assert.deepEqual(pool(arrival.after, 'beneGesserit', 'homeworld:beneGesserit'),
|
||||
pool(arrival.before, 'beneGesserit', 'homeworld:beneGesserit'));
|
||||
assert.equal(arrival.after.decision, null);
|
||||
assert.equal(arrival.after.pendingAmbassador ?? null, null);
|
||||
assert.ok(!Object.keys(player(arrival.after, 'beneGesserit').advisors ?? {}).some(key => key.startsWith('homeworld:')));
|
||||
assert.ok(!Object.keys(player(arrival.after, f.owner).forces).some(key => key.startsWith('homeworld:')));
|
||||
custody(arrival.after);
|
||||
});
|
||||
Reference in new issue
Block a user