Compose original single-native E1 E2 Nexus modules and skills

This commit is contained in:
Quintine committed 2026-10-07 11:50:25 +11:00
1 parent 204d91902d
commit 30a0bdbbb6
19 files changed
+1802 -53

No files matched your search

+1 -4
View File
@@ -70,10 +70,7 @@ void test('Discovery Skills cannot replace already initialized Discovery compone
assert.throws(() => initializeLeaderSkillsGameForAudit(started), RuleError);
});
void test('Discovery Nexus rejects native families, used components and incompatible prerequisites before redealing', () => {
const native = lobby(['ixians', 'guild', 'fremen'], ['ix']);
native.nexusCards = { cards: null, phase: null };
assert.throws(() => initializeNexusGameForAudit(native), RuleError);
void test('Discovery Nexus rejects used components and incompatible prerequisites before redealing', () => {
const two = lobby(['guild', 'emperor'], []);
two.nexusCards = { cards: null, phase: null }; two.techTokens = createTechTokens();
assert.throws(() => initializeNexusGameForAudit(two), RuleError);
+373
View File
@@ -0,0 +1,373 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeLeaderSkillsGameForAudit, initializeNexusGameForAudit,
joinGame, newPlayer, viewGame,
} from '../game/engine';
import type { Action, Game } from '../game/engine';
import type { FactionId } from '../game/catalog';
import { territory } from '../game/board';
import { treacheryDeck } from '../game/cards';
import type { Card } from '../game/cards';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import type { LeaderSkillId } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
import { traitorDeck } from '../game/traitors';
import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { combatArmy, homeworldBattleLocation } from '../game/combat-location';
import { quoteBattleResolution } from '../game/battle-resolution-quote';
import type { BattleResolutionQuote, ResolutionCombatant } from '../game/battle-resolution-quote';
import { DISCOVERY_CARD_PLACEMENTS, validateDiscoveryState } from '../game/discoveries';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { greatMakerRideAction } from '../game/great-maker-options';
import {
nativeHomeworldInventory, nativeHomeworldLeader, nativeHomeworldPlayer, frontNativeHomeworldSpice,
} from './fixture-homeworld-native-e1e2-modules';
import { nextHomeworldSkillsDiscoveryStep } from './fixture-homeworld-skills-discovery';
import { withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken } from './fixture-discovery-classic-nexus';
export type SingleNexusE1Native = 'ixians' | 'tleilaxu';
export type SingleNexusE1Step = { actor: string; action: Action };
export type SingleNexusE1Transition = { before: Game; step: SingleNexusE1Step; after: Game };
export type SingleNexusE1Options = {
/** Only an original lobby or its unassigned turn-one setup; saved deals are authoritative. */
initial?: Game;
native?: SingleNexusE1Native;
roster?: readonly FactionId[];
advanced?: boolean;
skills?: boolean;
homeworlds?: boolean;
discovery?: boolean;
tech?: boolean;
strongholds?: boolean;
startingKinds?: readonly Card['kind'][];
collectorSkill?: LeaderSkillId;
};
export type SingleNexusE1Setup = {
initial: Game; setup: Game; offered: Game | null; ixOffer: SingleNexusE1Transition | null;
afterSetup: Game; owner: string; guild: string; fremen: string; collector: string;
trainer: string | null; dancerLeader: string; actions: SingleNexusE1Step[];
};
export type SingleNexusE1Programme = SingleNexusE1Setup & {
game: Game; firstArrival: SingleNexusE1Transition | null;
reveal: SingleNexusE1Transition | null; entry: SingleNexusE1Transition | null;
vote: Game | null; ride: SingleNexusE1Transition | null;
firstMentat: SingleNexusE1Transition; alliance: Game; drawing: Game; draw: SingleNexusE1Transition;
};
export const singleNexusE1Reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
export const singleNexusE1Player = nativeHomeworldPlayer;
export const singleNexusE1Army = combatArmy;
export const singleNexusE1Leader = nativeHomeworldLeader;
export const singleNexusE1Clean = (g: Game): boolean =>
!g.response && !g.phaseOpening && !g.decision && !g.pendingTreacheryDiscard && !g.truthtrance;
export function singleNexusE1Inventory(g: Game): void {
if (g.homeworlds) nativeHomeworldInventory(g);
else {
for (const p of g.players) {
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((n, f) => n + f, 0), 20);
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks +
Object.values(p.elites.forces).reduce((n, f) => n + f, 0), p.faction === 'ixians' ? 7 : p.faction === 'emperor' ? 5 : 3);
}
assert.deepEqual([...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand),
...(g.ixSetupCards ?? []), ...(g.auction?.cards.slice(g.auction.index) ?? [])].map(c => c.id).sort(),
treacheryDeck(g.expansions).map(c => c.id).sort());
if (g.leaderSkills) validateLeaderSkills(g.leaderSkills, g.players);
if (g.discoveries) validateDiscoveryState(g.discoveries);
}
validateNexusCards(g.nexusCards!.cards!, g.players);
}
function permutation(source: readonly string[], desired: readonly string[]): number[] {
assert.deepEqual([...source].sort(), [...desired].sort());
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) * 0x100000000 / (i + 1)));
[current[i], current[j]] = [current[j], current[i]];
}
assert.deepEqual(current, desired); return rolls;
}
function originalLottery<T>(rolls: readonly number[], operation: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto); let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++] ?? 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 stepSingleNexusE1(g: Game, step: SingleNexusE1Step, actions?: SingleNexusE1Step[]): Game {
actions?.push(structuredClone(step));
return withClassicDiscoveryNexusLottery(() => applyAction(singleNexusE1Reload(g), step.actor, step.action));
}
function transition(g: Game, step: SingleNexusE1Step, actions: SingleNexusE1Step[]): SingleNexusE1Transition {
return { before: singleNexusE1Reload(g), step: structuredClone(step), after: stepSingleNexusE1(g, step, actions) };
}
/** Stop at every meaningful owned aftermath; never invent a plan, rescue or replacement. */
export function nextSingleNexusE1Step(g: Game): SingleNexusE1Step | null {
if (singleNexusE1Clean(g) && g.nexusCards?.phase?.stage === 'drawing') {
const actor = g.nexusCards.phase.eligible.find(id => !g.nexusCards!.phase!.done.includes(id)); assert.ok(actor);
return { actor, action: { type: 'nexusCardChoice', turn: g.turn,
card: g.nexusCards.cards!.hands[actor] ?? null, choice: 'keep', ownRedraws: 0 } };
}
if (!g.response && !g.phaseOpening && g.decision) {
const d = g.decision;
if (['sukRescue', 'ixSubstitution', 'battleCards', 'faceDance', 'strongholdCopy'].includes(d.kind) ||
d.kind === 'techToken' && !!g.battle) return null;
if (d.kind === 'leaderSkillVisibility') return { actor: d.player,
action: { type: 'leaderSkillVisibility', event: d.event, hide: true } };
if (d.kind === 'guildShipment' || d.kind === 'homeworldShipmentGuild') return { actor: d.player,
action: { type: 'decision', allow: true, ...(d.kind === 'homeworldShipmentGuild' ? { event: d.event } : {}) } };
if (d.kind === 'battleLosses') {
const elite = Math.max(...d.options.map(o => o.elite));
return { actor: d.player, action: { type: 'decision', choice: d.options.findIndex(o => o.elite === elite) } };
}
if (d.kind === 'techToken') return { actor: d.player, action: { type: 'decision', token: d.choices[0] } };
}
return nextHomeworldSkillsDiscoveryStep(g);
}
export function advanceSingleNexusE1(g: Game, until: (g: Game) => boolean, actions?: SingleNexusE1Step[]): Game {
for (let n = 0; n < 3000; n++) {
if (until(g)) return g;
assert.notEqual(g.status, 'finished');
const next = nextSingleNexusE1Step(g);
assert.ok(next, `Choose the original ${g.decision?.kind ?? 'sealed plan'} at ${g.turn}/${g.phase}.`);
g = stepSingleNexusE1(g, next, actions);
}
throw Error('Original single E1 chronology did not reach its owned boundary.');
}
export function settleSingleNexusE1(g: Game, actions?: SingleNexusE1Step[]): Game {
return advanceSingleNexusE1(g, s => singleNexusE1Clean(s) && !s.pendingShipment && !s.pendingHomeworldShipment, actions);
}
export function createSingleNexusE1Setup(options: SingleNexusE1Options = {}): SingleNexusE1Setup {
const native = options.native ?? (options.initial?.players.some(p => p.faction === 'tleilaxu') ? 'tleilaxu' : 'ixians');
const roster: readonly FactionId[] = options.roster ?? [native, 'guild', 'fremen'];
let g = options.initial ? singleNexusE1Reload(options.initial)
: createGame('SINGLENEXUSE1', newPlayer(roster[0], roster[0], roster[0]), options.advanced ?? true, ['ix']);
if (!options.initial) for (const faction of roster.slice(1)) joinGame(g, newPlayer(faction, faction, faction));
assert.deepEqual(g.expansions, ['ix']); assert.equal(g.advanced, options.advanced ?? g.advanced);
assert.equal(g.players.filter(p => p.faction === native).length, 1);
assert.ok(g.players.every(p => p.faction === native || ['guild', 'fremen', 'emperor', 'atreides', 'harkonnen', 'beneGesserit'].includes(p.faction)));
assert.ok(g.status === 'lobby' || g.status === 'setup' && g.turn === 1 && g.phase === 0 &&
(!g.leaderSkills || g.leaderSkills.assignments.length === 0) && g.players.every(p => !p.traitors.length &&
!p.faceDancers?.length && p.leaders.every(l => !l.dead && !l.usedAt)), 'Never convert a played save.');
const skills = options.skills ?? !!g.leaderSkills, actions: SingleNexusE1Step[] = [];
const flags = { homeworlds: options.homeworlds ?? !!g.homeworlds,
techTokens: options.tech ?? !!g.techTokens, strongholdCards: options.strongholds ?? !!g.strongholdCards };
const discovery = options.discovery ?? !!g.discoveryEnabled;
const owner = g.players.find(p => p.faction === native)!.id;
const guild = g.players.find(p => p.faction === 'guild')?.id ?? '';
const fremen = g.players.find(p => p.faction === 'fremen')?.id ?? '';
const collector = native === 'ixians' ? owner : guild;
const target = nativeHomeworldPlayer(g, guild || owner).leaders.slice().sort((a, b) => b.strength - a.strength)[0].id;
const selected: LeaderSkillId[] = [];
for (const p of g.players) {
const desired: LeaderSkillId = p.id === collector ? options.collectorSkill ?? 'suk-graduate' : 'prana-bindu-adept';
selected.push(!selected.includes(desired) ? desired :
(['swordmaster-of-ginaz', 'warmaster', 'killer-medic', 'sandmaster', 'planetologist'] as const).find(s => !selected.includes(s))!);
}
if (g.status === 'lobby') {
for (const [type, enabled] of Object.entries(flags)) if (!!g[type as keyof typeof flags] !== enabled)
g = stepSingleNexusE1(g, { actor: g.host, action: { type, enabled } }, actions);
g.discoveryEnabled = discovery; g.nexusCards ??= { cards: null, phase: null };
} else {
for (const [type, enabled] of Object.entries(flags)) assert.equal(!!g[type as keyof typeof flags], enabled);
assert.equal(!!g.discoveryEnabled, discovery); assert.equal(!!g.leaderSkills, skills);
assert.ok(g.nexusCards?.cards, 'A saved original deal cannot acquire a new Nexus deck.');
}
const initial = singleNexusE1Reload(g);
if (g.status === 'lobby') {
for (const p of g.players) if (!p.ready) g = stepSingleNexusE1(g, { actor: p.id, action: { type: 'ready' } }, actions);
const skillSource = LEADER_SKILL_CARDS.map(c => c.id), rest = skillSource.filter(s => !selected.includes(s));
const skillOrder = selected.flatMap(s => [s, rest.shift()!]).concat(rest);
const cards = treacheryDeck(g.expansions), remaining = [...cards], prefix: Card[] = [];
for (const [i, p] of g.players.entries()) for (let j = 0; j < (p.faction === 'harkonnen' ? 2 : 1); j++) {
const kind = options.startingKinds?.[i] ?? (i < 5 ? 'worthless' : 'shield');
const at = remaining.findIndex(c => c.kind === kind); assert.ok(at >= 0); prefix.push(remaining.splice(at, 1)[0]);
}
const rolls = [...(skills ? permutation(skillSource, skillOrder) : []),
...(discovery ? Array<number>(7).fill(0xffffffff) : []), ...Array<number>(NEXUS_FACTIONS.length - 1).fill(0xffffffff),
...permutation(cards.map(c => c.id), [...prefix, ...remaining].map(c => c.id))];
// A supplied lobby's original lottery is not overwritten.
g = options.initial ? (skills ? initializeLeaderSkillsGameForAudit(g) : initializeNexusGameForAudit(g))
: originalLottery(rolls, () => skills ? initializeLeaderSkillsGameForAudit(g) : initializeNexusGameForAudit(g));
}
const setup = singleNexusE1Reload(g);
let offered: Game | null = null, ixOffer: SingleNexusE1Transition | null = null, trainer: string | null = null;
for (let n = 0; g.status === 'setup' && n < 250; n++) {
let next: SingleNexusE1Step;
if (g.decision?.kind === 'ixSetup') {
const actor = g.decision.player, offer = viewGame(g, actor).ixTechnology!.setup!;
const card = offer.find(c => c.kind === 'worthless') ?? offer[0]; assert.ok(card);
ixOffer = transition(g, { actor, action: { type: 'decision', card: card.id } }, actions); g = ixOffer.after; continue;
}
if (g.setupStage === 'leaderSkills') {
offered ??= singleNexusE1Reload(g);
const actor = Object.keys(g.leaderSkills!.offers)[0], view = viewGame(g, actor).leaderSkills!, offer = view.offer!;
const wanted = selected[g.players.findIndex(p => p.id === actor)];
const skill = options.initial ? offer.cards.find(s => !view.unavailableSkills?.[s])
: offer.cards.find(s => s === wanted && !view.unavailableSkills?.[s]); assert.ok(skill);
const eligible = new Set(view.eligibleLeaders.map(l => l.id));
const leader = nativeHomeworldPlayer(g, actor).leaders.filter(l => eligible.has(l.id)).sort((a, b) => b.strength - a.strength)[0]; assert.ok(leader);
if (actor === collector) trainer = leader.id;
next = { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } };
} else {
const candidate = nextSingleNexusE1Step(g); assert.ok(candidate); next = candidate;
if (next.action.type === 'traitor') {
const choices = nativeHomeworldPlayer(g, next.actor).traitorChoices;
next.action.leader = choices.find(id => id !== (trainer ?? target)) ?? choices[0];
}
}
if (next.action.type === 'fremenSetup') next.action.placements = { sietch_tabr: 0, false_wall_south: 10, false_wall_west: 0 };
if (!options.initial && native === 'tleilaxu' && g.setupStage === 'forces' && next.action.type === 'fremenSetup') {
const held = new Set(g.players.flatMap(p => p.traitors));
const source = traitorDeck(g.players, true).filter(id => !held.has(id)), dancerLeader = trainer ?? target;
assert.ok(source.includes(dancerLeader)); actions.push(structuredClone(next));
g = originalLottery(permutation(source, [dancerLeader, ...source.filter(id => id !== dancerLeader)]),
() => applyAction(singleNexusE1Reload(g), next.actor, next.action));
} else g = stepSingleNexusE1(g, next, actions);
}
assert.equal(g.status, 'playing'); singleNexusE1Inventory(g);
return { initial, setup, offered, ixOffer, afterSetup: g, owner, guild, fremen, collector,
trainer, dancerLeader: trainer ?? target, actions };
}
export function shipSingleNexusE1(g: Game, actor: string, destination: string, amount: number, elite = 0,
actions: SingleNexusE1Step[] = []): SingleNexusE1Transition {
assert.equal(g.phase, 5); assert.equal(g.active, actor); assert.ok(singleNexusE1Clean(g));
const sources = g.homeworlds ? nativeShipmentSources(viewGame(g, actor), amount, elite) : undefined;
if (g.homeworlds) assert.ok(sources);
let action: Action;
if (destination.startsWith('homeworld:')) {
assert.ok(sources);
const choice = homeworldShipmentChoice(viewGame(g, actor), destination,
Object.fromEntries(Object.entries(sources).filter(([, f]) => f.normal + f.elite > 0)));
assert.ok(choice.action, choice.blocked ?? undefined); action = choice.action;
} else action = { type: 'ship', territory: destination, sector: destination === 'shrine' ? 0 : territory(destination).sectors[0],
amount, elite, allyPayment: 0, ...(sources ? { homeworldSources: sources } : {}) };
const result = transition(g, { actor, action }, actions); result.after = settleSingleNexusE1(result.after, actions); return result;
}
/** Original turn-one reveal and free entry, real both-pile blow/Maker, alliance closing and qualified draw. */
export function createSingleNexusE1Programme(options: SingleNexusE1Options = {}): SingleNexusE1Programme {
const s = createSingleNexusE1Setup(options), actions = s.actions; assert.ok(s.guild && s.fremen);
let g = singleNexusE1Reload(s.afterSetup);
const discovery = !!g.discoveries;
frontNativeHomeworldSpice(g, c => 'territory' in c && (discovery ? c.discovery === 'discovery-hagga-basin' : !c.discovery && c.territory === 'hagga_basin'), 0);
if (g.advanced) frontNativeHomeworldSpice(g, c => 'territory' in c && !c.discovery && c.territory === 'broken_land', 1);
const second = g.advanced ? 2 : 1;
frontNativeHomeworldSpice(g, c => 'worm' in c && (discovery ? !!c.greatMaker : !c.greatMaker && !c.suppressed), second);
frontNativeHomeworldSpice(g, c => 'territory' in c && !c.discovery && c.territory === 'rock_outcroppings', second + 1);
if (g.advanced) frontNativeHomeworldSpice(g, c => 'territory' in c && !c.discovery && c.territory === 'oh_gap', second + 2);
let firstArrival: SingleNexusE1Transition | null = null, reveal: SingleNexusE1Transition | null = null;
if (discovery) {
g = advanceSingleNexusE1(g, q => q.phase === 1 && singleNexusE1Clean(q), actions);
for (let n = 0; !g.discoveries!.tokens.some(t => t.face === 'shrine' && t.status === 'placed') && n < 100; n++) {
const next = nextSingleNexusE1Step(g); assert.ok(next); actions.push(structuredClone(next));
g = withClassicDiscoveryNexusToken(g, 'shrine', () => applyAction(singleNexusE1Reload(g), next.actor, next.action));
}
assert.ok(g.discoveries!.tokens.some(t => t.face === 'shrine' && t.status === 'placed'));
g = advanceSingleNexusE1(g, q => q.phase === 5 && q.active === s.collector && singleNexusE1Clean(q), actions);
const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
const sources = g.homeworlds ? nativeShipmentSources(viewGame(g, s.collector), 3, s.collector === s.owner && options.native !== 'tleilaxu' ? 1 : 0) : undefined;
if (g.homeworlds) assert.ok(sources);
firstArrival = transition(g, { actor: s.collector, action: { type: 'ship', territory: placement.territory, sector: placement.sector,
amount: 3, elite: nativeHomeworldPlayer(g, s.collector).faction === 'ixians' ? 1 : 0, allyPayment: 0,
...(sources ? { homeworldSources: sources } : {}) } }, actions);
g = settleSingleNexusE1(firstArrival.after, actions); firstArrival.after = singleNexusE1Reload(g);
g = advanceSingleNexusE1(g, q => q.phase === 7 && singleNexusE1Clean(q), actions);
const token = g.discoveries!.tokens.find(t => t.face === 'shrine' && t.status === 'placed')!;
g = stepSingleNexusE1(g, { actor: s.collector, action: { type: 'discovery', token: token.id, reveal: false } }, actions);
reveal = transition(g, { actor: s.collector, action: { type: 'discovery', token: token.id, reveal: true } }, actions); g = reveal.after;
}
g = advanceSingleNexusE1(g, q => q.phase === 8 && singleNexusE1Clean(q), actions);
let firstMentat: SingleNexusE1Transition | null = null;
for (let n = 0; g.turn === 1 && n < 200; n++) {
const next = nextSingleNexusE1Step(g); assert.ok(next);
const result = transition(g, next, actions); g = result.after; if (g.turn === 2) firstMentat = result;
}
assert.ok(firstMentat);
let entry: SingleNexusE1Transition | null = null, vote: Game | null = null, ride: SingleNexusE1Transition | null = null;
if (discovery) {
g = advanceSingleNexusE1(g, q => q.decision?.kind === 'discoveryEntry' && q.decision.player === s.collector, actions);
const view = viewGame(g, s.collector), sources = view.discoveryEntry!.sources;
const action = discoveryEntryMoveAction(view, nativeHomeworldPlayer(g, s.collector).faction === 'ixians'
? sources.map(source => ({ ...source, normal: Math.min(source.normal, 1), elite: Math.min(source.elite, 1) })) : sources); assert.ok(action);
entry = transition(g, { actor: s.collector, action }, actions); g = entry.after;
g = advanceSingleNexusE1(g, q => q.decision?.kind === 'greatMakerVote', actions); vote = singleNexusE1Reload(g);
while (g.decision?.kind === 'greatMakerVote') g = stepSingleNexusE1(g,
{ actor: g.decision.player, action: { type: 'decision', event: g.decision.event, yes: true } }, actions);
assert.ok(g.decision?.kind === 'greatMakerRide' && g.decision.player === s.fremen);
const actionRide = greatMakerRideAction(viewGame(g, s.fremen), 'polar_sink', 0, 2, g.advanced ? 1 : 0); assert.ok(actionRide);
ride = transition(g, { actor: s.fremen, action: actionRide }, actions); g = ride.after;
}
g = advanceSingleNexusE1(g, q => !!q.nexus && !q.spiceWindow && !q.spiceResolution && singleNexusE1Clean(q), actions);
for (const [actor, target] of [[s.guild, s.fremen], [s.fremen, s.guild]])
g = stepSingleNexusE1(g, { actor, action: { type: 'alliance', target } }, actions);
const alliance = singleNexusE1Reload(g);
g = advanceSingleNexusE1(g, q => q.nexusCards?.phase?.stage === 'drawing' && singleNexusE1Clean(q), actions);
const drawing = singleNexusE1Reload(g), cards = g.nexusCards!.cards!, face = nativeHomeworldPlayer(g, s.owner).faction;
const at = cards.deck.indexOf(face); assert.ok(at >= 0); cards.deck.unshift(cards.deck.splice(at, 1)[0]);
const draw = transition(g, { actor: s.owner, action: { type: 'nexusCardChoice', turn: g.turn,
card: cards.hands[s.owner] ?? null, choice: 'draw', ownRedraws: 0 } }, actions); g = draw.after;
g = advanceSingleNexusE1(g, q => q.phase === 5 && singleNexusE1Clean(q), actions); singleNexusE1Inventory(g);
return { ...s, game: g, firstArrival, reveal, entry, vote, ride, firstMentat, alliance, drawing, draw };
}
export function openSingleNexusE1Battle(g: Game, first: string, second: string, location: string,
actions?: SingleNexusE1Step[]): Game {
g = advanceSingleNexusE1(g, q => q.phase === 6 && singleNexusE1Clean(q) && !!q.active, actions);
const choice = viewGame(g, g.active!).battleChoices.find(b => b.territory === location &&
[first, second].includes(b.attacker) && [first, second].includes(b.defender)); assert.ok(choice);
g = stepSingleNexusE1(g, { actor: choice.chooser, action: { type: 'chooseBattle', territory: location,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } }, actions);
return advanceSingleNexusE1(g, q => !!q.battle && singleNexusE1Clean(q) && !q.battle.preparation && q.battle.preLeader?.closed !== false, actions);
}
export function revealSingleNexusE1Plans(g: Game, plans: readonly SingleNexusE1Step[], actions?: SingleNexusE1Step[]): Game {
for (const plan of plans) {
g = stepSingleNexusE1(g, plan, actions);
while (g.response || g.phaseOpening || g.decision?.kind === 'fullPlanOffer') {
const next = nextSingleNexusE1Step(g); assert.ok(next); g = stepSingleNexusE1(g, next, actions);
}
}
assert.ok(g.battle?.revealed); return g;
}
export function finishSingleNexusE1Battle(g: Game, actions?: SingleNexusE1Step[]): Game {
for (let n = 0; n < 600; n++) {
if (!g.battle && singleNexusE1Clean(g)) return g;
let next = nextSingleNexusE1Step(g);
if (!next && !g.response && !g.phaseOpening) {
const d = g.decision; assert.ok(d);
if (d.kind === 'battleCards') next = { actor: d.player, action: { type: 'decision', discard: [] } };
else if (d.kind === 'techToken') next = { actor: d.player, action: { type: 'decision', token: d.choices[0] } };
else if (d.kind === 'faceDance') next = { actor: d.player, action: { type: 'decision', reveal: false } };
else throw Error(`Consume the real ${d.kind} before continuing.`);
}
assert.ok(next); g = stepSingleNexusE1(g, next, actions);
}
throw Error('Original single E1 battle aftermath did not finish.');
}
/** Same public physical/held inputs as the existing paired quote, with optional training and native rules. */
export function quoteSingleNexusE1Battle(g: Game): BattleResolutionQuote {
const b = g.battle; assert.ok(b?.revealed);
const side = (actor: string): ResolutionCombatant => {
const p = nativeHomeworldPlayer(g, actor), view = viewGame(g, actor).battle!; assert.ok(view.ownForces);
return { id: actor, faction: p.faction, ally: p.ally, spice: p.spice, hand: p.hand,
plan: b.plans[actor], leader: p.leaders.find(l => l.id === b.plans[actor].leader), forces: view.ownForces,
stronghold: view.strongholdEffects[actor], leaderSkills: (g.leaderSkills?.assignments ?? []).filter(a => a.owner === actor).map(a => ({
skill: a.skill, leader: a.leader, faceUp: !b.leaderSkillHidden?.[actor], captured: false,
})) };
};
const home = homeworldBattleLocation(g, b.territory);
return quoteBattleResolution({ advanced: g.advanced, typedCasualties: !!g.homeworlds, turn: g.turn, territory: b.territory,
...(home ? { homeworld: { native: home.native, card: home.card, side: home.side, nativeForces: { ...home.forces[home.native] } } } : {}),
aggressor: viewGame(g, g.host).battle!.aggressor, attacker: side(b.attacker), defender: side(b.defender),
voters: viewGame(g, g.host).battle!.traitorVoters.map(actor => ({ id: actor,
beneficiary: [b.attacker, b.defender].includes(actor) ? actor : nativeHomeworldPlayer(g, actor).ally!,
called: b.traitorCalls[actor] ?? false, traitors: nativeHomeworldPlayer(g, actor).traitors })), participants: g.players,
physicalCards: [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand)],
pendingAuditorPresent: false, pendingRetentionPresent: false });
}
+350
View File
@@ -0,0 +1,350 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeLeaderSkillsGameForAudit, initializeNexusGameForAudit,
joinGame, newPlayer, viewGame,
} from '../game/engine';
import type { Action, Game, Player } from '../game/engine';
import type { FactionId } from '../game/catalog';
import type { Difficulty } from '../game/bot-profiles';
import type { Card } from '../game/cards';
import type { LeaderSkillId } from '../game/leader-skill-cards';
import { botActions } from '../game/bots';
import { territory } from '../game/board';
import { treacheryDeck } from '../game/cards';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
import { richeseCards } from '../game/richese-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { DISCOVERY_CARD_PLACEMENTS, validateDiscoveryState } from '../game/discoveries';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { greatMakerRideAction } from '../game/great-maker-options';
import { choamPowerAction, choamPowerPlays } from '../game/choam-power-options';
import { nativeHomeworldInventory, nativeHomeworldPlayer, nativeHomeworldLeader, frontNativeHomeworldSpice } from './fixture-homeworld-native-e1e2-modules';
import { nextHomeworldPairedNexusStep } from './fixture-homeworld-paired-nexus';
import { withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken } from './fixture-discovery-classic-nexus';
export type SingleNexusE2Native = 'choam' | 'richese';
export type SingleNexusE2Step = { actor: string; action: Action };
export type SingleNexusE2Transition = { before: Game; step: SingleNexusE2Step; after: Game };
export type SingleNexusE2Options = {
/** Only an original undealt lobby or its original, unassigned turn-one setup. */
initial?: Game;
native?: SingleNexusE2Native;
roster?: readonly FactionId[];
advanced?: boolean;
skills?: boolean;
homeworlds?: boolean;
discovery?: boolean;
tech?: boolean;
strongholds?: boolean;
skill?: LeaderSkillId;
/** Original first deal selection only; never replaces a supplied deal. */
fuelKind?: 'special' | 'shield';
};
export type SingleNexusE2Setup = {
initial: Game; setup: Game; offered: Game | null; afterSetup: Game; game: Game;
owner: string; others: string[]; trainer: string | null; actions: SingleNexusE2Step[];
};
export type SingleNexusE2Programme = SingleNexusE2Setup & {
firstMarker: SingleNexusE2Transition | null;
entry: SingleNexusE2Transition | null;
ride: SingleNexusE2Transition | null;
vote: Game | null;
alliance: Game; draw: SingleNexusE2Transition;
};
export const singleE2Reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
export const singleE2Player = nativeHomeworldPlayer;
export const singleE2Leader = nativeHomeworldLeader;
export const singleE2Clean = (game: Game): boolean =>
!game.response && !game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard && !game.truthtrance;
/** Persist minimal profiles on the real owning seat before projecting its window. */
export function singleE2Policy(game: Game, actor: string, difficulty: Difficulty): Action[] {
const saved = singleE2Reload(game); singleE2Player(saved, actor).bot = difficulty;
return botActions(viewGame(singleE2Reload(saved), actor));
}
export function singleE2Inventory(game: Game): void {
if (game.homeworlds) nativeHomeworldInventory(game);
else for (const p of game.players) {
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, n) => a + n, 0), 20, p.faction);
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((a, n) => a + n, 0), p.faction === 'emperor' ? 5 : 3);
}
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.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])];
assert.deepEqual(cards.map(c => c.id).sort(), [...treacheryDeck(['choam']),
...(game.players.some(p => p.faction === 'richese') ? richeseCards() : [])].map(c => c.id).sort());
validateNexusCards(game.nexusCards!.cards!, game.players);
const nx = game.nexusCards!.cards!;
assert.deepEqual([...nx.deck, ...nx.discard, ...Object.values(nx.hands).filter((c): c is FactionId => c !== null)].sort(), [...NEXUS_FACTIONS].sort());
}
export function singleE2Step(game: Game, step: SingleNexusE2Step, actions?: SingleNexusE2Step[]): Game {
actions?.push(structuredClone(step));
return withClassicDiscoveryNexusLottery(() => applyAction(singleE2Reload(game), step.actor, step.action));
}
export function nextSingleE2Step(game: Game): SingleNexusE2Step | null {
if (!game.response && !game.phaseOpening && game.decision) {
const d = game.decision;
if (['sukRescue', 'battleCards', 'techToken'].includes(d.kind)) return null;
if (d.kind === 'guildShipment' || d.kind === 'homeworldShipmentGuild')
return { actor: d.player, action: { type: 'decision', allow: true,
...(d.kind === 'homeworldShipmentGuild' ? { event: d.event } : {}) } };
if (d.kind === 'richeseUnbid') return { actor: d.player,
action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
}
const step = nextHomeworldPairedNexusStep(game);
if (step?.action.type === 'stormDial') step.action.amount = game.turn === 1 ? 0 : 1;
return step;
}
export function advanceSingleE2(game: Game, until: (g: Game) => boolean, actions?: SingleNexusE2Step[]): Game {
for (let n = 0; n < 3000; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextSingleE2Step(game);
assert.ok(step, `Choose the actual single E2 ${game.decision?.kind ?? 'sealed plan'} at ${game.turn}/${game.phase}.`);
game = singleE2Step(game, step, actions);
}
throw Error('Original single E2 chronology did not reach its owned boundary.');
}
export function settleSingleE2(game: Game, actions?: SingleNexusE2Step[]): Game {
return advanceSingleE2(game, g => singleE2Clean(g) && !g.pendingShipment && !g.pendingHomeworldShipment && !g.pendingChoamWorthless, actions);
}
function transition(game: Game, step: SingleNexusE2Step, actions: SingleNexusE2Step[]): SingleNexusE2Transition {
return { before: singleE2Reload(game), step: structuredClone(step), after: singleE2Step(game, step, actions) };
}
function permutation(source: readonly string[], desired: readonly string[]): number[] {
assert.deepEqual([...source].sort(), [...desired].sort());
const working = [...source], rolls: number[] = [];
for (let i = working.length - 1; i > 0; i--) {
const j = working.indexOf(desired[i]); assert.ok(j >= 0 && j <= i);
rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
[working[i], working[j]] = [working[j], working[i]];
}
return rolls;
}
function lottery<T>(rolls: readonly number[], operation: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto); let index = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[index++] ?? 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 createSingleE2Setup(options: SingleNexusE2Options = {}): SingleNexusE2Setup {
const native = options.native ?? options.initial?.players.find(p => p.faction === 'choam' || p.faction === 'richese')?.faction ?? 'richese';
const roster = options.roster ?? [native, 'guild', 'fremen'] as readonly FactionId[];
const advanced = options.advanced ?? options.initial?.advanced ?? true;
let game = options.initial ? singleE2Reload(options.initial)
: createGame('SINGLENEXUSE2', newPlayer(roster[0], roster[0], roster[0]), advanced, ['choam']);
if (!options.initial) for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
assert.equal(game.advanced, advanced); assert.deepEqual(game.expansions, ['choam']);
assert.equal(game.players.filter(p => p.faction === native).length, 1);
assert.ok(game.players.every(p => p.faction === native || ['atreides', 'harkonnen', 'guild', 'fremen', 'beneGesserit', 'emperor'].includes(p.faction)));
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 &&
(!game.leaderSkills || game.leaderSkills.assignments.length === 0));
const skills = options.skills ?? (options.initial?.status === 'setup' ? !!game.leaderSkills : true);
const discovery = options.discovery ?? !!game.discoveryEnabled;
const modules = { homeworlds: options.homeworlds ?? !!game.homeworlds,
techTokens: options.tech ?? !!game.techTokens, strongholdCards: options.strongholds ?? !!game.strongholdCards };
assert.ok(!modules.strongholdCards || advanced);
const owner = game.players.find(p => p.faction === native)!.id, actions: SingleNexusE2Step[] = [];
const requested = options.skill ?? 'suk-graduate';
if (game.status === 'lobby') {
for (const [type, enabled] of Object.entries(modules)) if (!!game[type as keyof typeof modules] !== enabled)
game = singleE2Step(game, { actor: game.host, action: { type, enabled } }, actions);
game.discoveryEnabled = discovery; game.nexusCards ??= { cards: null, phase: null };
} else {
for (const [type, enabled] of Object.entries(modules)) assert.equal(!!game[type as keyof typeof modules], enabled);
assert.equal(!!game.discoveryEnabled, discovery); assert.equal(!!game.leaderSkills, skills);
assert.ok(game.nexusCards?.cards, 'Never attach Nexus to a played deal.');
}
const initial = singleE2Reload(game);
if (game.status === 'lobby') {
for (const p of game.players) if (!p.ready) game = singleE2Step(game, { actor: p.id, action: { type: 'ready' } }, actions);
const passive: LeaderSkillId[] = ['warmaster', 'sandmaster', 'killer-medic', 'prana-bindu-adept', 'swordmaster-of-ginaz'].filter(s => s !== requested) as LeaderSkillId[];
const selected = game.players.map(p => p.id === owner ? requested : passive.shift()!);
const skillsSource = LEADER_SKILL_CARDS.map(c => c.id), rest = skillsSource.filter(s => !selected.includes(s));
const skillsOrder = selected.flatMap(s => [s, rest.shift()!]).concat(rest);
const deck = treacheryDeck(['choam']), remaining = [...deck], front: Card[] = [];
for (const p of game.players) for (let n = 0; n < (p.faction === 'harkonnen' ? 2 : 1); n++) {
const kind = p.faction === 'choam' ? options.fuelKind ?? 'special' : 'worthless';
const i = remaining.findIndex(c => c.kind === kind && (kind !== 'special' || c.effect !== 'karama')); assert.ok(i >= 0);
front.push(remaining.splice(i, 1)[0]);
}
const rolls = [...(skills ? permutation(skillsSource, skillsOrder) : []),
...(discovery ? Array<number>(7).fill(0xffffffff) : []),
...Array<number>(NEXUS_FACTIONS.length - 1).fill(0xffffffff),
...permutation(deck.map(c => c.id), [...front, ...remaining].map(c => c.id))];
game = lottery(rolls, () => skills ? initializeLeaderSkillsGameForAudit(game) : initializeNexusGameForAudit(game));
}
const setup = singleE2Reload(game); let offered: Game | null = null, trainer: string | null = null;
for (let n = 0; game.status === 'setup' && n < 250; n++) {
let step: SingleNexusE2Step;
if (game.setupStage === 'leaderSkills' && !game.decision) {
offered ??= singleE2Reload(game);
const actor = Object.keys(game.leaderSkills!.offers)[0], view = viewGame(game, actor).leaderSkills!;
const skill = actor === owner && options.skill ? options.skill
: view.offer!.cards.find(s => !view.unavailableSkills?.[s]);
assert.ok(skill && view.offer!.cards.includes(skill) && !view.unavailableSkills?.[skill],
'A requested skill must exist in the authoritative original private offer.');
const eligible = new Set(view.eligibleLeaders.map(l => l.id));
const leader = singleE2Player(game, actor).leaders.filter(l => eligible.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
assert.ok(skill && leader); if (actor === owner) trainer = leader.id;
step = { actor, action: { type: 'leaderSkill', event: view.offer!.event, skill, leader: leader.id } };
} else {
const next = nextSingleE2Step(game); assert.ok(next); step = next;
if (step.action.type === 'traitor' && trainer) {
const choices = singleE2Player(game, step.actor).traitorChoices;
step.action.leader = choices.find(id => id !== trainer) ?? choices[0];
}
if (step.action.type === 'fremenSetup') step.action.placements = { sietch_tabr: 0, false_wall_south: 10, false_wall_west: 0 };
}
game = singleE2Step(game, step, actions);
}
assert.equal(game.status, 'playing'); singleE2Inventory(game);
return { initial, setup, offered, afterSetup: singleE2Reload(game), game, owner,
others: game.players.filter(p => p.id !== owner).map(p => p.id), trainer, actions };
}
export function shipSingleE2(game: Game, actor: string, destination: string, amount: number,
actions?: SingleNexusE2Step[], sector?: number): Game {
const sources = game.homeworlds ? nativeShipmentSources(viewGame(game, actor), amount, 0) : undefined;
if (game.homeworlds) assert.ok(sources);
let action: Action;
if (destination.startsWith('homeworld:')) {
assert.ok(sources);
const choice = homeworldShipmentChoice(viewGame(game, actor), destination,
Object.fromEntries(Object.entries(sources).filter(([, f]) => f.normal + f.elite > 0)));
assert.ok(choice.action, choice.blocked ?? undefined); action = choice.action;
} else action = { type: 'ship', territory: destination, sector: sector ?? (destination === 'shrine' ? 0 : territory(destination).sectors[0]),
amount, elite: 0, allyPayment: 0, ...(sources ? { homeworldSources: sources } : {}) };
return settleSingleE2(singleE2Step(game, { actor, action }, actions), actions);
}
/** Native starts, real first marker/parent entry, END Mentat, both Spice piles,
* Maker or worm, settled alliance and a qualified original closing draw. */
export function createSingleE2Programme(options: SingleNexusE2Options = {}): SingleNexusE2Programme {
const f = createSingleE2Setup(options), actions = f.actions;
const guild = f.game.players.find(p => p.faction === 'guild')?.id;
const fremen = f.game.players.find(p => p.faction === 'fremen')?.id;
assert.ok(guild && fremen, 'The closing programme needs original Guild/Fremen alliance seats. Setup itself supports two through six.');
let game = f.game; const discovery = !!game.discoveries;
frontNativeHomeworldSpice(game, c => 'territory' in c && (discovery ? c.discovery === 'discovery-hagga-basin' : !c.discovery && c.territory === 'hagga_basin'), 0);
if (game.advanced) frontNativeHomeworldSpice(game, c => 'territory' in c && !c.discovery && c.territory === 'broken_land', 1);
const offset = game.advanced ? 2 : 1;
frontNativeHomeworldSpice(game, c => 'worm' in c && (discovery ? !!c.greatMaker : !c.greatMaker && !c.suppressed), offset);
frontNativeHomeworldSpice(game, c => 'territory' in c && !c.discovery && c.territory === 'rock_outcroppings', offset + 1);
if (game.advanced) frontNativeHomeworldSpice(game, c => 'territory' in c && !c.discovery && c.territory === 'oh_gap', offset + 2);
game = advanceSingleE2(game, g => g.phase === 1 && singleE2Clean(g), actions);
if (discovery) {
for (let n = 0; !game.discoveries!.tokens.some(t => t.face === 'shrine' && t.status === 'placed') && n < 100; n++) {
const step = nextSingleE2Step(game); assert.ok(step); actions.push(structuredClone(step));
game = withClassicDiscoveryNexusToken(game, 'shrine', () => applyAction(singleE2Reload(game), step.actor, step.action));
}
assert.ok(game.discoveries!.tokens.some(t => t.face === 'shrine' && t.status === 'placed'));
}
let firstMarker: SingleNexusE2Transition | null = null;
const needed = new Set([...(discovery ? [guild] : []), ...(singleE2Player(game, f.owner).faction === 'richese' ? [f.owner] : [])]);
while (needed.size) {
game = advanceSingleE2(game, g => g.turn === 1 && g.phase === 5 && singleE2Clean(g) && needed.has(g.active!), actions);
const actor = game.active!;
if (actor === f.owner) {
const p = singleE2Player(game, actor), token = p.noField!.tokens.find(t => t.value === 0)!;
firstMarker = transition(game, { actor, action: { type: 'ship', territory: 'habbanya_ridge_sietch',
sector: territory('habbanya_ridge_sietch').sectors[0], noField: token.id, event: p.noFieldEvent, allyPayment: 0 } }, actions);
game = settleSingleE2(firstMarker.after, actions); firstMarker.after = singleE2Reload(game);
game = singleE2Step(game, { actor, action: { type: 'revealNoField', token: token.id, event: singleE2Player(game, actor).noFieldEvent } }, actions);
} else {
const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
game = shipSingleE2(game, actor, placement.territory, 3, actions, placement.sector);
}
needed.delete(actor);
if (needed.size) game = singleE2Step(game, { actor, action: { type: 'endMovement' } }, actions);
}
if (discovery) {
game = advanceSingleE2(game, g => g.phase === 7 && singleE2Clean(g), actions);
const token = game.discoveries!.tokens.find(t => t.face === 'shrine' && t.status === 'placed')!;
for (const reveal of [false, true]) game = singleE2Step(game, { actor: guild, action: { type: 'discovery', token: token.id, reveal } }, actions);
}
game = advanceSingleE2(game, g => g.turn === 2, actions);
let entry: SingleNexusE2Transition | null = null, ride: SingleNexusE2Transition | null = null, vote: Game | null = null;
if (discovery) {
game = advanceSingleE2(game, g => g.decision?.kind === 'discoveryEntry' && g.decision.player === guild, actions);
const view = viewGame(game, guild), action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources); assert.ok(action);
entry = transition(game, { actor: guild, action }, actions); game = entry.after;
game = advanceSingleE2(game, g => g.decision?.kind === 'greatMakerVote', actions); vote = singleE2Reload(game);
while (game.decision?.kind === 'greatMakerVote') game = singleE2Step(game,
{ actor: game.decision.player, action: { type: 'decision', event: game.decision.event, yes: true } }, actions);
assert.ok(game.decision?.kind === 'greatMakerRide' && game.decision.player === fremen);
const actionRide = greatMakerRideAction(viewGame(game, fremen), 'polar_sink', 0, 2, game.advanced ? 1 : 0); assert.ok(actionRide);
ride = transition(game, { actor: fremen, action: actionRide }, actions); game = ride.after;
}
game = advanceSingleE2(game, g => !!g.nexus && !g.spiceWindow && !g.spiceResolution && singleE2Clean(g), actions);
for (const [actor, target] of [[guild, fremen], [fremen, guild]])
game = singleE2Step(game, { actor, action: { type: 'alliance', target } }, actions);
const alliance = singleE2Reload(game);
game = advanceSingleE2(game, g => g.nexusCards?.phase?.stage === 'drawing' && singleE2Clean(g), actions);
assert.ok(game.nexusCards!.phase!.eligible.includes(f.owner));
const cards = game.nexusCards!.cards!, face = singleE2Player(game, f.owner).faction;
const at = cards.deck.indexOf(face); assert.ok(at >= 0); cards.deck.unshift(cards.deck.splice(at, 1)[0]);
const draw = transition(game, { actor: f.owner, action: { type: 'nexusCardChoice', turn: game.turn,
card: cards.hands[f.owner] ?? null, choice: 'draw', ownRedraws: 0 } }, actions);
game = advanceSingleE2(draw.after, g => g.phase === 5 && singleE2Clean(g), actions);
return { ...f, game, firstMarker, entry, ride, vote, alliance, draw };
}
export function singleE2Cunning(game: Game, actor: string, fuel?: string, destination = 'shrine'): Action {
const p: Player = singleE2Player(game, actor), view = viewGame(game, actor);
if (p.faction === 'choam') {
const play = choamPowerPlays(view, 'kulon').find(p => p.source === 'nexus' && p.card.id === fuel); assert.ok(play);
const action = choamPowerAction(view, play); assert.ok(action); return action;
}
assert.equal(p.faction, 'richese'); const offer = view.nexusRicheseCunning; assert.ok(offer && !offer.blocked);
return { type: 'ship', territory: destination, sector: destination === 'shrine' ? 0 : territory(destination).sectors[0],
noField: p.noField!.tokens.find(t => t.value === 3)!.id, revealedToken: p.noField!.tokens.find(t => t.value === 5)!.id,
event: p.noFieldEvent, nexus: offer.event, allyPayment: 0 };
}
export function openSingleE2Battle(game: Game, owner: string, opponent: string, location: string, hidden = false): Game {
game = advanceSingleE2(game, g => g.phase === 6 && singleE2Clean(g) && !!g.active);
const choice = viewGame(game, game.active!).battleChoices.find(b => b.territory === location &&
[owner, opponent].includes(b.attacker) && [owner, opponent].includes(b.defender)); assert.ok(choice);
game = singleE2Step(game, { actor: choice.chooser, action: { type: 'chooseBattle', territory: location,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } });
for (let n = 0; n < 200; n++) {
if (game.battle && singleE2Clean(game) && !game.battle.preparation && game.battle.preLeader?.closed !== false) return game;
const next = nextSingleE2Step(game); assert.ok(next);
if (next.action.type === 'leaderSkillVisibility') next.action.hide = next.actor === owner && hidden;
game = singleE2Step(game, next);
}
throw Error('Original single E2 battle did not reach sealed plans.');
}
export function revealSingleE2Plans(game: Game, plans: readonly SingleNexusE2Step[]): Game {
for (const plan of plans) {
game = singleE2Step(game, plan);
while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') {
const next = nextSingleE2Step(game); assert.ok(next); game = singleE2Step(game, next);
}
}
assert.ok(game.battle?.revealed); return game;
}
export function finishSingleE2Battle(game: Game): Game {
for (let n = 0; n < 600; n++) {
if (!game.battle && singleE2Clean(game)) return game;
let next = nextSingleE2Step(game);
if (!next && !game.response && !game.phaseOpening) {
const d = game.decision; assert.ok(d);
if (d.kind === 'battleCards') next = { actor: d.player, action: { type: 'decision', discard: [] } };
else if (d.kind === 'techToken') next = { actor: d.player, action: { type: 'decision', token: d.choices[0] } };
else throw Error(`Choose real ${d.kind} before cleanup.`);
}
assert.ok(next); game = singleE2Step(game, next);
}
throw Error('Original single E2 aftermath did not finish.');
}
+425
View File
@@ -0,0 +1,425 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame } from '../game/engine';
import type { Action, Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { ownedTech } from '../game/tech-tokens';
import { nexusFaceDancersAction } from '../game/nexus-tleilaxu-options';
import type { BattleResolutionQuote } from '../game/battle-resolution-quote';
import {
advanceSingleNexusE1 as advance, createSingleNexusE1Programme as programme,
finishSingleNexusE1Battle as finish, openSingleNexusE1Battle as open,
quoteSingleNexusE1Battle as quote, revealSingleNexusE1Plans as plans,
settleSingleNexusE1 as settle, shipSingleNexusE1 as ship, singleNexusE1Army as army,
singleNexusE1Clean as clean, singleNexusE1Inventory as inventory, singleNexusE1Leader as leader,
singleNexusE1Player as player, singleNexusE1Reload as reload, stepSingleNexusE1 as step,
} from './fixture-single-nexus-e1';
function reject(g: Game, actor: string, action: Action): void {
const before = reload(g);
assert.throws(() => applyAction(g, actor, action));
assert.deepEqual(g, before, 'A rejected consumer cannot spend stock, move custody or debit payment.');
}
/** All four minimal saved difficulties consume their own real window after JSON continuation. */
function policies(g: Game, actor: string, select: (action: Action) => boolean,
outcome: (after: Game, action: Action) => void): void {
for (const difficulty of DIFFICULTIES) {
const saved = reload(g); player(saved, actor).bot = difficulty;
const action = botActions(viewGame(reload(saved), actor)).find(select);
assert.ok(action, `${difficulty} consumes its original owned window`);
const after = step(reload(saved), { actor, action }); outcome(after, action);
if (after.status === 'playing') inventory(after);
}
}
function movement(g: Game, actor: string, turn = 2): Game {
return advance(g, s => s.turn === turn && s.phase === 5 && s.active === actor && clean(s));
}
function cunning(g: Game, owner: string, foreign: string): Game {
const offer = viewGame(g, owner).nexusSuboids?.offer; assert.ok(offer && !offer.blocked);
const action: Action = { type: 'nexusSuboids', event: offer.event };
reject(g, foreign, action); reject(g, owner, { ...action, event: 'previous-battle' });
policies(g, owner, a => a.type === 'nexusSuboids', after => {
const resolved = settle(after);
assert.equal(resolved.nexusCards!.cards!.hands[owner], null);
assert.equal(resolved.nexusCards!.cards!.discard.filter(c => c === 'ixians').length, 1);
assert.deepEqual(player(resolved, owner).forces, player(g, owner).forces);
assert.equal(viewGame(resolved, owner).battle!.ownForces!.normalFreeSupport, true);
assert.equal(player(resolved, owner).spice, player(g, owner).spice);
});
const after = settle(step(reload(g), { actor: owner, action })); reject(after, owner, action); return after;
}
function moneyAtHeldBoundary(before: Game, after: Game, receipt: BattleResolutionQuote): void {
for (const p of before.players) {
const debit = receipt.payments.filter(q => q.player === p.id).reduce((n, q) => n + q.ownPayment, 0);
const bounty = receipt.bounty?.player === p.id ? receipt.bounty.amount : 0;
const held = receipt.strongholdIncome.filter(q => q.player === p.id).reduce((n, q) => n + q.amount, 0);
assert.equal(player(after, p.id).spice, p.spice - debit + bounty + held,
'Only the held source pays support/bounty/industry; ordinary Collection has not run.');
}
}
void test('single Ixian bare Nexus Skills lets a surviving original Smuggler collect the printed board spice without another module', () => {
const f = programme({ native: 'ixians', advanced: true, skills: true, collectorSkill: 'smuggler' });
assert.ok(f.trainer);
const pile = Object.entries(f.game.spice).find(([key, amount]) => key.startsWith('rock_outcroppings:') && amount > 0);
assert.ok(pile);
const needed = new Set([f.owner, f.guild]);
let g = reload(f.game);
while (needed.size) {
g = advance(g, s => s.phase === 5 && clean(s) && needed.has(s.active!));
const actor = g.active!;
g = ship(g, actor, 'rock_outcroppings', 2).after;
needed.delete(actor);
if (needed.size) g = step(g, { actor, action: { type: 'endMovement' } });
}
g = open(g, f.owner, f.guild, 'rock_outcroppings');
const purse = player(g, f.owner).spice;
g = plans(g, [
{ actor: f.owner, action: { type: 'battlePlan', leader: f.trainer, dial: 0, support: 0 } },
{ actor: f.guild, action: { type: 'battlePlan', leader: leader(g, f.guild, true), dial: 0, support: 0 } },
]);
assert.equal(quote(g).winner, f.owner);
const strength = player(g, f.owner).leaders.find(l => l.id === f.trainer)!.strength;
g = finish(g);
const receipt = g.lastBattleContext!.smugglerCollection;
assert.ok(receipt);
assert.equal(receipt.stage, 'collected');
assert.equal(receipt.before, pile[1]);
assert.equal(receipt.amount, Math.min(strength, pile[1]));
assert.equal(g.spice[pile[0]], 0, 'Ordinary Collection harvests the remainder after the printed Smuggler amount.');
assert.equal(player(g, f.owner).spice, purse + pile[1], 'The physical pile pays once across Smuggler and ordinary Collection.');
assert.equal(player(g, f.owner).leaders.find(l => l.id === f.trainer)!.dead, false);
inventory(g);
});
for (const skills of [false, true]) void test(`single Ixian ${skills ? 'all14' : 'no-Skills'} original offer, HMS and closing draw continue into native typed losses without a companion`, () => {
const f = programme({ native: 'ixians', advanced: true, skills, homeworlds: true, tech: true, strongholds: true });
assert.ok(f.ixOffer);
const held = player(f.ixOffer.after, f.owner).hand;
assert.equal(held.length, 1); assert.equal(held[0].id, f.ixOffer.step.action.card);
if (skills) {
assert.ok(f.offered && f.trainer);
}
assert.equal(player(f.afterSetup, f.owner).forces['hidden_mobile_stronghold:0'], 6);
assert.equal(player(f.afterSetup, f.owner).elites!.forces['hidden_mobile_stronghold:0'], 3);
assert.equal(player(f.alliance, f.guild).ally, f.fremen);
assert.equal(player(f.alliance, f.fremen).ally, f.guild);
policies(f.draw.before, f.owner, a => a.type === 'nexusCardChoice', after => {
assert.equal(after.nexusCards!.cards!.hands[f.owner], 'ixians');
assert.deepEqual(after.homeworlds!.custody, f.draw.before.homeworlds!.custody);
assert.equal(player(after, f.owner).spice, player(f.draw.before, f.owner).spice);
});
reject(f.draw.after, f.owner, f.draw.step.action);
const arrival = ship(movement(f.game, f.guild), f.guild, 'homeworld:ixians', 3);
assert.deepEqual(army(arrival.after, f.guild, 'homeworld:ixians'), { normal: 3, elite: 0 });
assert.deepEqual(army(arrival.after, f.guild, 'homeworld:guild'), {
normal: army(arrival.before, f.guild, 'homeworld:guild').normal - 3, elite: 0,
});
assert.ok(player(arrival.after, f.guild).spice < player(arrival.before, f.guild).spice,
'A genuine off-planet native source shipment pays its original invoice.');
reject(arrival.after, f.guild, arrival.step.action);
let g = cunning(open(arrival.after, f.owner, f.guild, 'homeworld:ixians'), f.owner, f.guild);
const before = reload(g), lostTech = ownedTech(g.techTokens, f.guild);
g = plans(g, [{ actor: f.owner, action: { type: 'battlePlan', leader: skills ? f.trainer! : leader(g, f.owner, true), dial: 3, support: 0 } },
{ actor: f.guild, action: { type: 'battlePlan', leader: leader(g, f.guild), dial: 0, support: 0 } }]);
const receipt = quote(g); assert.equal(receipt.winner, f.owner);
assert.equal(receipt.payments.find(p => p.player === f.owner)!.ownPayment, 0);
assert.equal(player(g, f.owner).spice, player(before, f.owner).spice);
if (skills) {
g = advance(g, s => s.decision?.kind === 'sukRescue');
const d = g.decision; assert.ok(d?.kind === 'sukRescue');
const choice = d.options.findIndex(o => o.normal === 0 && o.elite === 1 && o.kept?.kind === 'elite'); assert.ok(choice >= 0);
const rescue: Action = { type: 'decision', event: d.event, choice };
reject(g, f.guild, rescue); reject(g, f.owner, { ...rescue, event: 'old-rescue' });
policies(g, f.owner, a => a.type === 'decision' && a.choice !== undefined, (after, action) => {
const saved = d.options[Number(action.choice)];
assert.equal(player(after, f.owner).tanks, player(g, f.owner).tanks + 3 - saved.normal - saved.elite);
assert.equal(player(after, f.owner).elites!.tanks, player(g, f.owner).elites!.tanks + 3 - saved.elite);
assert.equal(player(after, f.owner).spice, player(g, f.owner).spice);
});
g = step(reload(g), { actor: f.owner, action: rescue });
}
g = advance(g, s => s.decision?.kind === 'ixSubstitution');
const remaining = skills ? 2 : 3;
assert.equal(player(g, f.owner).elites!.tanks, player(before, f.owner).elites!.tanks + remaining);
assert.deepEqual(g.pendingIxSubstitution!.losses, { 'homeworld:ixians': remaining });
if (skills) {
assert.deepEqual(g.lastBattleContext!.sukRescue!.physical!.saved, { normal: 0, elite: 1 });
assert.deepEqual(g.lastBattleContext!.sukRescue!.physical!.tanks, { normal: 0, elite: 2 });
}
const residual = reload(g), pool = army(g, f.owner, 'homeworld:ixians');
const exchange: Action = { type: 'decision', sources: { 'homeworld:ixians': 1 }, recover: { 'homeworld:ixians': 1 } };
reject(g, f.guild, exchange);
reject(g, f.owner, { ...exchange, recover: { 'homeworld:ixians': remaining + 1 } });
reject(g, f.owner, { ...exchange, sources: { reserves: 1 } });
reject(g, f.owner, { ...exchange, recover: { 'homeworld:tleilaxu': 1 } });
policies(g, f.owner, a => a.type === 'decision' && (a.sources !== undefined || a.decline === true), (after, action) => {
const count = action.decline ? 0 : Object.values(action.recover as Record<string, number>).reduce((n, f) => n + f, 0);
assert.equal(player(after, f.owner).elites!.tanks, player(residual, f.owner).elites!.tanks - count);
assert.deepEqual(army(after, f.owner, 'homeworld:ixians'), { normal: pool.normal - count, elite: pool.elite + count });
assert.equal(player(after, f.owner).spice, player(residual, f.owner).spice);
});
g = step(reload(g), { actor: f.owner, action: exchange });
assert.deepEqual(army(g, f.owner, 'homeworld:ixians'), { normal: pool.normal - 1, elite: pool.elite + 1 });
assert.equal(player(g, f.owner).elites!.tanks, player(residual, f.owner).elites!.tanks - 1);
assert.equal(player(g, f.owner).tanks, player(residual, f.owner).tanks);
reject(g, f.owner, exchange); g = finish(g);
assert.deepEqual(army(g, f.guild, 'homeworld:ixians'), { normal: 0, elite: 0 });
assert.equal(player(g, f.guild).tanks, player(before, f.guild).tanks + 3);
for (const token of lostTech) assert.equal(g.techTokens![token].owner, f.owner);
reject(g, f.guild, { type: 'nexusFaceDancers', event: g.lastBattleContext!.event }); inventory(g);
});
void test('single Ixian visitor rescue remains source-local: a native free strength bonus cannot waive visitor paid support or create a borrowed return', () => {
const f = programme({ native: 'ixians', advanced: true, skills: true, homeworlds: true, tech: true, strongholds: true });
assert.ok(f.trainer);
const arrival = ship(movement(f.game, f.owner), f.owner, 'homeworld:guild', 4, 3);
const homeBefore = army(arrival.after, f.owner, 'homeworld:ixians');
let g = cunning(open(arrival.after, f.owner, f.guild, 'homeworld:guild'), f.owner, f.guild);
const before = reload(g);
g = plans(g, [{ actor: f.owner, action: { type: 'battlePlan', leader: f.trainer, dial: 3, support: 0 } },
{ actor: f.guild, action: { type: 'battlePlan', leader: leader(g, f.guild), dial: 1, support: 1 } }]);
const receipt = quote(g), payment = receipt.payments.find(p => p.player === f.guild)!;
assert.equal(payment.ownPayment, 1); assert.equal(receipt.winner, f.owner);
g = advance(g, s => s.decision?.kind === 'sukRescue'); moneyAtHeldBoundary(before, g, receipt);
const d = g.decision; assert.ok(d?.kind === 'sukRescue');
const choice = d.options.findIndex(o => o.normal === 0 && o.elite === 1 && o.kept?.kind === 'elite'); assert.ok(choice >= 0);
reject(g, f.owner, { type: 'decision', event: d.event, choice, destinations: { 'homeworld:ixians': { normal: 0, elite: 1 } } });
g = step(reload(g), { actor: f.owner, action: { type: 'decision', event: d.event, choice } });
g = advance(g, s => s.decision?.kind === 'ixSubstitution');
const physical = g.lastBattleContext!.sukRescue!.physical!;
assert.equal(physical.source, 'visitor-homeworld'); assert.deepEqual(physical.destinations, {});
assert.deepEqual(physical.saved, { normal: 0, elite: 1 }); assert.deepEqual(physical.tanks, { normal: 0, elite: 2 });
assert.deepEqual(army(g, f.owner, 'homeworld:ixians'), homeBefore, 'The kept rescued Cyborg never returns through an absent companion.');
const pool = army(g, f.owner, 'homeworld:guild'), beforeExchange = reload(g);
const exchange: Action = { type: 'decision', sources: { 'homeworld:guild': 1 }, recover: { 'homeworld:guild': 1 } };
reject(g, f.owner, { ...exchange, sources: { 'homeworld:ixians': 1 } });
g = step(reload(g), { actor: f.owner, action: exchange });
assert.deepEqual(army(g, f.owner, 'homeworld:guild'), { normal: pool.normal - 1, elite: pool.elite + 1 });
assert.deepEqual(army(g, f.owner, 'homeworld:ixians'), homeBefore);
assert.equal(player(g, f.owner).spice, player(beforeExchange, f.owner).spice);
g = finish(g); inventory(g);
});
for (const skills of [false, true]) void test(`single Ixian ${skills ? 'trained' : 'untrained'} original Discovery reveal/free entry and nested losses retain both-pile closing and physical cleanup`, () => {
const f = programme({ native: 'ixians', advanced: true, skills, homeworlds: true, discovery: true, tech: true, strongholds: true });
assert.ok(f.firstArrival && f.reveal && f.entry && f.vote && f.ride);
const entered = army(f.entry.after, f.owner, 'shrine'); assert.deepEqual(entered, { normal: 1, elite: 1 });
assert.deepEqual(f.entry.after.homeworlds!.custody, f.entry.before.homeworlds!.custody);
assert.equal(player(f.entry.after, f.owner).spice, player(f.entry.before, f.owner).spice);
assert.ok(f.entry.before.spiceDiscard[0].length && f.entry.before.spiceDiscard[1].length,
'The actual Advanced Spice sequence uses both piles before the owned entry.');
reject(f.entry.before, f.guild, f.entry.step.action);
reject(f.entry.before, f.owner, { ...f.entry.step.action, event: 'stale-entry' });
policies(f.entry.before, f.owner, a => a.type === 'decision' && a.accept === false, after => {
assert.deepEqual(army(after, f.owner, 'shrine'), { normal: 0, elite: 0 });
assert.deepEqual(player(after, f.owner).forces, player(f.entry!.before, f.owner).forces);
assert.equal(player(after, f.owner).spice, player(f.entry!.before, f.owner).spice);
});
assert.equal(player(f.ride.after, f.fremen).reserves, player(f.ride.before, f.fremen).reserves - 2);
assert.equal(player(f.ride.after, f.fremen).elites!.reserves, player(f.ride.before, f.fremen).elites!.reserves - 1);
assert.equal(player(f.ride.after, f.fremen).spice, player(f.ride.before, f.fremen).spice);
const needed = new Set([f.owner, f.guild]); let g = reload(f.game);
while (needed.size) {
g = advance(g, s => s.phase === 5 && clean(s) && needed.has(s.active!)); const actor = g.active!;
g = ship(g, actor, 'shrine', 3, actor === f.owner ? 2 : 0).after; needed.delete(actor);
if (needed.size) g = step(g, { actor, action: { type: 'endMovement' } });
}
g = cunning(open(g, f.owner, f.guild, 'shrine'), f.owner, f.guild);
const worthless = player(g, f.owner).hand.find(c => c.kind === 'worthless'); assert.ok(worthless);
const before = reload(g), lostTech = ownedTech(g.techTokens, f.guild);
g = plans(g, [{ actor: f.owner, action: { type: 'battlePlan', leader: skills ? f.trainer! : leader(g, f.owner, true), dial: 3, support: 0, weapon: worthless.id } },
{ actor: f.guild, action: { type: 'battlePlan', leader: leader(g, f.guild), dial: 0, support: 0 } }]);
const receipt = quote(g);
if (skills) {
g = advance(g, s => s.decision?.kind === 'sukRescue'); moneyAtHeldBoundary(before, g, receipt);
const d = g.decision; assert.ok(d?.kind === 'sukRescue');
const choice = d.options.findIndex(o => o.normal === 0 && o.elite === 1 && o.kept?.kind === 'elite'); assert.ok(choice >= 0);
g = step(reload(g), { actor: f.owner, action: { type: 'decision', event: d.event, choice } });
}
g = advance(g, s => s.decision?.kind === 'ixSubstitution');
assert.deepEqual(g.pendingIxSubstitution!.losses, { 'shrine:0': skills ? 2 : 3 });
const residual = reload(g), pool = army(g, f.owner, 'shrine');
const exchange: Action = { type: 'decision', sources: { 'shrine:0': 1 }, recover: { 'shrine:0': 1 } };
reject(g, f.owner, { ...exchange, recover: { 'homeworld:ixians': 1 } });
g = step(reload(g), { actor: f.owner, action: exchange });
assert.deepEqual(army(g, f.owner, 'shrine'), { normal: pool.normal - 1, elite: pool.elite + 1 });
assert.equal(player(g, f.owner).elites!.tanks, player(residual, f.owner).elites!.tanks - 1);
assert.equal(player(g, f.owner).tanks, player(residual, f.owner).tanks);
g = advance(g, s => s.decision?.kind === 'battleCards');
for (const token of lostTech) assert.equal(g.techTokens![token].owner, f.guild);
policies(g, f.owner, a => a.type === 'decision' && a.discard !== undefined, (after, action) => {
const discarded = (action.discard as string[]).includes(worthless.id);
assert.equal(player(after, f.owner).hand.some(c => c.id === worthless.id), !discarded);
assert.equal(after.discard.some(c => c.id === worthless.id), discarded);
});
g = step(reload(g), { actor: f.owner, action: { type: 'decision', discard: [worthless.id] } }); g = finish(g);
assert.equal(g.discard.filter(c => c.id === worthless.id).length, 1);
for (const token of lostTech) assert.equal(g.techTokens![token].owner, f.owner);
assert.equal(player(g, f.guild).tanks, player(before, f.guild).tanks + 3);
assert.deepEqual(army(g, f.guild, 'shrine'), { normal: 0, elite: 0 }); inventory(g);
});
for (const skills of [false, true]) void test(`single Tleilaxu ${skills ? 'all14' : 'no-Skills'} actual original matching Face Dancer and Cunning refresh do not manufacture Cyborgs`, () => {
const f = programme({ native: 'tleilaxu', advanced: true, skills, homeworlds: true, discovery: true, tech: true, strongholds: true });
assert.ok(f.entry);
if (skills) {
assert.ok(f.offered);
const foreign = viewGame(f.offered, f.guild).leaderSkills!, pending = structuredClone(foreign.offer!);
assert.ok(pending && foreign.eligibleLeaders.length);
reject(f.offered, f.owner, { type: 'leaderSkill', event: pending.event, skill: pending.cards[0],
leader: foreign.eligibleLeaders[0].id });
}
const arrival = ship(movement(f.game, f.owner), f.owner, 'shrine', 2);
let g = open(arrival.after, f.owner, f.guild, 'shrine');
const winner = f.dancerLeader, nativeSkill = reload(g).leaderSkills?.assignments.find(a => a.owner === f.owner);
assert.ok(player(g, f.owner).faceDancers!.some(c => c.leader === winner && !c.revealed));
const worthless = player(g, f.guild).hand.find(c => c.kind === 'worthless'); assert.ok(worthless);
g = plans(g, [{ actor: f.guild, action: { type: 'battlePlan', leader: winner, dial: 0, support: 0, weapon: worthless.id } },
{ actor: f.owner, action: { type: 'battlePlan', leader: leader(g, f.owner), dial: 0, support: 0 } }]);
assert.equal(quote(g).winner, f.guild);
g = advance(g, s => s.decision?.kind === 'battleCards');
assert.equal(g.techTokens!.axlotl.owner, f.owner);
g = step(reload(g), { actor: f.guild, action: { type: 'decision', discard: [worthless.id] } });
g = advance(g, s => s.decision?.kind === 'faceDance');
assert.equal(g.techTokens!.axlotl.owner, f.guild);
assert.equal(g.discard.filter(c => c.id === worthless.id).length, 1);
const before = reload(g), survivor = army(g, f.guild, 'shrine'), nativeHome = army(g, f.owner, 'homeworld:tleilaxu');
const dance: Action = { type: 'decision', reveal: true, sources: { reserves: 1 }, sector: 0 };
reject(g, f.guild, dance); reject(g, f.owner, { ...dance, sources: { reserves: survivor.normal + 1 } });
reject(g, f.owner, { ...dance, sources: { 'homeworld:ixians': 1 } });
policies(g, f.owner, a => a.type === 'decision' && a.reveal !== undefined, (after, action) => {
const count = action.reveal ? Object.values((action.sources ?? {}) as Record<string, number>).reduce((n, f) => n + f, 0) : 0;
assert.equal(player(after, f.guild).leaders.find(l => l.id === winner)!.dead, !!action.reveal);
assert.equal(player(after, f.owner).faceDancers!.find(c => c.leader === winner)!.revealed, !!action.reveal);
assert.deepEqual(army(after, f.guild, 'shrine'), action.reveal ? { normal: 0, elite: 0 } : survivor);
assert.equal(army(after, f.owner, 'shrine').normal, count);
assert.deepEqual(after.techTokens, before.techTokens);
assert.equal(player(after, f.owner).spice, player(before, f.owner).spice);
});
g = step(reload(g), { actor: f.owner, action: dance });
assert.deepEqual(army(g, f.guild, 'shrine'), { normal: 0, elite: 0 });
assert.equal(player(g, f.guild).reserves, player(before, f.guild).reserves + survivor.normal);
assert.deepEqual(army(g, f.owner, 'shrine'), { normal: 1, elite: 0 });
assert.deepEqual(army(g, f.owner, 'homeworld:tleilaxu'), { normal: nativeHome.normal - 1, elite: 0 });
assert.deepEqual(g.techTokens, before.techTokens);
if (skills) {
assert.equal(g.leaderSkills!.assignments.some(a => a.leader === winner), false);
assert.equal(g.leaderSkills!.deck.filter(s => s === 'suk-graduate').length, 1);
const observer = g.leaderSkills!.assignments.find(a => a.owner === f.owner);
assert.equal(observer?.leader, nativeSkill?.leader);
assert.equal(observer?.skill, nativeSkill?.skill, 'Foreign disc death cannot retire the native observer’s training.');
}
reject(g, f.owner, dance);
const offer = viewGame(g, f.owner).nexusTleilaxu?.cunning; assert.ok(offer && !offer.blocked);
const refresh = nexusFaceDancersAction(viewGame(g, f.owner), offer.event); assert.ok(refresh);
const reserve = [...g.traitorReserve!], stock = structuredClone(player(g, f.owner).faceDancers!);
const untouched = stock.filter(c => !c.revealed), allowance = player(g, f.owner).faceDancerReplacedTurn;
reject(g, f.guild, refresh); reject(g, f.owner, { ...refresh, event: 'old-refresh' });
reject(g, f.owner, { ...refresh, leaders: [winner] });
policies(g, f.owner, a => a.type === 'nexusFaceDancers', after => {
assert.deepEqual(player(after, f.owner).faceDancers!.filter(c => untouched.some(old => old.leader === c.leader)), untouched);
assert.deepEqual(player(after, f.owner).faceDancers!.filter(c => !untouched.some(old => old.leader === c.leader)), [{ leader: reserve[0], revealed: false }]);
assert.deepEqual([...after.traitorReserve!].sort(), [...reserve.slice(1), winner].sort());
assert.equal(player(after, f.owner).faceDancerReplacedTurn, allowance);
assert.equal(after.nexusCards!.cards!.hands[f.owner], null);
assert.deepEqual(after.techTokens, g.techTokens);
assert.deepEqual(army(after, f.owner, 'shrine'), { normal: 1, elite: 0 });
assert.equal(player(after, f.owner).spice, player(g, f.owner).spice);
});
g = step(reload(g), { actor: f.owner, action: refresh }); reject(g, f.owner, refresh); g = finish(g); inventory(g);
});
for (const skills of [false, true]) void test(`single Tleilaxu ${skills ? 'trained' : 'untrained'} native paid support, real leader death and own discounted revival preserve original disc identity`, () => {
const f = programme({ native: 'tleilaxu', advanced: true, skills, homeworlds: true, tech: true,
startingKinds: ['projectile', 'projectile', 'worthless'] });
const arrival = ship(movement(f.game, f.guild), f.guild, 'homeworld:tleilaxu', 3);
let g = open(arrival.after, f.owner, f.guild, 'homeworld:tleilaxu');
const own = skills ? g.leaderSkills!.assignments.find(a => a.owner === f.owner)!.leader : leader(g, f.owner, true);
const foreign = leader(g, f.guild), nativeAssignment = g.leaderSkills?.assignments.find(a => a.owner === f.owner);
const foreignAssignment = structuredClone(g.leaderSkills?.assignments.find(a => a.owner === f.guild));
const ownWeapon = player(g, f.owner).hand.find(c => c.kind === 'projectile')!.id;
const enemyWeapon = player(g, f.guild).hand.find(c => c.kind === 'projectile')!.id;
const before = reload(g);
g = plans(g, [{ actor: f.owner, action: { type: 'battlePlan', leader: own, dial: 1, support: 1, weapon: ownWeapon } },
{ actor: f.guild, action: { type: 'battlePlan', leader: foreign, dial: 0, support: 0, weapon: enemyWeapon } }]);
const receipt = quote(g); assert.equal(receipt.payments.find(p => p.player === f.owner)!.ownPayment, 1);
assert.equal(player(g, f.owner).spice, player(before, f.owner).spice, 'Plans do not prepay even with native free strength.');
g = advance(g, s => s.decision?.kind === 'battleCards'); moneyAtHeldBoundary(before, g, receipt);
assert.equal(player(g, f.owner).leaders.find(l => l.id === own)!.dead, true);
assert.equal(player(g, f.guild).leaders.find(l => l.id === foreign)!.dead, true);
assert.equal(player(g, f.owner).tanks, player(before, f.owner).tanks + 1);
assert.equal(player(g, f.guild).tanks, player(before, f.guild).tanks + 3);
if (skills) {
assert.equal(g.leaderSkills!.assignments.some(a => a.leader === own), false);
assert.equal(g.leaderSkills!.deck.filter(s => s === nativeAssignment!.skill).length, 1);
const foreignTraining = g.leaderSkills!.assignments.find(a => a.owner === f.guild);
assert.equal(foreignTraining?.leader, foreignAssignment?.leader);
assert.equal(foreignTraining?.skill, foreignAssignment?.skill);
}
g = step(reload(g), { actor: f.owner, action: { type: 'decision', discard: [ownWeapon] } }); g = finish(g);
assert.equal(g.discard.filter(c => c.id === ownWeapon).length, 1);
g = advance(g, s => s.turn === 3 && s.phase === 4 && clean(s));
const revivalBefore = reload(g), offer = viewGame(g, f.owner).revival.leaders.find(l => l.id === own); assert.ok(offer && offer.affordable);
const revive: Action = { type: 'reviveLeader', leader: own };
reject(g, f.guild, revive); reject(g, f.owner, { ...revive, leader: f.dancerLeader });
const revived = (state: Game): Game => advance(state, s => !s.pendingRevival && !s.response && !s.phaseOpening &&
(skills ? !!s.leaderSkills?.offers[f.owner] : !s.decision));
policies(g, f.owner, a => a.type === 'reviveLeader' && a.leader === own, after => {
const resolved = revived(after);
assert.equal(player(resolved, f.owner).leaders.find(l => l.id === own)!.dead, false);
assert.equal(player(resolved, f.owner).spice, player(revivalBefore, f.owner).spice - offer.cost);
assert.deepEqual(resolved.homeworlds!.custody, revivalBefore.homeworlds!.custody);
assert.equal(player(resolved, f.guild).leaders.some(l => l.id === own), false);
});
g = revived(step(reload(g), { actor: f.owner, action: revive })); reject(g, f.owner, revive);
assert.equal(player(g, f.owner).spice, player(revivalBefore, f.owner).spice - offer.cost);
if (skills) {
const acceptance = viewGame(g, f.owner).leaderSkills!.offer!;
const draw: Action = { type: 'leaderSkill', event: acceptance.event, mode: 'draw' };
reject(g, f.guild, draw); reject(g, f.owner, { ...draw, event: 'old-revival' });
const beforeDraw = reload(g);
policies(g, f.owner, a => a.type === 'leaderSkill' && a.mode === 'draw', after => {
assert.equal(after.leaderSkills!.offers[f.owner].cards.length, 2);
assert.equal(after.leaderSkills!.deck.length, beforeDraw.leaderSkills!.deck.length - 2);
assert.equal(player(after, f.owner).leaders.find(l => l.id === own)!.dead, false);
assert.equal(player(after, f.owner).spice, player(beforeDraw, f.owner).spice);
});
g = step(g, { actor: f.owner, action: draw }); reject(g, f.owner, draw);
const training = viewGame(g, f.owner).leaderSkills!; assert.equal(training.offer!.leader, own);
const pending = reload(g).leaderSkills!.offers[f.owner];
const skill = training.offer!.cards.find(s => !training.unavailableSkills?.[s]); assert.ok(skill);
const train: Action = { type: 'leaderSkill', event: training.offer!.event, leader: own, skill };
reject(g, f.guild, train); reject(g, f.owner, { ...train, leader: foreign });
reject(g, f.owner, { ...train, event: 'former-native-training' });
assert.deepEqual(g.leaderSkills!.offers[f.owner], pending, 'A foreign or stale training attempt cannot consume the owned revival offer.');
policies(g, f.owner, a => a.type === 'leaderSkill', (after, action) => {
assert.equal(after.leaderSkills!.assignments.find(a => a.owner === f.owner)!.leader, own);
assert.equal(after.leaderSkills!.assignments.find(a => a.owner === f.owner)!.skill, action.skill);
assert.equal(player(after, f.owner).spice, player(g, f.owner).spice);
const foreignTraining = after.leaderSkills!.assignments.find(a => a.owner === f.guild);
assert.equal(foreignTraining?.leader, foreignAssignment?.leader);
assert.equal(foreignTraining?.skill, foreignAssignment?.skill);
});
g = step(reload(g), { actor: f.owner, action: train }); reject(g, f.owner, train);
assert.equal(g.leaderSkills!.assignments.find(a => a.owner === f.owner)!.leader, own);
}
inventory(g);
});
void test('single native Tleilaxu cannot replace a real matching Guild winner in a foreign Homeworld battle', () => {
const f = programme({ native: 'tleilaxu', advanced: false, skills: false, homeworlds: true, tech: true });
assert.ok(player(f.afterSetup, f.owner).faceDancers!.some(c => c.leader === f.dancerLeader && !c.revealed));
const arrival = ship(movement(f.game, f.owner), f.owner, 'homeworld:guild', 1);
let g = open(arrival.after, f.owner, f.guild, 'homeworld:guild');
g = plans(g, [{ actor: f.guild, action: { type: 'battlePlan', leader: f.dancerLeader, dial: 0, support: 0 } },
{ actor: f.owner, action: { type: 'battlePlan', leader: leader(g, f.owner), dial: 0, support: 0 } }]);
assert.equal(quote(g).winner, f.guild); g = finish(g);
const physical = army(g, f.guild, 'homeworld:guild');
reject(g, f.owner, { type: 'decision', reveal: true, sources: { reserves: 1 }, sector: 0 });
assert.equal(player(g, f.owner).faceDancers!.find(c => c.leader === f.dancerLeader)!.revealed, false);
assert.equal(player(g, f.guild).leaders.find(l => l.id === f.dancerLeader)!.dead, false);
assert.deepEqual(army(g, f.guild, 'homeworld:guild'), physical);
assert.equal(player(g, f.owner).tanks, 1); inventory(g);
});
+284
View File
@@ -0,0 +1,284 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame } from '../game/engine';
import type { Action, Game } from '../game/engine';
import { DIFFICULTIES } from '../game/bot-profiles';
import { ownedTech } from '../game/tech-tokens';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { CHOAM_AUDITOR_ID } from '../game/choam-auditor';
import { nativeHomeworldArmy } from './fixture-homeworld-native-e1e2-modules';
import {
advanceSingleE2 as advance, createSingleE2Programme as programme,
finishSingleE2Battle as finishBattle, openSingleE2Battle as openBattle,
revealSingleE2Plans as revealPlans, settleSingleE2 as settle,
shipSingleE2 as ship, singleE2Clean as clean, singleE2Cunning as cunning,
singleE2Inventory as inventory, singleE2Leader as leader, singleE2Player as player,
singleE2Policy as policy, singleE2Reload as reload, singleE2Step as step,
} from './fixture-single-nexus-e2';
import type { SingleNexusE2Programme } from './fixture-single-nexus-e2';
/** Every state begins at an original initializer. No physical force, held card,
* purse, phase, training identity, alliance or outcome is assigned by a test. */
function reject(game: Game, actor: string, action: Action): void {
const before = reload(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'Invalid consumers do not debit physical inventory or payments.');
}
function policies(game: Game, actor: string, select: (action: Action) => boolean,
outcome: (after: Game, action: Action) => void): void {
for (const difficulty of DIFFICULTIES) {
const action = policy(game, actor, difficulty).find(select);
assert.ok(action, `${difficulty} must consume its genuine owned window.`);
const after = step(reload(game), { actor, action });
outcome(after, action); inventory(after);
}
}
function movement(game: Game, actor: string): Game {
return advance(game, g => g.turn === 2 && g.phase === 5 && g.active === actor && clean(g));
}
function collectionIncome(game: Game, actor: string): number {
if (game.phase !== 7 || !clean(game)) return 0;
const row = quoteSpiceCollection(game).receipts.find(r => r.player === actor);
return (row?.strongholds ?? 0) + (row?.collected ?? 0);
}
function originalClosing(f: SingleNexusE2Programme): void {
assert.equal(player(f.alliance, 'guild').ally, 'fremen');
assert.equal(player(f.alliance, 'fremen').ally, 'guild');
assert.ok(!player(f.alliance, f.owner).ally);
assert.equal(f.draw.after.nexusCards!.cards!.hands[f.owner], player(f.draw.after, f.owner).faction);
assert.deepEqual(player(f.draw.after, f.owner).forces, player(f.draw.before, f.owner).forces);
policies(f.draw.before, f.owner, a => a.type === 'nexusCardChoice', after => {
assert.equal(after.nexusCards!.cards!.hands[f.owner], player(after, f.owner).faction);
});
if (f.entry) {
assert.equal(player(f.entry.after, 'guild').forces['shrine:0'], 3);
assert.equal(player(f.entry.after, 'guild').spice, player(f.entry.before, 'guild').spice);
assert.deepEqual(f.entry.after.homeworlds, f.entry.before.homeworlds);
policies(f.entry.before, 'guild', a => a.type === 'decision' && a.accept === false, after => {
assert.deepEqual(player(after, 'guild').forces, player(f.entry!.before, 'guild').forces);
assert.equal(player(after, 'guild').spice, player(f.entry!.before, 'guild').spice);
});
assert.ok(f.ride && f.vote);
assert.equal(player(f.ride.after, 'fremen').reserves, player(f.ride.before, 'fremen').reserves - 2);
assert.equal(player(f.ride.after, 'fremen').forces['polar_sink:0'], (player(f.ride.before, 'fremen').forces['polar_sink:0'] ?? 0) + 2);
assert.equal(player(f.ride.after, 'fremen').spice, player(f.ride.before, 'fremen').spice);
}
}
void test('single CHOAM Special fuels native Cunning once; visitor Suk delays only the other payer support income and original winner Tech', () => {
const f = programme({ native: 'choam', advanced: true, skills: true, homeworlds: true,
discovery: false, tech: true, strongholds: true, skill: 'suk-graduate', fuelKind: 'special' });
originalClosing(f); assert.ok(f.trainer && f.offered);
const offered = viewGame(f.offered, f.owner).leaderSkills!;
assert.ok(offered.offer!.cards.includes('suk-graduate'));
assert.ok(player(f.offered, f.owner).leaders.some(l => l.id === CHOAM_AUDITOR_ID));
reject(f.offered, f.owner, { type: 'leaderSkill', event: offered.offer!.event,
skill: 'suk-graduate', leader: CHOAM_AUDITOR_ID });
let game = movement(f.game, f.owner);
const fuel = player(game, f.owner).hand.find(c => c.kind === 'special' && c.effect !== 'karama'); assert.ok(fuel);
const request = cunning(game, f.owner, fuel.id), beforeFuel = reload(game);
reject(game, f.owner, { ...request, nexus: 'obsolete-choam' });
reject(game, 'guild', request);
reject(game, f.owner, { ...request, card: player(game, 'guild').hand[0].id });
game = settle(step(game, { actor: f.owner, action: request }));
assert.equal(player(game, f.owner).spice, player(beforeFuel, f.owner).spice);
assert.equal(player(game, f.owner).hand.some(c => c.id === fuel.id), false);
assert.equal(game.discard.filter(c => c.id === fuel.id).length, 1);
assert.equal(game.nexusCards!.cards!.hands[f.owner], null);
assert.equal(game.choamMovement!.bonus, (beforeFuel.choamMovement?.bonus ?? 0) + 1);
reject(game, f.owner, request);
game = ship(game, f.owner, 'homeworld:guild', 3);
game = openBattle(game, f.owner, 'guild', 'homeworld:guild', true);
const beforePlans = reload(game), lostTech = ownedTech(game.techTokens, 'guild');
game = revealPlans(game, [
{ actor: f.owner, action: { type: 'battlePlan', leader: f.trainer, dial: 2, support: 2 } },
{ actor: 'guild', action: { type: 'battlePlan', leader: leader(game, 'guild'), dial: 2, support: 2 } },
]);
assert.equal(player(game, f.owner).spice, player(beforePlans, f.owner).spice);
game = advance(game, g => g.decision?.kind === 'sukRescue');
assert.equal(player(game, f.owner).spice, player(beforePlans, f.owner).spice - 2);
assert.equal(player(game, 'guild').spice, player(beforePlans, 'guild').spice - 2);
assert.deepEqual(game.pendingChoamBattleIncome, { owner: f.owner, amount: 1 },
'CHOAM excludes its own two support payments; only the opponent pays pending native income.');
for (const token of lostTech) assert.equal(game.techTokens![token].owner, 'guild');
const d = game.decision; assert.ok(d?.kind === 'sukRescue');
const home = nativeHomeworldArmy(game, f.owner, 'homeworld:choam');
const source = nativeHomeworldArmy(game, f.owner, 'homeworld:guild'), beforeRescue = reload(game);
policies(game, f.owner, a => a.type === 'decision' && a.choice !== undefined, (after, action) => {
const choice = d.options[Number(action.choice)], saved = choice.normal + choice.elite;
assert.equal(player(after, f.owner).tanks, player(beforeRescue, f.owner).tanks + 2 - saved);
assert.equal(player(after, f.owner).spice, player(beforeRescue, f.owner).spice +
(after.pendingChoamBattleIncome ? 0 : 1) + collectionIncome(after, f.owner),
'Rescue never refunds support; the original native income may settle only after its physical rescue.');
const kept = choice.kept?.kind === 'normal' ? 1 : 0;
assert.deepEqual(nativeHomeworldArmy(after, f.owner, 'homeworld:guild'), { normal: source.normal - 2 + kept, elite: 0 });
assert.deepEqual(nativeHomeworldArmy(after, f.owner, 'homeworld:choam'), { normal: home.normal + choice.normal - kept, elite: 0 });
});
const choice = d.options.findIndex(o => o.normal === 1 && o.elite === 0 && o.kept?.kind === 'normal'); assert.ok(choice >= 0);
const rescue: Action = { type: 'decision', event: d.event, choice };
reject(game, 'guild', rescue); reject(game, f.owner, { ...rescue, event: 'obsolete-rescue' });
game = step(reload(game), { actor: f.owner, action: rescue });
game = finishBattle(game);
assert.equal(game.lastBattleContext!.winner, f.owner);
assert.equal(game.lastBattleContext!.sukRescue!.physical!.source, 'visitor-homeworld');
assert.equal(player(game, f.owner).tanks, player(beforePlans, f.owner).tanks + 1);
assert.deepEqual(nativeHomeworldArmy(game, f.owner, 'homeworld:guild'), { normal: 2, elite: 0 });
assert.equal(player(game, f.owner).spice, player(beforePlans, f.owner).spice - 2 + 1 + collectionIncome(game, f.owner));
for (const token of lostTech) assert.equal(game.techTokens![token].owner, f.owner);
assert.equal(game.discard.filter(c => c.id === fuel.id).length, 1);
reject(game, f.owner, rescue); inventory(game);
});
void test('single CHOAM Shield is distinct any-Treachery Cunning fuel without Skills; real Maker entry and nested support settle once', () => {
const f = programme({ native: 'choam', advanced: true, skills: false, homeworlds: false,
discovery: true, tech: true, strongholds: false, fuelKind: 'shield' });
originalClosing(f);
let game = movement(f.game, f.owner);
const fuel = player(game, f.owner).hand.find(c => c.kind === 'shield'); assert.ok(fuel);
const beforeFuel = reload(game), action = cunning(game, f.owner, fuel.id);
game = settle(step(game, { actor: f.owner, action }));
assert.equal(game.discard.filter(c => c.id === fuel.id).length, 1);
assert.equal(player(game, f.owner).hand.some(c => c.id === fuel.id), false);
assert.equal(player(game, f.owner).spice, player(beforeFuel, f.owner).spice);
assert.equal(game.choamMovement!.bonus, 1); reject(game, f.owner, action);
game = ship(game, f.owner, 'shrine', 3);
game = openBattle(game, f.owner, 'guild', 'shrine');
const beforePlans = reload(game), lostTech = ownedTech(game.techTokens, 'guild');
game = revealPlans(game, [
{ actor: f.owner, action: { type: 'battlePlan', leader: leader(game, f.owner, true), dial: 2, support: 2 } },
{ actor: 'guild', action: { type: 'battlePlan', leader: leader(game, 'guild'), dial: 2, support: 2 } },
]);
game = finishBattle(game);
assert.equal(game.lastBattleContext!.winner, f.owner);
assert.equal(player(game, f.owner).forces['shrine:0'], 1);
assert.equal(player(game, f.owner).tanks, player(beforePlans, f.owner).tanks + 2);
assert.equal(player(game, 'guild').tanks, player(beforePlans, 'guild').tanks + 3);
assert.equal(player(game, f.owner).spice, player(beforePlans, f.owner).spice - 2 + 1 + collectionIncome(game, f.owner));
assert.equal(player(game, 'guild').spice, player(beforePlans, 'guild').spice - 2 + collectionIncome(game, 'guild'));
for (const token of lostTech) assert.equal(game.techTokens![token].owner, f.owner);
inventory(game);
});
void test('Basic single Richese without Skills signs one nested invoice, exposes five before three and resolves real card custody without a CHOAM income owner', () => {
const f = programme({ native: 'richese', advanced: false, skills: false,
homeworlds: false, discovery: true, tech: true, strongholds: false });
originalClosing(f); assert.ok(f.firstMarker);
assert.equal(player(f.firstMarker.after, f.owner).spice, player(f.firstMarker.before, f.owner).spice - 1);
assert.equal(player(f.firstMarker.after, f.owner).reserves, player(f.firstMarker.before, f.owner).reserves);
let game = movement(f.game, f.owner), before = reload(game);
const request = cunning(game, f.owner);
reject(game, f.owner, { ...request, nexus: 'obsolete-richese' });
reject(game, f.owner, { ...request, revealedToken: request.noField });
reject(game, f.owner, { ...request, smuggler: true });
reject(game, f.owner, { ...request, homeworldSources: { 'homeworld:richese': { normal: 5, elite: 0 } } });
game = step(game, { actor: f.owner, action: request });
game = settle(reload(game));
assert.equal(player(game, f.owner).spice, player(before, f.owner).spice - 1);
assert.equal(player(game, 'guild').spice, player(before, 'guild').spice + 1);
assert.equal(player(game, f.owner).forces['shrine:0'], 5);
assert.equal(player(game, f.owner).reserves, player(before, f.owner).reserves - 5);
assert.equal(player(game, f.owner).noField!.deployed!.tokenId, request.noField);
assert.equal(game.nexusRicheseCunningHistory!.at(-1)!.stage, 'shipped');
assert.equal(game.nexusCards!.cards!.discard.filter(c => c === 'richese').length, 1);
reject(game, f.owner, request);
before = reload(game);
const reveal: Action = { type: 'revealNoField', event: player(game, f.owner).noFieldEvent, token: request.noField };
reject(game, f.owner, { ...reveal, event: 'obsolete-token' });
game = step(reload(game), { actor: f.owner, action: reveal });
assert.equal(player(game, f.owner).forces['shrine:0'], 8);
assert.equal(player(game, f.owner).reserves, player(before, f.owner).reserves - 3);
assert.equal(player(game, f.owner).spice, player(before, f.owner).spice);
assert.equal(player(game, 'guild').spice, player(before, 'guild').spice);
game = openBattle(game, f.owner, 'guild', 'shrine');
const held = player(game, f.owner).hand.find(c => c.kind === 'worthless'); assert.ok(held);
const beforePlans = reload(game), lostTech = ownedTech(game.techTokens, 'guild');
game = revealPlans(game, [
{ actor: f.owner, action: { type: 'battlePlan', leader: leader(game, f.owner, true), dial: 2, support: 0, weapon: held.id } },
{ actor: 'guild', action: { type: 'battlePlan', leader: leader(game, 'guild'), dial: 0, support: 0 } },
]);
game = advance(game, g => g.decision?.kind === 'battleCards');
assert.equal(player(game, f.owner).spice, player(beforePlans, f.owner).spice);
for (const token of lostTech) assert.equal(game.techTokens![token].owner, 'guild');
const cleanup = reload(game);
policies(game, f.owner, a => a.type === 'decision' && a.discard !== undefined, (after, action) => {
const discarded = action.discard as string[];
assert.equal(player(after, f.owner).hand.some(c => c.id === held.id), !discarded.includes(held.id));
assert.equal(after.discard.some(c => c.id === held.id), discarded.includes(held.id));
assert.equal(player(after, f.owner).spice, player(cleanup, f.owner).spice);
});
reject(game, 'guild', { type: 'decision', discard: [held.id] });
game = step(reload(game), { actor: f.owner, action: { type: 'decision', discard: [held.id] } });
game = finishBattle(game);
assert.equal(game.lastBattleContext!.winner, f.owner);
assert.equal(player(game, f.owner).forces['shrine:0'], 6);
assert.equal(player(game, f.owner).tanks, player(beforePlans, f.owner).tanks + 2);
assert.equal(game.discard.filter(c => c.id === held.id).length, 1);
assert.equal(player(game, f.owner).hand.some(c => c.id === held.id), false);
assert.equal(player(game, f.owner).spice, player(beforePlans, f.owner).spice + collectionIncome(game, f.owner));
for (const token of lostTech) assert.equal(game.techTokens![token].owner, f.owner);
inventory(game);
});
void test('Advanced single Richese original native supply funds a real nested pair; Suk rescue and held-card cleanup cannot repeat support or invent companion income', () => {
const f = programme({ native: 'richese', advanced: true, skills: true, homeworlds: true,
discovery: true, tech: true, strongholds: true, skill: 'suk-graduate' });
originalClosing(f); assert.ok(f.trainer);
let game = movement(f.game, f.owner), before = reload(game);
const home = nativeHomeworldArmy(game, f.owner, 'homeworld:richese'), request = cunning(game, f.owner);
assert.ok(home.normal >= 8, 'Only the actual remaining native supply can fund both markers.');
reject(game, f.owner, { ...request, source: 'homeworld:guild' });
reject(game, f.owner, { ...request, smugglerCompanion: true });
reject(game, 'guild', request);
game = settle(step(game, { actor: f.owner, action: request }));
assert.equal(player(game, f.owner).spice, player(before, f.owner).spice - 1);
assert.equal(player(game, 'guild').spice, player(before, 'guild').spice + 1);
assert.deepEqual(nativeHomeworldArmy(game, f.owner, 'homeworld:richese'), { normal: home.normal - 5, elite: 0 });
assert.equal(player(game, f.owner).forces['shrine:0'], 5);
before = reload(game);
game = step(reload(game), { actor: f.owner, action: { type: 'revealNoField',
event: player(game, f.owner).noFieldEvent, token: request.noField } });
assert.equal(player(game, f.owner).forces['shrine:0'], 8);
assert.deepEqual(nativeHomeworldArmy(game, f.owner, 'homeworld:richese'), { normal: home.normal - 8, elite: 0 });
assert.equal(player(game, f.owner).spice, player(before, f.owner).spice);
game = openBattle(game, f.owner, 'guild', 'shrine', true);
const held = player(game, f.owner).hand.find(c => c.kind === 'worthless'); assert.ok(held);
const beforePlans = reload(game), lostTech = ownedTech(game.techTokens, 'guild');
game = revealPlans(game, [
{ actor: f.owner, action: { type: 'battlePlan', leader: f.trainer, dial: 2, support: 2, weapon: held.id } },
{ actor: 'guild', action: { type: 'battlePlan', leader: leader(game, 'guild'), dial: 2, support: 2 } },
]);
assert.equal(player(game, f.owner).spice, player(beforePlans, f.owner).spice);
game = advance(game, g => g.decision?.kind === 'sukRescue');
assert.equal(player(game, f.owner).spice, player(beforePlans, f.owner).spice - 2);
assert.equal(player(game, 'guild').spice, player(beforePlans, 'guild').spice - 2);
const d = game.decision; assert.ok(d?.kind === 'sukRescue');
const nativeBeforeRescue = nativeHomeworldArmy(game, f.owner, 'homeworld:richese'), beforeRescue = reload(game);
policies(game, f.owner, a => a.type === 'decision' && a.choice !== undefined, (after, action) => {
const option = d.options[Number(action.choice)], kept = option.kept?.kind === 'normal' ? 1 : 0;
assert.equal(player(after, f.owner).forces['shrine:0'], 6 + kept);
assert.equal(player(after, f.owner).tanks, player(beforeRescue, f.owner).tanks + 2 - option.normal - option.elite);
assert.deepEqual(nativeHomeworldArmy(after, f.owner, 'homeworld:richese'), { normal: nativeBeforeRescue.normal + option.normal - kept, elite: 0 });
assert.equal(player(after, f.owner).spice, player(beforeRescue, f.owner).spice);
});
const choice = d.options.findIndex(o => o.normal === 2 && o.elite === 0 && o.kept?.kind === 'normal'); assert.ok(choice >= 0);
const rescue: Action = { type: 'decision', event: d.event, choice };
reject(game, f.owner, { ...rescue, event: 'obsolete-suk' });
reject(game, f.owner, { ...rescue, destinations: { 'homeworld:guild': { normal: 2, elite: 0 } } });
game = step(reload(game), { actor: f.owner, action: rescue });
game = advance(game, g => g.decision?.kind === 'battleCards');
assert.equal(player(game, f.owner).spice, player(beforePlans, f.owner).spice - 2);
assert.equal(player(game, 'guild').spice, player(beforePlans, 'guild').spice - 2);
assert.equal(player(game, f.owner).forces['shrine:0'], 7);
assert.deepEqual(nativeHomeworldArmy(game, f.owner, 'homeworld:richese'), { normal: nativeBeforeRescue.normal + 1, elite: 0 });
for (const token of lostTech) assert.equal(game.techTokens![token].owner, 'guild');
reject(game, 'guild', { type: 'decision', discard: [held.id] });
game = finishBattle(step(reload(game), { actor: f.owner, action: { type: 'decision', discard: [held.id] } }));
assert.equal(game.lastBattleContext!.winner, f.owner);
assert.equal(player(game, f.owner).tanks, player(beforePlans, f.owner).tanks);
assert.equal(player(game, 'guild').tanks, player(beforePlans, 'guild').tanks + 3);
assert.equal(game.discard.filter(c => c.id === held.id).length, 1);
assert.equal(player(game, f.owner).spice, player(beforePlans, f.owner).spice - 2 + collectionIncome(game, f.owner));
assert.equal(player(game, 'guild').spice, player(beforePlans, 'guild').spice - 2 + collectionIncome(game, 'guild'));
for (const token of lostTech) assert.equal(game.techTokens![token].owner, f.owner);
reject(game, f.owner, request); inventory(game);
});