Compose original mixed and paired Nexus optional modules

This commit is contained in:
Quintine committed 2026-10-07 14:12:47 +11:00
1 parent 0e1797d4e0
commit 05ab2c92b0
30 files changed
+2268 -203

No files matched your search

+4 -6
View File
@@ -54,12 +54,10 @@ void test('Discovery cannot redeal started components or silently remove another
void test('Discovery Skills cannot silently drop unsupported native families or unrelated modules and previews', () => {
const unsupported = lobby(['ixians', 'ecaz', 'guild'], ['ix', 'ecaz']);
assert.throws(() => initializeLeaderSkillsGameForAudit(unsupported), RuleError);
const overlays = Array.from({ length: 4 }, () => lobby(['guild', 'emperor', 'harkonnen'], []));
overlays[0] = lobby(['ixians', 'choam', 'guild'], ['ix', 'choam']);
overlays[0].nexusCards = { cards: null, phase: null };
overlays[1].advanced = false; overlays[1].strongholdCards = createStrongholdCards();
overlays[2].mentatQuestionPreview = true;
overlays[3].spiceBankerIncomePreview = true;
const overlays = Array.from({ length: 3 }, () => lobby(['guild', 'emperor', 'harkonnen'], []));
overlays[0].advanced = false; overlays[0].strongholdCards = createStrongholdCards();
overlays[1].mentatQuestionPreview = true;
overlays[2].spiceBankerIncomePreview = true;
for (const game of overlays) assert.throws(() => initializeLeaderSkillsGameForAudit(game), RuleError);
const two = lobby(['guild', 'emperor'], []); two.techTokens = createTechTokens();
assert.throws(() => initializeLeaderSkillsGameForAudit(two), RuleError);
+4 -7
View File
@@ -331,12 +331,9 @@ void test('original Discovery supply, reveal and free nested entry survive the l
physical(done);
});
void test('Advanced Harkonnen and mixed-E3 original lobbies remain inadmissible without mutating the declared source pieces', () => {
void test('Advanced Harkonnen original lobbies remain inadmissible without mutating declared source pieces', () => {
const native = lobby(); joinGame(native, newPlayer('harkonnen', 'Harkonnen', 'harkonnen'));
const mixed = lobby(); joinGame(mixed, newPlayer('ecaz', 'Ecaz', 'ecaz'));
for (const g of [native, mixed]) {
let ready = reload(g);
for (const p of ready.players) ready = act(ready, { actor: p.id, action: { type: 'ready' } });
const before = structuredClone(ready); assert.throws(() => initializeNexusGameForAudit(ready)); assert.deepEqual(ready, before);
}
let ready = reload(native);
for (const p of ready.players) ready = act(ready, { actor: p.id, action: { type: 'ready' } });
const before = structuredClone(ready); assert.throws(() => initializeNexusGameForAudit(ready)); assert.deepEqual(ready, before);
});
+333
View File
@@ -0,0 +1,333 @@
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 type { LeaderSkillId } from '../game/leader-skill-cards';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { treacheryDeck } from '../game/cards';
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { richeseCards } from '../game/richese-cards';
import { territory } from '../game/board';
import { traitorDeck } from '../game/traitors';
import { DISCOVERY_CARD_PLACEMENTS, validateDiscoveryState } from '../game/discoveries';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { greatMakerRideAction } from '../game/great-maker-options';
import { nativeShipmentSources } from '../game/homeworld-options';
import { nextSingleNexusE1Step, shipSingleNexusE1, quoteSingleNexusE1Battle,
openSingleNexusE1Battle, revealSingleNexusE1Plans } from './fixture-single-nexus-e1';
import { nativeHomeworldInventory, nativeHomeworldPlayer, nativeHomeworldLeader,
frontNativeHomeworldSpice } from './fixture-homeworld-native-e1e2-modules';
import { withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken } from './fixture-discovery-classic-nexus';
export interface MixedNexusStep { actor: string; action: Action }
export interface MixedNexusTransition { before: Game; step: MixedNexusStep; after: Game }
export interface MixedNexusOptions {
initial?: Game;
advanced?: boolean;
skills?: boolean;
homeworlds?: boolean;
discovery?: boolean;
tech?: boolean;
strongholds?: boolean;
roster?: readonly FactionId[];
/** Selects only an original undealt offer, never changes a supplied offer. */
skillOwner?: FactionId;
skill?: LeaderSkillId;
fuelKind?: 'special' | 'shield';
}
export interface MixedNexusSetup {
initial: Game; setup: Game; offered: Game | null; ixOffer: MixedNexusTransition | null;
afterSetup: Game; trainers: Record<string, string>; dancerLeader: string | null;
seats: Partial<Record<FactionId, string>>; actions: MixedNexusStep[];
}
export interface MixedNexusProgramme extends MixedNexusSetup {
game: Game;
firstArrival: MixedNexusTransition | null;
firstMarker: MixedNexusTransition | null;
reveal: MixedNexusTransition | null;
entry: MixedNexusTransition | null;
vote: Game | null;
ride: MixedNexusTransition | null;
firstMentat: MixedNexusTransition | null;
alliance: Game | null;
drawing: Game | null;
draws: MixedNexusTransition[];
}
export const mixedNexusReload = (g: Game): Game => JSON.parse(JSON.stringify(g)) as Game;
export const mixedNexusPlayer = nativeHomeworldPlayer;
export const mixedNexusLeader = nativeHomeworldLeader;
export const mixedNexusQuote = quoteSingleNexusE1Battle;
export const mixedNexusShip = shipSingleNexusE1;
export const mixedNexusOpenBattle = openSingleNexusE1Battle;
export const mixedNexusRevealPlans = revealSingleNexusE1Plans;
export const mixedNexusClean = (g: Game): boolean =>
!g.response && !g.phaseOpening && !g.decision && !g.pendingTreacheryDiscard && !g.truthtrance;
/** Response queues have priority. Physical choices belong to the human, not to
* this scheduler. In particular a quoted casualty menu is not a default dial. */
export function nextMixedNexusStep(g: Game): MixedNexusStep | null {
if (!g.response && !g.phaseOpening && g.decision) {
const d = g.decision;
if (['battleLosses', 'sukRescue', 'ixSubstitution', 'battleCards', 'faceDance', 'strongholdCopy'].includes(d.kind) ||
d.kind === 'techToken' && !!g.battle) return null;
if (d.kind === 'richeseUnbid') return { actor: d.player,
action: { type: 'decision', event: g.richeseBidding!.event, keep: false } };
}
return nextSingleNexusE1Step(g);
}
export function stepMixedNexus(g: Game, step: MixedNexusStep, actions?: MixedNexusStep[]): Game {
actions?.push(structuredClone(step));
return withClassicDiscoveryNexusLottery(() => applyAction(mixedNexusReload(g), step.actor, step.action));
}
export function advanceMixedNexus(g: Game, until: (g: Game) => boolean, actions?: MixedNexusStep[]): Game {
for (let n = 0; n < 3000; n++) {
if (until(g)) return g;
assert.notEqual(g.status, 'finished');
const next = nextMixedNexusStep(g);
assert.ok(next, `Choose the original ${g.decision?.kind ?? 'sealed plan'} at ${g.turn}/${g.phase}.`);
g = stepMixedNexus(g, next, actions);
}
throw Error('Original mixed E1/E2 chronology did not reach its owned boundary.');
}
export function settleMixedNexus(g: Game, actions?: MixedNexusStep[]): Game {
return advanceMixedNexus(g, q => mixedNexusClean(q) && !q.pendingShipment &&
!q.pendingHomeworldShipment && !q.pendingChoamWorthless, actions);
}
function transition(g: Game, step: MixedNexusStep, actions: MixedNexusStep[]): MixedNexusTransition {
return { before: mixedNexusReload(g), step: structuredClone(step), after: stepMixedNexus(g, step, actions) };
}
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]];
}
return rolls;
}
function lottery<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 mixedNexusInventory(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, p.faction);
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);
}
if (g.leaderSkills) validateLeaderSkills(g.leaderSkills, g.players);
if (g.discoveries) validateDiscoveryState(g.discoveries);
const cards = [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand), ...(g.ixSetupCards ?? []),
...(g.ixAuction?.cards ?? []), ...(g.richeseCache ?? []), ...(g.richeseRemoved ?? []),
...(g.auction?.cards.slice(g.auction.index + (g.currentAuctionSale ? 1 : 0)) ?? [])];
assert.deepEqual(cards.map(c => c.id).sort(), [...treacheryDeck(['ix', 'choam']),
...(g.players.some(p => p.faction === 'richese') ? richeseCards() : [])].map(c => c.id).sort());
validateNexusCards(g.nexusCards!.cards!, g.players);
}
/** Original rules sources: Nexus closing, E1/E2 native powers, Homeworlds and
* Discovery composition; no companion is implied by another seated family.
* Assigned original training is valid input: pending offers alone continue. */
export function createMixedNexusSetup(options: MixedNexusOptions = {}): MixedNexusSetup {
const roster: readonly FactionId[] = options.roster ?? ['ixians', 'tleilaxu', 'choam', 'richese', 'guild', 'fremen'];
let g = options.initial ? mixedNexusReload(options.initial)
: createGame('MIXEDNEXUS', newPlayer(roster[0], roster[0], roster[0]), options.advanced ?? true, ['ix', 'choam']);
if (!options.initial) for (const faction of roster.slice(1)) joinGame(g, newPlayer(faction, faction, faction));
assert.deepEqual([...g.expansions].sort(), ['choam', 'ix']);
assert.equal(g.advanced, options.advanced ?? g.advanced);
assert.ok(g.players.length >= 2 && g.players.length <= 6);
assert.equal(new Set(g.players.map(p => p.faction)).size, g.players.length);
assert.ok(g.players.some(p => ['ixians', 'tleilaxu'].includes(p.faction)) &&
g.players.some(p => ['choam', 'richese'].includes(p.faction)));
assert.ok(g.status === 'lobby' || g.status === 'setup' && g.turn === 1 && g.phase === 0,
'Continue only an original lobby or its original setup, never a played save.');
const skills = options.skills ?? (options.initial?.status === 'setup' ? !!g.leaderSkills : true);
const discovery = options.discovery ?? !!g.discoveryEnabled;
const modules = { homeworlds: options.homeworlds ?? !!g.homeworlds,
techTokens: options.tech ?? !!g.techTokens, strongholdCards: options.strongholds ?? !!g.strongholdCards };
assert.ok(!modules.strongholdCards || g.advanced);
const actions: MixedNexusStep[] = [], seats: Partial<Record<FactionId, string>> = {};
for (const p of g.players) seats[p.faction] = p.id;
const skillOwner = seats[options.skillOwner ?? 'ixians'] ?? g.players[0].id;
const passives: LeaderSkillId[] = ['warmaster', 'sandmaster', 'killer-medic', 'prana-bindu-adept', 'swordmaster-of-ginaz']
.filter(s => s !== (options.skill ?? 'suk-graduate')) as LeaderSkillId[];
const selected = g.players.map(p => p.id === skillOwner ? options.skill ?? 'suk-graduate' : passives.shift()!);
if (g.status === 'lobby') {
for (const [type, enabled] of Object.entries(modules)) if (!!g[type as keyof typeof modules] !== enabled)
g = stepMixedNexus(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(modules)) assert.equal(!!g[type as keyof typeof modules], enabled);
assert.equal(!!g.discoveryEnabled, discovery); assert.equal(!!g.leaderSkills, skills);
assert.ok(g.nexusCards?.cards, 'A supplied original deal cannot acquire a new deck.');
}
const initial = mixedNexusReload(g);
if (g.status === 'lobby') {
for (const p of g.players) if (!p.ready) g = stepMixedNexus(g, { actor: p.id, action: { type: 'ready' } }, actions);
const source = LEADER_SKILL_CARDS.map(c => c.id), rest = source.filter(s => !selected.includes(s));
const skillOrder = selected.flatMap(s => [s, rest.shift()!]).concat(rest);
const deck = treacheryDeck(g.expansions), remaining = [...deck], prefix = [];
const dealOrder = seats.ixians ? [mixedNexusPlayer(g, seats.ixians), ...g.players.filter(p => p.id !== seats.ixians)] : g.players;
for (const p of dealOrder) {
const kind = p.faction === 'choam' ? options.fuelKind ?? 'special' : 'worthless';
const at = remaining.findIndex(c => c.kind === kind && (kind !== 'special' || c.effect !== 'karama'));
assert.ok(at >= 0); prefix.push(remaining.splice(at, 1)[0]);
}
const rolls = [...(skills ? permutation(source, skillOrder) : []),
...(discovery ? Array<number>(7).fill(0xffffffff) : []),
...Array<number>(NEXUS_FACTIONS.length - 1).fill(0xffffffff),
...permutation(deck.map(c => c.id), [...prefix, ...remaining].map(c => c.id))];
const initialize = () => skills ? initializeLeaderSkillsGameForAudit(g) : initializeNexusGameForAudit(g);
g = options.initial ? initialize() : lottery(rolls, initialize);
}
const setup = mixedNexusReload(g); let offered: Game | null = null, ixOffer: MixedNexusTransition | null = null;
const trainers: Record<string, string> = {};
for (const a of g.leaderSkills?.assignments ?? []) trainers[a.owner] = a.leader;
const target = seats.ixians ? trainers[seats.ixians] ?? mixedNexusPlayer(g, seats.ixians).leaders.slice()
.sort((a, b) => b.strength - a.strength)[0].id : null;
let dancerLeader = target;
for (let n = 0; g.status === 'setup' && n < 250; n++) {
let next: MixedNexusStep;
if (g.decision?.kind === 'ixSetup') {
const offer = viewGame(g, g.decision.player).ixTechnology!.setup!;
const card = offer.find(c => c.kind === 'worthless') ?? offer[0]; assert.ok(card);
ixOffer = transition(g, { actor: g.decision.player, action: { type: 'decision', card: card.id } }, actions);
g = ixOffer.after; continue;
}
if (g.setupStage === 'leaderSkills' && !g.decision) {
offered ??= mixedNexusReload(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 = mixedNexusPlayer(g, actor).leaders.filter(l => eligible.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
assert.ok(leader); trainers[actor] = leader.id;
if (actor === seats.ixians) dancerLeader = leader.id;
next = { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } };
} else {
const candidate = nextMixedNexusStep(g); assert.ok(candidate); next = candidate;
if (next.action.type === 'traitor') {
const choices = mixedNexusPlayer(g, next.actor).traitorChoices;
next.action.leader = choices.find(id => id !== dancerLeader) ?? choices[0];
}
if (next.action.type === 'fremenSetup') next.action.placements = { sietch_tabr: 0, false_wall_south: 10, false_wall_west: 0 };
}
if (!options.initial && seats.tleilaxu && dancerLeader && g.setupStage === 'forces' && next.action.type === 'fremenSetup') {
const held = new Set(g.players.flatMap(p => p.traitors)), source = traitorDeck(g.players, true).filter(id => !held.has(id));
assert.ok(source.includes(dancerLeader)); actions.push(structuredClone(next));
g = lottery(permutation(source, [dancerLeader, ...source.filter(id => id !== dancerLeader)]),
() => applyAction(mixedNexusReload(g), next.actor, next.action));
} else g = stepMixedNexus(g, next, actions);
}
assert.equal(g.status, 'playing'); mixedNexusInventory(g);
return { initial, setup, offered, ixOffer, afterSetup: mixedNexusReload(g), trainers, dancerLeader, seats, actions };
}
/** Only original unplayed source-order permutations and unused lotteries are
* selected. Every army, invoice, token, alliance and reward is action-produced.
* Two seats have no legal closing qualification; their endpoint is native setup. */
export function createMixedNexusProgramme(options: MixedNexusOptions = {}): MixedNexusProgramme {
const s = createMixedNexusSetup(options), actions = s.actions;
let g = mixedNexusReload(s.afterSetup);
const result: MixedNexusProgramme = { ...s, game: g, firstArrival: null, firstMarker: null, reveal: null,
entry: null, vote: null, ride: null, firstMentat: null, alliance: null, drawing: null, draws: [] };
if (g.players.length === 2) return result;
const guild = s.seats.guild, fremen = s.seats.fremen;
const discovery = !!g.discoveries;
const collector = s.seats.ixians ?? guild ?? g.players[0].id;
assert.ok(!discovery || guild && fremen, 'Maker programme uses original Guild and Fremen seats.');
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 offset = g.advanced ? 2 : 1;
frontNativeHomeworldSpice(g, c => 'worm' in c && (discovery ? !!c.greatMaker : !c.greatMaker && !c.suppressed), offset);
frontNativeHomeworldSpice(g, c => 'territory' in c && !c.discovery && c.territory === 'rock_outcroppings', offset + 1);
if (g.advanced) frontNativeHomeworldSpice(g, c => 'territory' in c && !c.discovery && c.territory === 'oh_gap', offset + 2);
g = advanceMixedNexus(g, q => q.phase === 1 && mixedNexusClean(q), actions);
if (discovery) {
for (let n = 0; !g.discoveries!.tokens.some(t => t.face === 'shrine' && t.status === 'placed') && n < 100; n++) {
const next = nextMixedNexusStep(g); assert.ok(next); actions.push(structuredClone(next));
g = withClassicDiscoveryNexusToken(g, 'shrine', () => applyAction(mixedNexusReload(g), next.actor, next.action));
}
assert.ok(g.discoveries!.tokens.some(t => t.face === 'shrine' && t.status === 'placed'));
}
const needed = new Set([...(discovery ? [collector] : []), ...(s.seats.richese ? [s.seats.richese] : [])]);
while (needed.size) {
g = advanceMixedNexus(g, q => q.phase === 5 && mixedNexusClean(q) && needed.has(q.active!), actions);
const actor = g.active!;
if (actor === s.seats.richese) {
const p = mixedNexusPlayer(g, actor), token = p.noField!.tokens.find(t => t.value === 0)!;
result.firstMarker = transition(g, { actor, action: { type: 'ship', territory: 'habbanya_ridge_sietch',
sector: territory('habbanya_ridge_sietch').sectors[0], noField: token.id, event: p.noFieldEvent, allyPayment: 0 } }, actions);
g = settleMixedNexus(result.firstMarker.after, actions); result.firstMarker.after = mixedNexusReload(g);
g = stepMixedNexus(g, { actor, action: { type: 'revealNoField', token: token.id, event: mixedNexusPlayer(g, actor).noFieldEvent } }, actions);
} else {
const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
const elite = mixedNexusPlayer(g, actor).faction === 'ixians' ? 1 : 0;
const sources = g.homeworlds ? nativeShipmentSources(viewGame(g, actor), 3, elite) : undefined;
if (g.homeworlds) assert.ok(sources);
result.firstArrival = transition(g, { actor, action: { type: 'ship', territory: placement.territory,
sector: placement.sector, amount: 3, elite, allyPayment: 0,
...(sources ? { homeworldSources: sources } : {}) } }, actions);
g = settleMixedNexus(result.firstArrival.after, actions); result.firstArrival.after = mixedNexusReload(g);
}
needed.delete(actor);
if (needed.size) g = stepMixedNexus(g, { actor, action: { type: 'endMovement' } }, actions);
}
if (discovery) {
g = advanceMixedNexus(g, q => q.phase === 7 && mixedNexusClean(q), actions);
const token = g.discoveries!.tokens.find(t => t.face === 'shrine' && t.status === 'placed')!;
g = stepMixedNexus(g, { actor: collector, action: { type: 'discovery', token: token.id, reveal: false } }, actions);
result.reveal = transition(g, { actor: collector, action: { type: 'discovery', token: token.id, reveal: true } }, actions); g = result.reveal.after;
}
g = advanceMixedNexus(g, q => q.phase === 8 && mixedNexusClean(q), actions);
while (g.turn === 1) {
const next = nextMixedNexusStep(g); assert.ok(next); const t = transition(g, next, actions); g = t.after;
if (g.turn === 2) result.firstMentat = t;
}
if (discovery) {
g = advanceMixedNexus(g, q => q.decision?.kind === 'discoveryEntry' && q.decision.player === collector, actions);
const view = viewGame(g, collector), sources = view.discoveryEntry!.sources;
const action = discoveryEntryMoveAction(view, mixedNexusPlayer(g, collector).faction === 'ixians'
? sources.map(source => ({ ...source, normal: Math.min(source.normal, 1), elite: Math.min(source.elite, 1) })) : sources); assert.ok(action);
result.entry = transition(g, { actor: collector, action }, actions); g = result.entry.after;
g = advanceMixedNexus(g, q => q.decision?.kind === 'greatMakerVote', actions); result.vote = mixedNexusReload(g);
while (g.decision?.kind === 'greatMakerVote') g = stepMixedNexus(g,
{ actor: g.decision.player, action: { type: 'decision', event: g.decision.event, yes: true } }, actions);
assert.ok(g.decision?.kind === 'greatMakerRide' && g.decision.player === fremen);
const actionRide = greatMakerRideAction(viewGame(g, fremen!), 'polar_sink', 0, 2, g.advanced ? 1 : 0); assert.ok(actionRide);
result.ride = transition(g, { actor: fremen!, action: actionRide }, actions); g = result.ride.after;
}
g = advanceMixedNexus(g, q => !!q.nexus && !q.spiceWindow && !q.spiceResolution && mixedNexusClean(q), actions);
const pair = guild && fremen ? [guild, fremen] : g.players.slice(-2).map(p => p.id);
for (const [actor, target] of [[pair[0], pair[1]], [pair[1], pair[0]]])
g = stepMixedNexus(g, { actor, action: { type: 'alliance', target } }, actions);
result.alliance = mixedNexusReload(g);
g = advanceMixedNexus(g, q => q.nexusCards?.phase?.stage === 'drawing' && mixedNexusClean(q), actions);
result.drawing = mixedNexusReload(g);
for (const actor of g.nexusCards!.phase!.eligible) {
const face = mixedNexusPlayer(g, actor).faction, cards = g.nexusCards!.cards!;
const at = cards.deck.indexOf(face); assert.ok(at >= 0); cards.deck.unshift(cards.deck.splice(at, 1)[0]);
const draw = transition(g, { actor, action: { type: 'nexusCardChoice', turn: g.turn,
card: cards.hands[actor] ?? null, choice: 'draw', ownRedraws: 0 } }, actions);
result.draws.push(draw); g = draw.after;
}
g = advanceMixedNexus(g, q => q.phase === 5 && mixedNexusClean(q), actions);
mixedNexusInventory(g); result.game = g; return result;
}
+376
View File
@@ -0,0 +1,376 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, initializeLeaderSkillsGameForAudit, initializePairedNexusGameForAudit,
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 { botActions } from '../game/bots';
import { leaders, spiceDeck, treacheryDeck } from '../game/cards';
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
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 { chooseEcazLoyalty } from '../game/ecaz-loyalty';
import { TERROR_KINDS } from '../game/moritani-terror';
import { validateAmbassadors } from '../game/ecaz-ambassadors';
import { DISCOVERY_SPICE_CARDS, validateDiscoveryState } from '../game/discoveries';
import { greatMakerRideAction } from '../game/great-maker-options';
import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
import { homeworldForceGroups } from '../game/homeworld-custody';
import { homeworldCombatLocation } from '../game/homeworld-combat';
import { quoteBattleResolution } from '../game/battle-resolution-quote';
import type { BattleResolutionQuote, ResolutionCombatant } from '../game/battle-resolution-quote';
import { nextE3NexusEcazStep } from './fixture-e3-nexus-ecaz';
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
export interface PairedE3NexusStep { actor: string; action: Action }
export interface PairedE3NexusOptions {
initial?: Game;
roster?: readonly FactionId[];
advanced?: boolean;
skills?: boolean;
homeworlds?: boolean;
discovery?: boolean;
tech?: boolean;
strongholds?: boolean;
assassinationFaction?: 'guild' | 'ecaz';
}
export interface PairedE3NexusTransition { before: Game; step: PairedE3NexusStep; after: Game }
export interface PairedE3NexusSetup {
initial: Game; offered: Game; afterSetup: Game;
ecaz: string; moritani: string; others: string[]; actions: PairedE3NexusStep[];
}
export interface PairedE3NexusProgramme extends PairedE3NexusSetup {
game: Game; alliance: Game | null; drawing: Game | null;
draws: PairedE3NexusTransition[]; maker: Game | null;
claim: PairedE3NexusTransition | null; firstMentat: PairedE3NexusTransition | null;
}
export interface PairedE3NexusBattle {
game: Game; beforeBattle: Game; location: string; winner: string; loser: string;
arrivals: PairedE3NexusTransition[]; plans: PairedE3NexusStep[];
}
export const pairedE3NexusReload = (g: Game): Game => JSON.parse(JSON.stringify(g)) as Game;
export const pairedE3NexusClean = (g: Game): boolean =>
!g.response && !g.phaseOpening && !g.decision && !g.pendingTreacheryDiscard && !g.truthtrance;
export function pairedE3NexusPlayer(g: Game, actor: string): Player {
const p = g.players.find(p => p.id === actor); assert.ok(p); return p;
}
export function pairedE3NexusPolicy(g: Game, actor: string, difficulty: Difficulty): Action[] {
const saved = pairedE3NexusReload(g); pairedE3NexusPlayer(saved, actor).bot = difficulty;
return botActions(viewGame(pairedE3NexusReload(saved), actor));
}
/** Original response queues precede owned native choices. A human selects the
* coalition lead, posture, assassination, Terror, physical losses and rewards. */
export function nextPairedE3NexusStep(g: Game): PairedE3NexusStep | null {
if (!g.response && !g.phaseOpening && !g.pendingTreacheryDiscard && g.setupStage === 'leaderSkills') return null;
if (!g.response && !g.phaseOpening && !g.pendingTreacheryDiscard && g.decision) {
if (['ecazBattleLead', 'leaderSkillVisibility', 'moritaniPlacement', 'moritaniAssassinate',
'moritaniTerror', 'ecazPlacement', 'ecazAmbassador', 'battleLosses', 'battleCards',
'techToken', 'sukRescue', 'strongholdCopy', 'discoveryEntry', 'greatMakerVote',
'greatMakerRide', 'homeworldDefense', 'homeworldRevivalDeployment', 'moritaniRetention'].includes(g.decision.kind)) return null;
if (g.decision.kind === 'moritaniSetup') {
const action = pairedE3NexusPolicy(g, g.decision.player, 'Easy')[0]; assert.ok(action);
return { actor: g.decision.player, action };
}
}
return nextE3NexusEcazStep(g);
}
export function stepPairedE3Nexus(g: Game, step: PairedE3NexusStep, actions?: PairedE3NexusStep[]): Game {
actions?.push(structuredClone(step));
return withClassicDiscoveryNexusLottery(() => applyAction(pairedE3NexusReload(g), step.actor, step.action));
}
export function advancePairedE3Nexus(g: Game, until: (g: Game) => boolean,
actions?: PairedE3NexusStep[], choose?: (g: Game) => PairedE3NexusStep | null): Game {
for (let n = 0; n < 3200; n++) {
if (until(g)) return g;
assert.notEqual(g.status, 'finished');
const next = nextPairedE3NexusStep(g) ?? choose?.(g);
assert.ok(next, `The owner must choose ${g.decision?.kind ?? 'a sealed plan'} at ${g.turn}/${g.phase}`);
g = stepPairedE3Nexus(g, next, actions);
}
throw Error('Original paired E3 programme did not reach its boundary');
}
/** Explicit quiet programme choices: notably decline Grumman so supply remains
* available. Battle choices are not supplied by this traversal. */
export function quietPairedE3NexusChoice(g: Game): PairedE3NexusStep | null {
const d = g.decision; if (!d || g.response || g.phaseOpening) return null;
if (['moritaniPlacement', 'ecazPlacement', 'ecazAmbassador', 'moritaniAssassinate',
'homeworldRevivalDeployment'].includes(d.kind))
return { actor: d.player, action: { type: 'decision', decline: true,
...('event' in d ? { event: d.event } : {}) } };
if (d.kind === 'leaderSkillVisibility')
return { actor: d.player, action: { type: 'leaderSkillVisibility', event: d.event, hide: false } };
if (d.kind === 'discoveryEntry' || d.kind === 'greatMakerRide')
return { actor: d.player, action: { type: 'decision', event: d.event, accept: false } };
if (d.kind === 'greatMakerVote') return { actor: d.player, action: { type: 'decision', event: d.event, yes: true } };
if (d.kind === 'homeworldDefense') return { actor: d.player, action: { type: 'decision', event: d.event, use: false } };
if (d.kind === 'moritaniTerror') return { actor: d.player, action: { type: 'decision', decline: true } };
return null;
}
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 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');
}
}
function selectedSkills(g: Game): LeaderSkillId[] {
const preferred: Partial<Record<FactionId, LeaderSkillId>> = { ecaz: 'warmaster', moritani: 'master-of-assassins',
guild: 'suk-graduate', emperor: 'swordmaster-of-ginaz', atreides: 'bureaucrat',
fremen: 'killer-medic', beneGesserit: 'prana-bindu-adept', harkonnen: 'smuggler' };
return g.players.map(p => preferred[p.faction]!);
}
function traitorRolls(g: Game, faction: 'guild' | 'ecaz' = 'guild'): number[] {
const original = traitorDeck(g.players), loyal = g.advanced ? chooseEcazLoyalty(original, 0xffffffff / 0x100000000) : null;
const source = original.filter(c => c !== loyal), desired = [...source];
const target = faction === 'ecaz' ? source.find(card => leaders('ecaz').some(l => l.id === card))
: leaders('guild').find(l => l.name === 'Master Bewt')?.id;
if (target && g.players.some(p => p.faction === faction)) {
const destination = g.players.findIndex(p => p.faction === 'moritani') * 4;
const at = desired.indexOf(target); [desired[at], desired[destination]] = [desired[destination], desired[at]];
}
return [...(g.advanced ? [0xffffffff] : []), ...permutation(source, desired)];
}
/** Determinism selects only original undealt lotteries. Supplied lobby entropy
* and partially assigned original setup are authoritative and never redealt. */
export function createPairedE3NexusSetup(options: PairedE3NexusOptions = {}): PairedE3NexusSetup {
const roster = options.roster ?? options.initial?.players.map(p => p.faction) ?? ['ecaz', 'moritani', 'guild', 'emperor'];
const advanced = options.advanced ?? options.initial?.advanced ?? true;
let g = options.initial ? pairedE3NexusReload(options.initial)
: createGame('PAIREDE3NEXUS', newPlayer(roster[0], roster[0], roster[0]), advanced, ['ecaz']);
if (!options.initial) for (const faction of roster.slice(1)) joinGame(g, newPlayer(faction, faction, faction));
assert.equal(g.advanced, advanced); assert.deepEqual(g.expansions, ['ecaz']);
assert.deepEqual(g.players.map(p => p.faction), roster);
assert.ok(g.players.length >= 2 && g.players.length <= 6 && new Set(roster).size === roster.length);
assert.ok(roster.includes('ecaz') && roster.includes('moritani'));
assert.ok(roster.every(f => ['ecaz', 'moritani', 'guild', 'emperor', 'atreides', 'fremen', 'beneGesserit',
...(advanced ? [] : ['harkonnen'])].includes(f)));
const skills = options.skills ?? (options.initial ? !!g.leaderSkills : false);
const discovery = options.discovery ?? !!g.discoveryEnabled;
const modules = { homeworlds: options.homeworlds ?? !!g.homeworlds,
techTokens: options.tech ?? !!g.techTokens, strongholdCards: options.strongholds ?? !!g.strongholdCards };
assert.ok(!modules.techTokens || g.players.length >= 3); assert.ok(!modules.strongholdCards || advanced);
assert.ok(g.status === 'lobby' || g.status === 'setup' && g.turn === 1 && g.phase === 0,
'Continue only an original lobby or original first-turn setup');
assert.ok(!g.ecazTreachery);
const initial = pairedE3NexusReload(g), actions: PairedE3NexusStep[] = [];
if (g.status === 'lobby') {
for (const [type, enabled] of Object.entries(modules)) if (!!g[type as keyof typeof modules] !== enabled)
g = stepPairedE3Nexus(g, { actor: g.host, action: { type, enabled } }, actions);
g.discoveryEnabled = discovery; g.nexusCards ??= { cards: null, phase: null };
for (const p of g.players) if (!p.ready) g = stepPairedE3Nexus(g, { actor: p.id, action: { type: 'ready' } }, actions);
if (options.initial) g = skills ? initializeLeaderSkillsGameForAudit(g) : initializePairedNexusGameForAudit(g);
else {
const sourceSkills = LEADER_SKILL_CARDS.map(c => c.id), selected = selectedSkills(g);
const rest = sourceSkills.filter(c => !selected.includes(c));
const skillOrder = selected.flatMap(c => [c, rest.shift()!]).concat(rest);
const cards = treacheryDeck(['ecaz']), remaining = [...cards];
const prefix = g.players.flatMap(p => Array.from({ length: p.faction === 'harkonnen' ? 2 : 1 }, () => {
const worthless = remaining.findIndex(c => c.kind === 'worthless');
const at = worthless >= 0 ? worthless : remaining.findIndex(c => c.kind === 'shield'); assert.ok(at >= 0);
return remaining.splice(at, 1)[0].id;
}));
const rolls = [...(skills ? permutation(sourceSkills, 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)]),
...Array<number>(spiceDeck().length + (discovery ? DISCOVERY_SPICE_CARDS.length + 1 : 0) - 1).fill(0xffffffff),
...(!skills && !roster.includes('beneGesserit') ? traitorRolls(g, options.assassinationFaction) : [])];
g = lottery(rolls, () => skills ? initializeLeaderSkillsGameForAudit(g) : initializePairedNexusGameForAudit(g));
}
}
assert.equal(!!g.leaderSkills, skills); assert.equal(!!g.discoveries, discovery);
for (const [type, enabled] of Object.entries(modules)) assert.equal(!!g[type as keyof typeof modules], enabled);
assert.ok(g.nexusCards?.cards);
const offered = pairedE3NexusReload(g), selected = selectedSkills(g);
for (let n = 0; g.status === 'setup' && n < 300; n++) {
let next: PairedE3NexusStep;
if (g.setupStage === 'leaderSkills') {
const actor = Object.keys(g.leaderSkills!.offers)[0], own = viewGame(g, actor).leaderSkills!;
const skill = options.initial ? own.offer!.cards.find(c => !own.unavailableSkills?.[c]) : selected[g.players.findIndex(p => p.id === actor)];
const eligible = own.eligibleLeaders.map(l => pairedE3NexusPlayer(g, actor).leaders.find(d => d.id === l.id)!);
const leader = pairedE3NexusPlayer(g, actor).faction === 'guild'
? eligible.find(l => l.name === 'Master Bewt') ?? eligible[0] : eligible.sort((a, b) => b.strength - a.strength)[0];
assert.ok(skill && leader && own.offer!.cards.includes(skill) && !own.unavailableSkills?.[skill]);
next = { actor, action: { type: 'leaderSkill', event: own.offer!.event, skill, leader: leader.id } };
if (!options.initial && Object.keys(g.leaderSkills!.offers).length === 1) {
actions.push(structuredClone(next));
g = lottery([...Array<number>(g.leaderSkills!.deck.length).fill(0xffffffff), ...traitorRolls(g, options.assassinationFaction)],
() => applyAction(pairedE3NexusReload(g), next.actor, next.action)); continue;
}
} else {
const proposed = nextPairedE3NexusStep(g) ?? quietPairedE3NexusChoice(g); assert.ok(proposed); next = proposed;
if (next.action.type === 'traitor' && pairedE3NexusPlayer(g, next.actor).faction === 'moritani') {
const choices = pairedE3NexusPlayer(g, next.actor).traitorChoices;
const target = options.assassinationFaction === 'ecaz'
? choices.find(card => leaders('ecaz').some(l => l.id === card))
: leaders('guild').find(l => l.name === 'Master Bewt')!.id;
next.action.leader = target && choices.includes(target) ? 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) {
actions.push(structuredClone(next));
g = applyAction(pairedE3NexusReload(g), next.actor, next.action);
} else g = stepPairedE3Nexus(g, next, actions);
}
assert.equal(g.status, 'playing');
const ecaz = g.players.find(p => p.faction === 'ecaz')!.id, moritani = g.players.find(p => p.faction === 'moritani')!.id;
pairedE3NexusInventory(g);
return { initial, offered, afterSetup: g, ecaz, moritani, others: g.players.filter(p => ![ecaz, moritani].includes(p.id)).map(p => p.id), actions };
}
function frontSpice(g: Game, match: (c: Game['spiceDeck'][number]) => boolean, position: number): void {
const at = g.spiceDeck.findIndex((c, i) => i >= position && match(c)); assert.ok(at >= position);
g.spiceDeck.splice(position, 0, g.spiceDeck.splice(at, 1)[0]);
}
/** Both natives stay unallied while two real other seats ally. Two/three-seat
* programmes return native setup: there cannot be two unallied native draws. */
export function createPairedE3NexusProgramme(options: PairedE3NexusOptions = {}): PairedE3NexusProgramme {
const f = createPairedE3NexusSetup(options);
let g = pairedE3NexusReload(f.afterSetup);
if (f.others.length < 2) return { ...f, game: g, alliance: null, drawing: null, draws: [], maker: null, claim: null, firstMentat: null };
for (let i = 0; i < (g.advanced ? 2 : 1); i++)
frontSpice(g, c => 'territory' in c && !c.discovery && !['arrakeen', 'tueks_sietch'].includes(c.territory), i);
g = advancePairedE3Nexus(g, s => s.phase === 5 && s.active === f.ecaz && pairedE3NexusClean(s), f.actions, quietPairedE3NexusChoice);
const claim = shipPairedE3Nexus(g, f.ecaz, 'sietch_tabr:14', 1, f.actions); g = claim.after;
let firstMentat: PairedE3NexusTransition | null = null;
for (let n = 0; g.turn === 1 && n < 600; n++) {
const before = pairedE3NexusReload(g), next = nextPairedE3NexusStep(g) ?? quietPairedE3NexusChoice(g); assert.ok(next);
g = stepPairedE3Nexus(g, next, f.actions);
if (g.turn === 2) firstMentat = { before, step: next, after: pairedE3NexusReload(g) };
}
assert.ok(firstMentat);
g = advancePairedE3Nexus(g, s => s.turn === 2 && s.phase === 1 && pairedE3NexusClean(s), f.actions, quietPairedE3NexusChoice);
frontSpice(g, c => 'worm' in c && (g.discoveries ? !!c.greatMaker : !c.greatMaker && !c.suppressed), 0);
for (let i = 1; i <= (g.advanced ? 2 : 1); i++) frontSpice(g, c => 'territory' in c && !c.discovery, i);
let maker: Game | null = null;
g = advancePairedE3Nexus(g, s => !!s.nexus && !s.spiceWindow && !s.spiceResolution && pairedE3NexusClean(s), f.actions, s => {
if (s.decision?.kind === 'greatMakerVote') maker ??= pairedE3NexusReload(s);
if (s.decision?.kind === 'greatMakerRide') {
const action = greatMakerRideAction(viewGame(s, s.decision.player), 'polar_sink', 0, 2, s.advanced ? 1 : 0);
assert.ok(action); return { actor: s.decision.player, action };
}
return quietPairedE3NexusChoice(s);
});
for (const [actor, target] of [[f.others[0], f.others[1]], [f.others[1], f.others[0]]])
g = stepPairedE3Nexus(g, { actor, action: { type: 'alliance', target } }, f.actions);
const alliance = pairedE3NexusReload(g);
g = advancePairedE3Nexus(g, s => s.nexusCards?.phase?.stage === 'drawing' && pairedE3NexusClean(s), f.actions, quietPairedE3NexusChoice);
const cards = g.nexusCards!.cards!;
cards.deck = ['ecaz', 'moritani', ...cards.deck.filter(c => c !== 'ecaz' && c !== 'moritani')];
const drawing = pairedE3NexusReload(g), draws: PairedE3NexusTransition[] = [];
for (const actor of [f.ecaz, f.moritani]) {
const before = pairedE3NexusReload(g), step: PairedE3NexusStep = { actor,
action: { type: 'nexusCardChoice', turn: g.turn, card: cards.hands[actor] ?? null, choice: 'draw', ownRedraws: 0 } };
g = stepPairedE3Nexus(g, step, f.actions); draws.push({ before, step, after: pairedE3NexusReload(g) });
}
g = advancePairedE3Nexus(g, s => s.phase === 5 && pairedE3NexusClean(s), f.actions, quietPairedE3NexusChoice);
pairedE3NexusInventory(g); return { ...f, game: g, alliance, drawing, draws, maker, claim, firstMentat };
}
export function shipPairedE3Nexus(g: Game, actor: string, location: string, amount: number,
actions?: PairedE3NexusStep[]): PairedE3NexusTransition {
assert.ok(g.phase === 5 && g.active === actor && pairedE3NexusClean(g));
const sources = g.homeworlds ? nativeShipmentSources(viewGame(g, actor), amount, 0) : undefined;
if (g.homeworlds) assert.ok(sources);
let action: Action;
if (location.startsWith('homeworld:')) {
assert.ok(sources); const choice = homeworldShipmentChoice(viewGame(g, actor), location,
Object.fromEntries(Object.entries(sources).filter(([, f]) => f.normal + f.elite > 0)));
assert.ok(choice.action, choice.blocked ?? undefined); action = choice.action;
} else {
const [site, sector] = location.split(':');
action = { type: 'ship', territory: site, sector: Number(sector), amount, elite: 0, allyPayment: 0,
...(sources ? { homeworldSources: sources } : {}) };
}
const before = pairedE3NexusReload(g), step = { actor, action };
g = stepPairedE3Nexus(g, step, actions);
g = advancePairedE3Nexus(g, s => pairedE3NexusClean(s) && !s.pendingShipment && !s.pendingHomeworldShipment,
actions, quietPairedE3NexusChoice);
return { before, step, after: g };
}
export function openPairedE3NexusBattle(g: Game, location: string, actions?: PairedE3NexusStep[]): Game {
g = advancePairedE3Nexus(g, s => s.phase === 6 && pairedE3NexusClean(s), actions, quietPairedE3NexusChoice);
const choice = viewGame(g, g.active!).battleChoices.find(c => c.territory === location); assert.ok(choice);
return stepPairedE3Nexus(g, { actor: choice.chooser, action: { type: 'chooseBattle', territory: location,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } }, actions);
}
export function revealPairedE3Nexus(g: Game, plans: readonly PairedE3NexusStep[], actions?: PairedE3NexusStep[]): Game {
g = advancePairedE3Nexus(g, s => !!s.battle && pairedE3NexusClean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false,
actions, quietPairedE3NexusChoice);
for (const plan of plans) {
g = stepPairedE3Nexus(g, plan, actions);
g = advancePairedE3Nexus(g, s => !s.response && !s.phaseOpening && s.decision?.kind !== 'fullPlanOffer', actions, quietPairedE3NexusChoice);
}
assert.ok(g.battle?.revealed); return g;
}
export function quotePairedE3NexusBattle(g: Game): BattleResolutionQuote {
const b = g.battle!, publicBattle = viewGame(g, g.host).battle!; assert.ok(b?.revealed);
const side = (actor: string): ResolutionCombatant => {
const p = pairedE3NexusPlayer(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) ?? (g.dukeVidal?.controller === actor ? g.dukeVidal.leader : undefined),
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 context = homeworldContext(g), home = b.territory.startsWith('homeworld:')
? homeworldCombatLocation({ ...context, players: context.players.map(p => ({ ...p, ally: pairedE3NexusPlayer(g, p.id).ally })), order: g.order }, g.homeworlds!.custody!, b.territory) : null;
return quoteBattleResolution({ advanced: g.advanced, typedCasualties: !!g.homeworlds, turn: g.turn,
territory: b.territory, aggressor: publicBattle.aggressor,
...(publicBattle.ecazOccupy?.profile ? { ecazOccupy: publicBattle.ecazOccupy.profile } : {}),
...(home ? { homeworld: { native: home.native, card: home.card, side: home.side, nativeForces: home.forces[home.native] } } : {}),
attacker: side(b.attacker), defender: side(b.defender), voters: publicBattle.traitorVoters.map(actor => ({ id: actor,
beneficiary: [b.attacker, b.defender].includes(actor) ? actor : pairedE3NexusPlayer(g, actor).ally!,
called: b.traitorCalls[actor] ?? false, traitors: pairedE3NexusPlayer(g, actor).traitors })),
participants: g.players, physicalCards: [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand)],
pendingAuditorPresent: false, pendingRetentionPresent: false, nexusMoritani: true });
}
export function pairedE3NexusInventory(g: Game): void {
const nx = g.nexusCards!.cards!; validateNexusCards(nx, g.players);
assert.deepEqual([...nx.deck, ...nx.discard, ...Object.values(nx.hands).filter(c => c !== null)].sort(), [...NEXUS_FACTIONS].sort());
const auction = g.auction?.cards.slice(g.auction.index + (g.currentAuctionSale ? 1 : 0)) ?? [];
assert.deepEqual([...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand), ...auction].map(c => c.id).sort(), treacheryDeck(['ecaz']).map(c => c.id).sort());
assert.equal(g.richeseCache?.length ?? 0, 0);
if (g.leaderSkills) {
validateLeaderSkills(g.leaderSkills, g.players);
assert.deepEqual([...g.leaderSkills.deck, ...Object.values(g.leaderSkills.offers).flatMap(o => o.cards),
...g.leaderSkills.assignments.map(a => a.skill)].sort(), LEADER_SKILL_CARDS.map(c => c.id).sort());
assert.ok(g.leaderSkills.assignments.every(a => a.leader !== DUKE_VIDAL_ID));
}
if (g.homeworlds) homeworldGameIntegrity(g);
if (g.discoveries) validateDiscoveryState(g.discoveries);
const homes = g.homeworlds?.custody ? homeworldForceGroups(homeworldContext(g), g.homeworlds.custody) : [];
for (const p of g.players) {
const visitors = homes.filter(h => h.native !== p.id).map(h => h.forces[p.id] ?? { normal: 0, elite: 0 });
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, n) => a + n, 0) + visitors.reduce((a, f) => a + f.normal + f.elite, 0), 20);
assert.deepEqual(p.leaders.map(l => l.id).sort(), leaders(p.faction).map(l => l.id).sort());
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((a, n) => a + n, 0) + visitors.reduce((a, f) => a + f.elite, 0), p.faction === 'fremen' ? 3 : 5);
}
const retired = g.moritaniAssassinate?.opportunities.filter(r => r.stage === 'replaced').map(r => r.card!) ?? [];
assert.deepEqual([...g.traitorReserve!, ...g.players.flatMap(p => [...p.traitors, ...p.traitorChoices]), ...retired,
...(g.ecazLoyalty?.card ? [g.ecazLoyalty.card] : [])].sort(), traitorDeck(g.players).sort());
assert.equal(pairedE3NexusPlayer(g, g.players.find(p => p.faction === 'ecaz')!.id).leaders.length, 5);
assert.equal(g.dukeVidal!.leader.id, DUKE_VIDAL_ID); validateAmbassadors(g.ecazAmbassadors!);
assert.deepEqual(g.moritaniTerror!.tokens.map(t => t.kind).sort(), [...TERROR_KINDS].sort());
}
@@ -364,7 +364,7 @@ for (const faction of ['ecaz', 'moritani'] as const) void test(`${faction} origi
custody(f.entry.after);
});
void test('original supplied offers keep human choices while Duke training, Advanced Harkonnen and paired E3 reject before custody conversion', () => {
void test('original skill offers keep human choices while Duke training and Advanced Harkonnen reject before custody conversion', () => {
const original = createGame('SUPPLIEDHWE3NX', newPlayer('native-original', 'Original native', 'ecaz'), false, ['ecaz']);
joinGame(original, newPlayer('guild-original', 'Original Guild', 'guild'));
const f = setup({ initial: original });
@@ -382,16 +382,9 @@ void test('original supplied offers keep human choices while Duke training, Adva
assert.ok(player(after, f.owner).leaders.some(l => l.id === assignment.leader));
assert.notEqual(assignment.leader, DUKE_VIDAL_ID);
});
const resumed = setup({ initial: reload(f.offered) });
const actual = resumed.afterSetup.leaderSkills!.assignments.find(a => a.owner === f.owner)!;
assert.ok(own.offer!.cards.includes(actual.skill));
assert.equal(player(resumed.afterSetup, f.owner).reserves, 14);
assert.equal(pool(resumed.afterSetup, f.owner, 'homeworld:ecaz').normal, 14); custody(resumed.afterSetup);
assert.throws(() => setup({ initial: f.afterSetup }), /lobby|ORIGINAL|setup/);
for (const [advanced, factions] of [
[true, ['ecaz', 'guild', 'harkonnen']],
[true, ['moritani', 'guild', 'harkonnen']],
[true, ['ecaz', 'guild', 'moritani']],
] as const) {
let g = createGame('ORIGINALSTANDALONEGUARD', newPlayer(factions[0], factions[0], factions[0]), advanced, ['ecaz']);
for (const faction of factions.slice(1)) joinGame(g, newPlayer(faction, faction, faction));
+405
View File
@@ -0,0 +1,405 @@
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 { CHOAM_AUDITOR_ID } from '../game/choam-auditor';
import { ownedTech } from '../game/tech-tokens';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { combatArmy } from '../game/combat-location';
import { nexusFaceDancersAction } from '../game/nexus-tleilaxu-options';
import { choamPowerAction, choamPowerPlays } from '../game/choam-power-options';
import {
advanceMixedNexus as advance, createMixedNexusProgramme as programme, createMixedNexusSetup as setup,
mixedNexusClean as clean, mixedNexusInventory as inventory, mixedNexusLeader as leader,
mixedNexusOpenBattle as open, mixedNexusPlayer as player, mixedNexusQuote as quote,
mixedNexusReload as reload, mixedNexusRevealPlans as plans, mixedNexusShip as ship,
nextMixedNexusStep as next, settleMixedNexus as settle, stepMixedNexus as step,
} from './fixture-mixed-nexus-modules';
import type { MixedNexusProgramme } from './fixture-mixed-nexus-modules';
function reject(g: Game, actor: string, action: Action): void {
const before = reload(g);
assert.throws(() => applyAction(g, actor, action));
assert.deepEqual(g, before, 'Rejected consumers cannot debit wallets, move counters or consume original stock.');
}
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 the actual owning window after JSON continuation.`);
const after = step(reload(saved), { actor, action }); outcome(after, action);
if (after.status === 'playing') inventory(after);
}
}
function movement(g: Game, actor: string): Game {
return advance(g, q => q.turn === 2 && q.phase === 5 && q.active === actor && clean(q));
}
/** These are explicit test-author choices. The reusable human controller stops
* at each of these windows rather than choosing another player's physical stock. */
function finish(g: Game): Game {
for (let n = 0; n < 600; n++) {
if (!g.battle && clean(g)) return g;
let control = next(g);
if (!control && !g.response && !g.phaseOpening) {
const d = g.decision; assert.ok(d);
if (d.kind === 'battleLosses') {
const elite = Math.max(...d.options.map(o => o.elite));
control = { actor: d.player, action: { type: 'decision', choice: d.options.findIndex(o => o.elite === elite) } };
} else if (d.kind === 'battleCards') control = { actor: d.player, action: { type: 'decision', discard: [] } };
else if (d.kind === 'techToken') control = { actor: d.player, action: { type: 'decision', token: d.choices[0] } };
else if (d.kind === 'faceDance') control = { actor: d.player, action: { type: 'decision', reveal: false } };
else throw Error(`The test must explicitly choose ${d.kind}.`);
}
assert.ok(control); g = step(g, control);
}
throw Error('Original mixed battle did not finish.');
}
function boundary(g: Game, kind: 'sukRescue' | 'ixSubstitution' | 'battleCards' | 'techToken' | 'faceDance'): Game {
for (let n = 0; n < 600; n++) {
if (!g.response && !g.phaseOpening && g.decision?.kind === kind) return g;
let control = next(g);
if (!control && !g.response && !g.phaseOpening && g.decision?.kind === 'battleLosses') {
const d = g.decision, elite = Math.max(...d.options.map(o => o.elite));
control = { actor: d.player, action: { type: 'decision', choice: d.options.findIndex(o => o.elite === elite) } };
}
assert.ok(control, `Choose ${g.decision?.kind ?? 'sealed plan'} before ${kind}.`); g = step(g, control);
}
throw Error(`Original aftermath did not reach ${kind}.`);
}
function collectionIncome(g: Game, actor: string): number {
if (g.phase !== 7 || !clean(g)) return 0;
return quoteSpiceCollection(g).receipts.filter(r => r.player === actor)
.reduce((n, r) => n + r.collected + r.strongholds, 0);
}
function originalClosing(f: MixedNexusProgramme): void {
assert.ok(f.firstMentat && f.alliance && f.drawing);
assert.equal(f.firstMentat.before.turn, 1); assert.equal(f.firstMentat.after.turn, 2);
const allies = f.alliance.players.filter(p => p.ally);
assert.equal(allies.length, 2);
assert.equal(allies[0].ally, allies[1].id); assert.equal(allies[1].ally, allies[0].id);
assert.equal(f.drawing.phase, 1);
if (f.drawing.advanced) assert.ok(f.drawing.spiceDiscard.every(pile => pile.length > 0), 'Both original Spice piles precede closing.');
for (const draw of f.draws) {
const actor = draw.step.actor, face = player(draw.after, actor).faction;
assert.equal(draw.after.nexusCards!.cards!.hands[actor], face);
reject(draw.after, actor, draw.step.action);
policies(draw.before, actor, a => a.type === 'nexusCardChoice', after => {
assert.equal(after.nexusCards!.cards!.hands[actor], face);
});
}
inventory(f.game);
}
void test('mixed original Ix offer/HMS and all14 Auditor exclusion retain physical native setup', () => {
const f = setup({ advanced: true, skills: true, homeworlds: true, tech: true, strongholds: true });
const ix = f.seats.ixians!, choam = f.seats.choam!;
assert.ok(f.ixOffer && f.offered);
assert.equal(player(f.afterSetup, ix).forces['hidden_mobile_stronghold:0'], 6);
assert.equal(player(f.afterSetup, ix).elites!.forces['hidden_mobile_stronghold:0'], 3);
const offer = viewGame(f.offered, choam).leaderSkills!;
assert.ok(player(f.offered, choam).leaders.some(l => l.id === CHOAM_AUDITOR_ID));
reject(f.offered, choam, { type: 'leaderSkill', event: offer.offer!.event,
skill: offer.offer!.cards[0], leader: CHOAM_AUDITOR_ID });
reject(f.offered, choam, { type: 'leaderSkill', event: offer.offer!.event,
skill: offer.offer!.cards[0], leader: player(f.offered, ix).leaders[0].id });
inventory(f.afterSetup);
});
void test('Basic two-family two-seat setup and smaller four-seat real closing require no Advanced module', () => {
const two = programme({ advanced: false, skills: false, roster: ['ixians', 'richese'] });
assert.equal(player(two.game, two.seats.ixians!).forces['hidden_mobile_stronghold:0'], 6);
assert.equal(player(two.game, two.seats.richese!).noField!.tokens.length, 3);
assert.equal(two.game.richeseCache!.length, 10); inventory(two.game);
const four = programme({ advanced: false, skills: false, tech: true,
roster: ['ixians', 'richese', 'guild', 'fremen'] });
originalClosing(four);
assert.ok(four.firstMarker);
assert.equal(player(four.firstMarker.after, four.seats.richese!).spice,
player(four.firstMarker.before, four.seats.richese!).spice - 1);
assert.equal(player(four.firstMarker.after, four.seats.richese!).reserves,
player(four.firstMarker.before, four.seats.richese!).reserves);
const three = programme({ advanced: false, skills: false, tech: true,
roster: ['tleilaxu', 'choam', 'guild'] });
originalClosing(three);
assert.equal(three.game.nexusCards!.cards!.hands[three.seats.tleilaxu!], 'tleilaxu');
assert.equal(player(three.game, three.seats.tleilaxu!).faceDancers!.length, 3);
});
void test('bare mixed Nexus all14 Smuggler pays from the real printed board pile, not a silently absent shared mode', () => {
const f = programme({ advanced: true, skills: true, skill: 'smuggler',
roster: ['ixians', 'richese', 'guild', 'fremen'] });
const ix = f.seats.ixians!, guild = f.seats.guild!, trainer = f.trainers[ix];
const pile = Object.entries(f.game.spice).find(([key, amount]) => key.startsWith('rock_outcroppings:') && amount > 0); assert.ok(pile);
const needed = new Set([ix, guild]); let g = reload(f.game);
while (needed.size) {
g = advance(g, q => q.phase === 5 && clean(q) && needed.has(q.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, ix, guild, 'rock_outcroppings'); const purse = player(g, ix).spice;
g = plans(g, [{ actor: ix, action: { type: 'battlePlan', leader: trainer, dial: 0, support: 0 } },
{ actor: guild, action: { type: 'battlePlan', leader: leader(g, guild), dial: 0, support: 0 } }]);
assert.equal(quote(g).winner, ix); g = finish(g);
const receipt = g.lastBattleContext!.smugglerCollection; assert.ok(receipt);
const strength = player(g, ix).leaders.find(l => l.id === trainer)!.strength;
assert.equal(receipt.amount, Math.min(strength, pile[1]));
assert.equal(g.spice[pile[0]], 0);
assert.equal(player(g, ix).spice, purse + pile[1], 'Smuggler and ordinary Collection cannot pay the same spice twice.');
inventory(g);
});
for (const location of ['homeworld:ixians', 'homeworld:guild', 'shrine'] as const) void test(
`mixed Ix ${location}: real typed Suk precedes same-quote residual Cyborg exchange without borrowed returns`, () => {
const nested = location === 'shrine';
const f = programme({ advanced: true, skills: true, homeworlds: true, discovery: nested, tech: true, strongholds: true });
originalClosing(f);
const ix = f.seats.ixians!, guild = f.seats.guild!, trainer = f.trainers[ix];
if (nested) {
assert.ok(f.firstArrival && f.entry && f.vote && f.ride);
assert.deepEqual(combatArmy(f.entry.after, ix, 'shrine'), { normal: 1, elite: 1 });
assert.equal(player(f.entry.after, ix).spice, player(f.entry.before, ix).spice);
assert.equal(player(f.entry.after, ix).reserves, player(f.entry.before, ix).reserves);
assert.equal(player(f.entry.after, ix).elites!.reserves, player(f.entry.before, ix).elites!.reserves);
reject(f.entry.before, guild, f.entry.step.action);
assert.equal(player(f.ride.after, f.seats.fremen!).reserves, player(f.ride.before, f.seats.fremen!).reserves - 2);
assert.equal(player(f.ride.after, f.seats.fremen!).elites!.reserves, player(f.ride.before, f.seats.fremen!).elites!.reserves - 1);
}
let g = reload(f.game);
if (location === 'homeworld:ixians') g = ship(movement(g, guild), guild, location, 3).after;
else if (location === 'homeworld:guild') g = ship(movement(g, ix), ix, location, 4, 3).after;
else {
const needed = new Set([ix, guild]);
while (needed.size) {
g = advance(g, q => q.phase === 5 && clean(q) && needed.has(q.active!)); const actor = g.active!;
g = ship(g, actor, location, 3, actor === ix ? 2 : 0).after; needed.delete(actor);
if (needed.size) g = step(g, { actor, action: { type: 'endMovement' } });
}
}
g = open(g, ix, guild, location);
const offer = viewGame(g, ix).nexusSuboids!.offer!; assert.ok(!offer.blocked);
const cunning: Action = { type: 'nexusSuboids', event: offer.event };
reject(g, guild, cunning); reject(g, ix, { ...cunning, event: 'old-battle' });
policies(g, ix, a => a.type === 'nexusSuboids', after => {
const resolved = settle(after);
assert.deepEqual(combatArmy(resolved, ix, location), combatArmy(g, ix, location));
assert.equal(player(resolved, ix).spice, player(g, ix).spice);
assert.equal(resolved.nexusCards!.cards!.hands[ix], null);
assert.equal(viewGame(resolved, ix).battle!.ownForces!.normalFreeSupport, true);
});
g = settle(step(g, { actor: ix, action: cunning })); reject(g, ix, cunning);
const held = player(g, ix).hand.find(c => c.kind === 'worthless')!, before = reload(g);
const homeBefore = combatArmy(g, ix, 'homeworld:ixians'), tokens = ownedTech(g.techTokens, guild);
g = plans(g, [{ actor: ix, action: { type: 'battlePlan', leader: trainer, dial: 3, support: 0, weapon: held.id } },
{ actor: guild, action: { type: 'battlePlan', leader: leader(g, guild), dial: location === 'homeworld:guild' ? 1 : 0,
support: location === 'homeworld:guild' ? 1 : 0 } }]);
const receipt = quote(g); assert.equal(receipt.winner, ix);
assert.equal(receipt.payments.find(p => p.player === ix)!.ownPayment, 0);
assert.equal(player(g, ix).spice, player(before, ix).spice, 'Plans never prepay support.');
g = boundary(g, 'sukRescue'); const d = g.decision; assert.ok(d?.kind === 'sukRescue');
const rescueChoice = d.options.findIndex(o => o.normal === 0 && o.elite === 1 && o.kept?.kind === 'elite'); assert.ok(rescueChoice >= 0);
const rescue: Action = { type: 'decision', event: d.event, choice: rescueChoice };
reject(g, guild, rescue); reject(g, ix, { ...rescue, event: 'old-rescue' });
if (location !== 'homeworld:ixians') reject(g, ix, { ...rescue,
destinations: { 'homeworld:ixians': { normal: 0, elite: 1 } } });
g = step(g, { actor: ix, action: rescue }); g = boundary(g, 'ixSubstitution');
const source = location === 'shrine' ? 'shrine:0' : location;
assert.deepEqual(g.pendingIxSubstitution!.losses, { [source]: 2 });
assert.equal(player(g, ix).elites!.tanks, player(before, ix).elites!.tanks + 2);
assert.equal(player(g, ix).tanks, player(before, ix).tanks + 2);
for (const token of tokens) assert.equal(g.techTokens![token].owner, guild);
const residual = reload(g), pool = combatArmy(g, ix, location);
const exchange: Action = { type: 'decision', sources: { [source]: 1 }, recover: { [source]: 1 } };
reject(g, guild, exchange); reject(g, ix, { ...exchange, recover: { [source]: 3 } });
reject(g, ix, { ...exchange, sources: { reserves: 1 } });
if (location !== 'homeworld:ixians') reject(g, ix, { ...exchange, sources: { 'homeworld:ixians': 1 } });
policies(g, ix, 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, ix).elites!.tanks, player(residual, ix).elites!.tanks - count);
assert.deepEqual(combatArmy(after, ix, location), { normal: pool.normal - count, elite: pool.elite + count });
assert.equal(player(after, ix).tanks, player(residual, ix).tanks);
assert.equal(player(after, ix).spice, player(residual, ix).spice);
});
g = step(g, { actor: ix, action: exchange });
assert.deepEqual(combatArmy(g, ix, location), { normal: pool.normal - 1, elite: pool.elite + 1 });
assert.equal(player(g, ix).elites!.tanks, player(residual, ix).elites!.tanks - 1);
assert.equal(player(g, ix).tanks, player(residual, ix).tanks);
if (location !== 'homeworld:ixians') assert.deepEqual(combatArmy(g, ix, 'homeworld:ixians'), homeBefore);
g = boundary(g, 'battleCards');
reject(g, guild, { type: 'decision', discard: [held.id] });
g = step(g, { actor: ix, action: { type: 'decision', discard: [held.id] } });
g = finish(g);
assert.equal(g.discard.filter(c => c.id === held.id).length, 1);
assert.deepEqual(combatArmy(g, guild, location), { normal: 0, elite: 0 });
const defeated = combatArmy(before, guild, location);
assert.equal(player(g, guild).tanks, player(before, guild).tanks + defeated.normal + defeated.elite);
for (const token of tokens) assert.equal(g.techTokens![token].owner, ix);
inventory(g);
});
void test('mixed nested original winning-card cleanup/Tech precede matching native Face Dance, trainer retirement and source-local refresh', () => {
const f = programme({ advanced: true, skills: true, homeworlds: true, discovery: true, tech: true, strongholds: true, skill: 'smuggler' });
const ix = f.seats.ixians!, tl = f.seats.tleilaxu!, guild = f.seats.guild!, trainer = f.trainers[ix];
assert.ok(player(f.afterSetup, tl).faceDancers!.some(c => c.leader === trainer && !c.revealed));
let g = ship(movement(f.game, tl), tl, 'shrine', 2).after;
g = open(g, ix, tl, 'shrine');
const held = player(g, ix).hand.find(c => c.kind === 'worthless')!, ownTraining = g.leaderSkills!.assignments.find(a => a.owner === tl)!;
const before = reload(g), tokens = ownedTech(g.techTokens, tl);
assert.equal(tokens.length, 1);
g = plans(g, [{ actor: ix, action: { type: 'battlePlan', leader: trainer, dial: 0, support: 0, weapon: held.id } },
{ actor: tl, action: { type: 'battlePlan', leader: leader(g, tl), dial: 0, support: 0 } }]);
assert.equal(quote(g).winner, ix);
g = boundary(g, 'battleCards');
for (const token of tokens) assert.equal(g.techTokens![token].owner, tl);
reject(g, tl, { type: 'decision', reveal: true, sources: { reserves: 1 }, sector: 0 });
g = step(g, { actor: ix, action: { type: 'decision', discard: [held.id] } });
// A sole token transfers automatically; multiple-token menus remain human-owned.
g = boundary(g, 'faceDance');
for (const token of tokens) assert.equal(g.techTokens![token].owner, ix);
assert.equal(g.discard.filter(c => c.id === held.id).length, 1);
const survivor = combatArmy(g, ix, 'shrine'), stock = reload(g), native = combatArmy(g, tl, 'homeworld:tleilaxu');
assert.ok(survivor.normal + survivor.elite >= 1);
const dance: Action = { type: 'decision', reveal: true, sources: { reserves: 1 }, sector: 0 };
reject(g, guild, dance); reject(g, tl, { ...dance, sources: { reserves: survivor.normal + survivor.elite + 1 } });
reject(g, tl, { ...dance, sources: { 'homeworld:ixians': 1 } });
policies(g, tl, 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, ix).leaders.find(l => l.id === trainer)!.dead, !!action.reveal);
assert.equal(player(after, tl).faceDancers!.find(c => c.leader === trainer)!.revealed, !!action.reveal);
assert.deepEqual(combatArmy(after, ix, 'shrine'), action.reveal ? { normal: 0, elite: 0 } : survivor);
assert.equal(combatArmy(after, tl, 'shrine').normal, count);
for (const token of tokens) assert.equal(after.techTokens![token].owner, ix);
assert.equal(player(after, tl).spice, player(stock, tl).spice);
});
g = step(g, { actor: tl, action: dance });
assert.equal(player(g, ix).reserves, player(stock, ix).reserves + survivor.normal + survivor.elite);
assert.equal(player(g, ix).elites!.reserves, player(stock, ix).elites!.reserves + survivor.elite);
assert.deepEqual(combatArmy(g, tl, 'homeworld:tleilaxu'), { normal: native.normal - 1, elite: 0 });
assert.deepEqual(combatArmy(g, tl, 'shrine'), { normal: 1, elite: 0 });
assert.equal(g.leaderSkills!.assignments.some(a => a.leader === trainer), false);
assert.equal(g.leaderSkills!.deck.filter(s => s === 'smuggler').length, 1);
assert.equal(g.leaderSkills!.assignments.find(a => a.owner === tl)!.leader, ownTraining.leader);
assert.equal(player(g, ix).tanks, player(before, ix).tanks, 'Face Dance returns surviving physical counters; it is not a battle casualty.');
reject(g, tl, dance);
const offer = viewGame(g, tl).nexusTleilaxu!.cunning!; assert.ok(!offer.blocked);
const refresh = nexusFaceDancersAction(viewGame(g, tl), offer.event); assert.ok(refresh);
const reserve = [...g.traitorReserve!], untouched = player(g, tl).faceDancers!.filter(c => !c.revealed);
const allowance = player(g, tl).faceDancerReplacedTurn;
reject(g, guild, refresh); reject(g, tl, { ...refresh, leaders: [trainer] });
policies(g, tl, a => a.type === 'nexusFaceDancers', after => {
assert.deepEqual(player(after, tl).faceDancers!.filter(c => untouched.some(old => old.leader === c.leader)), untouched);
assert.deepEqual(player(after, tl).faceDancers!.filter(c => !untouched.some(old => old.leader === c.leader)), [{ leader: reserve[0], revealed: false }]);
assert.deepEqual([...after.traitorReserve!].sort(), [...reserve.slice(1), trainer].sort());
assert.equal(player(after, tl).faceDancerReplacedTurn, allowance);
assert.equal(after.nexusCards!.cards!.hands[tl], null);
for (const token of tokens) assert.equal(after.techTokens![token].owner, ix);
assert.deepEqual(combatArmy(after, tl, 'shrine'), { normal: 1, elite: 0 });
});
g = step(g, { actor: tl, action: refresh }); reject(g, tl, refresh); g = finish(g); inventory(g);
});
void test('mixed native Richese signed five-plus-three invoice pays Guild once, materializes original supply and rejects borrowed companions', () => {
const f = programme({ advanced: true, skills: false, homeworlds: true, tech: true,
roster: ['ixians', 'choam', 'richese', 'guild', 'fremen'] });
originalClosing(f); const rich = f.seats.richese!, guild = f.seats.guild!, choam = f.seats.choam!;
let g = reload(f.game);
const arrivals = new Set([rich, guild]);
while (arrivals.size) {
g = advance(g, s => s.turn === 2 && s.phase === 5 && clean(s) && arrivals.has(s.active!));
const actor = g.active!;
if (actor === guild) g = ship(g, guild, 'arrakeen', 3).after;
else {
let before = reload(g);
const p = player(g, rich), offer = viewGame(g, rich).nexusRicheseCunning!; assert.ok(!offer.blocked);
const request: Action = { type: 'ship', territory: 'arrakeen', sector: 10,
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 };
const native = combatArmy(g, rich, 'homeworld:richese'); assert.ok(native.normal >= 8);
reject(g, guild, request); reject(g, rich, { ...request, nexus: 'old-richese' });
reject(g, rich, { ...request, revealedToken: request.noField });
reject(g, rich, { ...request, smugglerCompanion: true });
reject(g, rich, { ...request, source: 'homeworld:guild' });
g = settle(step(g, { actor: rich, action: request }));
assert.equal(player(g, rich).spice, player(before, rich).spice - 1);
assert.equal(player(g, guild).spice, player(before, guild).spice + 1);
assert.equal(player(g, choam).spice, player(before, choam).spice, 'Shipment is not CHOAM battle support income.');
assert.deepEqual(combatArmy(g, rich, 'homeworld:richese'), { normal: native.normal - 5, elite: 0 });
assert.equal(player(g, rich).forces['arrakeen:10'], 5);
assert.equal(g.nexusCards!.cards!.hands[rich], null); reject(g, rich, request);
before = reload(g);
const reveal: Action = { type: 'revealNoField', token: request.noField, event: player(g, rich).noFieldEvent };
reject(g, rich, { ...reveal, event: 'old-marker' });
g = step(g, { actor: rich, action: reveal });
assert.equal(player(g, rich).forces['arrakeen:10'], 8);
assert.deepEqual(combatArmy(g, rich, 'homeworld:richese'), { normal: native.normal - 8, elite: 0 });
for (const payer of [rich, guild, choam]) assert.equal(player(g, payer).spice, player(before, payer).spice, 'Hidden three materialization cannot repeat the signed shipment payment.');
}
arrivals.delete(actor);
if (arrivals.size) g = step(g, { actor, action: { type: 'endMovement' } });
}
// Both actual actors use their existing movement slots; CHOAM receives half
// subsequent paid support, not the already settled marker invoice.
g = open(g, rich, guild, 'arrakeen');
const before = reload(g);
g = plans(g, [{ actor: rich, action: { type: 'battlePlan', leader: leader(g, rich, true), dial: 2, support: 2 } },
{ actor: guild, action: { type: 'battlePlan', leader: leader(g, guild), dial: 2, support: 2 } }]);
const receipt = quote(g); assert.equal(receipt.winner, rich);
assert.equal(player(g, choam).spice, player(before, choam).spice, 'Income waits for actual battle resolution.');
g = finish(g);
assert.equal(player(g, choam).spice, player(before, choam).spice + 2 + collectionIncome(g, choam));
assert.equal(player(g, rich).spice, player(before, rich).spice - 2 + collectionIncome(g, rich));
assert.equal(player(g, guild).spice, player(before, guild).spice - 2 + collectionIncome(g, guild));
assert.equal(player(g, rich).forces['arrakeen:10'], 6);
assert.equal(player(g, guild).tanks, player(before, guild).tanks + 3); inventory(g);
});
for (const fuelKind of ['special', 'shield'] as const) void test(
`mixed CHOAM original ${fuelKind} fuels any-Treachery Cunning and delays actual other-payer support through Suk`, () => {
const f = programme({ advanced: true, skills: true, homeworlds: true, tech: true, strongholds: true,
skillOwner: 'choam', skill: 'suk-graduate', fuelKind });
const choam = f.seats.choam!, guild = f.seats.guild!, trainer = f.trainers[choam];
let g = movement(f.game, choam);
const fuel = player(g, choam).hand.find(c => c.kind === fuelKind && (fuelKind !== 'special' || c.effect !== 'karama')); assert.ok(fuel);
const play = choamPowerPlays(viewGame(g, choam), 'kulon').find(p => p.source === 'nexus' && p.card.id === fuel.id); assert.ok(play);
const request = choamPowerAction(viewGame(g, choam), play); assert.ok(request);
const beforeFuel = reload(g);
reject(g, guild, request); reject(g, choam, { ...request, nexus: 'old-choam' });
reject(g, choam, { ...request, card: player(g, guild).hand[0].id });
g = settle(step(g, { actor: choam, action: request }));
assert.equal(player(g, choam).spice, player(beforeFuel, choam).spice);
assert.equal(g.discard.filter(c => c.id === fuel.id).length, 1);
assert.equal(player(g, choam).hand.some(c => c.id === fuel.id), false);
assert.equal(g.choamMovement!.bonus, 1); assert.equal(g.nexusCards!.cards!.hands[choam], null);
reject(g, choam, request);
g = ship(g, choam, 'homeworld:guild', 3).after;
g = open(g, choam, guild, 'homeworld:guild'); const before = reload(g), tokens = ownedTech(g.techTokens, guild);
g = plans(g, [{ actor: choam, action: { type: 'battlePlan', leader: trainer, dial: 2, support: 2 } },
{ actor: guild, action: { type: 'battlePlan', leader: leader(g, guild), dial: 2, support: 2 } }]);
assert.equal(player(g, choam).spice, player(before, choam).spice);
g = boundary(g, 'sukRescue'); const d = g.decision; assert.ok(d?.kind === 'sukRescue');
assert.equal(player(g, choam).spice, player(before, choam).spice - 2);
assert.equal(player(g, guild).spice, player(before, guild).spice - 2);
assert.equal(g.pendingChoamBattleIncome!.amount, 1, 'Own support is excluded; only the actual Guild payer funds native income.');
for (const token of tokens) assert.equal(g.techTokens![token].owner, guild);
const source = combatArmy(g, choam, 'homeworld:guild'), home = combatArmy(g, choam, 'homeworld:choam');
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(g, guild, rescue); reject(g, choam, { ...rescue, event: 'old-rescue' });
policies(g, choam, a => a.type === 'decision' && a.choice !== undefined, (after, action) => {
const saved = d.options[Number(action.choice)], kept = saved.kept?.kind === 'normal' ? 1 : 0;
assert.equal(player(after, choam).tanks, player(g, choam).tanks + 2 - saved.normal - saved.elite);
assert.deepEqual(combatArmy(after, choam, 'homeworld:guild'), { normal: source.normal - 2 + kept, elite: 0 });
assert.deepEqual(combatArmy(after, choam, 'homeworld:choam'), { normal: home.normal + saved.normal - kept, elite: 0 });
assert.equal(player(after, choam).spice, player(g, choam).spice + (after.pendingChoamBattleIncome ? 0 : 1) + collectionIncome(after, choam));
});
g = step(g, { actor: choam, action: rescue }); g = finish(g);
assert.equal(g.lastBattleContext!.winner, choam);
assert.deepEqual(combatArmy(g, choam, 'homeworld:guild'), { normal: 2, elite: 0 });
assert.equal(player(g, choam).tanks, player(before, choam).tanks + 1);
assert.equal(player(g, choam).spice, player(before, choam).spice - 2 + 1 + collectionIncome(g, choam));
for (const token of tokens) assert.equal(g.techTokens![token].owner, choam);
inventory(g);
});
-1
View File
@@ -90,7 +90,6 @@ void test('public trigger requires a native Advanced Moritani normal loss, a sur
{ opposingLeader: 'cheap-hero-traitor' },
{ opposingLeader: 'emperor-0' },
{ faction: 'harkonnen', opposingLeader: 'harkonnen-0' },
{ faction: 'ecaz', opposingLeader: 'ecaz-0' },
{ turn: 0 },
{ turn: 1.5 },
{ event: '' },
@@ -0,0 +1,437 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, initializeLeaderSkillsGameForAudit, initializePairedNexusGameForAudit, joinGame, newPlayer, viewGame } from '../game/engine';
import type { Action, Game } from '../game/engine';
import type { FactionId } from '../game/catalog';
import { DIFFICULTIES } from '../game/bot-profiles';
import { leaders } from '../game/cards';
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
import { ownedTech } from '../game/tech-tokens';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { territory } from '../game/board';
import { nexusEcazDukeAction } from '../game/nexus-ecaz-duke-options';
import { homeworldForceGroups } from '../game/homeworld-custody';
import { homeworldContext } from '../game/homeworld-game';
import {
advancePairedE3Nexus as advance, createPairedE3NexusProgramme as programme,
createPairedE3NexusSetup as setup, nextPairedE3NexusStep,
openPairedE3NexusBattle as openBattle, pairedE3NexusClean as clean,
pairedE3NexusInventory as inventory, pairedE3NexusPlayer as player,
pairedE3NexusPolicy as policy, pairedE3NexusReload as reload,
quietPairedE3NexusChoice as quiet, quotePairedE3NexusBattle as quote,
revealPairedE3Nexus as reveal, shipPairedE3Nexus as ship, stepPairedE3Nexus as step,
} from './fixture-paired-e3-nexus-modules';
import type { PairedE3NexusOptions, PairedE3NexusStep } from './fixture-paired-e3-nexus-modules';
function reject(g: Game, actor: string, action: Action, error?: RegExp): void {
const before = structuredClone(g);
if (error) assert.throws(() => applyAction(g, actor, action), error);
else assert.throws(() => applyAction(g, actor, action));
assert.deepEqual(g, before, 'A rejected consumer cannot spend, kill, place or transfer original pieces');
}
function policies(g: Game, actor: string, effect: (after: Game, action: Action) => void): void {
for (const difficulty of DIFFICULTIES) {
const action = policy(reload(g), actor, difficulty)[0]; assert.ok(action, difficulty);
const after = step(reload(g), { actor, action }); effect(after, action); inventory(after);
}
}
function settlementChoice(g: Game): PairedE3NexusStep | null {
const d = g.decision; if (!d || g.response || g.phaseOpening) return null;
if (d.kind === 'battleLosses') return { actor: d.player, action: { type: 'decision', choice: 0 } };
if (d.kind === 'battleCards') return { actor: d.player, action: { type: 'decision', discard: [] } };
if (d.kind === 'techToken') return { actor: d.player, action: { type: 'decision', token: d.choices[0] } };
if (d.kind === 'moritaniRetention') return { actor: d.player, action: { type: 'decision', keep: null,
...(d.source === 'nexus' ? { event: d.event } : {}) } };
if (d.kind === 'sukRescue') {
const choice = d.options.map((o, index) => ({ index, saved: o.normal + o.elite, kept: !!o.kept }))
.sort((a, b) => b.saved - a.saved || Number(b.kept) - Number(a.kept))[0].index;
return { actor: d.player, action: { type: 'decision', event: d.event, choice } };
}
return quiet(g);
}
function settle(g: Game, stopAssassination = false, normalCaller?: string): Game {
for (let n = 0; n < 400; n++) {
if (!g.response && !g.phaseOpening && (stopAssassination && g.decision?.kind === 'moritaniAssassinate' ||
!g.battle && clean(g))) return g;
const next = nextPairedE3NexusStep(g) ?? settlementChoice(g); assert.ok(next);
if (next.action.type === 'traitorCall' && next.actor === normalCaller) next.action.call = true;
g = step(g, next);
}
throw Error('Original battle settlement did not finish');
}
function collectionIncome(g: Game, actor: string): number {
return g.phase === 7 ? quoteSpiceCollection(g).receipts.find(r => r.player === actor)?.strongholds ?? 0 : 0;
}
function shipWhenActive(g: Game, actor: string, location: string, amount: number): Game {
g = advance(g, s => s.phase === 5 && s.active === actor && clean(s), undefined, quiet);
return ship(g, actor, location, amount).after;
}
function guildLoss(options: PairedE3NexusOptions = {}, normalCall = false) {
const f = programme(options), guild = f.game.players.find(p => p.faction === 'guild')!.id;
let g = shipWhenActive(f.game, f.moritani, options.homeworlds && !normalCall ? 'homeworld:guild' : 'tueks_sietch:5', 1);
const location = options.homeworlds && !normalCall ? 'homeworld:guild' : 'tueks_sietch';
g = openBattle(g, location);
g = advance(g, s => !!s.battle && clean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false,
undefined, s => s.decision?.kind === 'leaderSkillVisibility' && s.decision.player === guild && normalCall
? { actor: guild, action: { type: 'leaderSkillVisibility', event: s.decision.event, hide: true } } : quiet(s));
const own = player(g, f.moritani).leaders.filter(l => !l.dead && !l.usedAt &&
!g.leaderSkills?.assignments.some(a => a.leader === l.id)).sort((a, b) => a.strength - b.strength)[0];
const target = leaders('guild').find(l => l.name === 'Master Bewt')!.id;
const enemy = normalCall ? player(g, guild).leaders.find(l => l.id === target)!
: player(g, guild).leaders.filter(l => !l.dead && !l.usedAt && l.id !== target).sort((a, b) => b.strength - a.strength)[0];
assert.ok(own && enemy && player(g, f.moritani).traitors.includes(target));
const plans: PairedE3NexusStep[] = [
{ actor: f.moritani, action: { type: 'battlePlan', leader: own.id, dial: 0, support: 0,
weapon: player(g, f.moritani).hand.find(c => c.kind === 'worthless')?.id } },
{ actor: guild, action: { type: 'battlePlan', leader: enemy.id, dial: 2, support: g.advanced ? 2 : 0,
weapon: player(g, guild).hand.find(c => c.kind === 'worthless')?.id } },
];
const revealed = reveal(g, plans), pending = settle(revealed, true, normalCall ? f.moritani : undefined);
return { f, guild, target, location, revealed, pending, enemy: enemy.id };
}
for (const skills of [false, true]) void test(`${skills ? 'all14' : 'No-Skills'} native assassination against Ecaz consumes an original different disc and printed bounty`, () => {
const f = programme({ advanced: true, skills, tech: true, assassinationFaction: 'ecaz' });
const arrivals = new Set([f.ecaz, f.moritani]);
let g = reload(f.game);
while (arrivals.size) {
g = advance(g, s => s.turn === 2 && s.phase === 5 && clean(s) && arrivals.has(s.active!), undefined, quiet);
const actor = g.active!;
g = ship(g, actor, `rock_outcroppings:${territory('rock_outcroppings').sectors[0]}`, actor === f.ecaz ? 2 : 1).after;
arrivals.delete(actor);
if (arrivals.size) g = step(g, { actor, action: { type: 'endMovement' } });
}
g = openBattle(g, 'rock_outcroppings');
g = advance(g, s => !!s.battle && clean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false, undefined, quiet);
const ecaz = player(g, f.ecaz), target = ecaz.leaders.find(l => player(g, f.moritani).traitors.includes(l.id))!;
assert.ok(target && !target.dead);
const winner = ecaz.leaders.find(l => !l.dead && !l.usedAt && l.id !== target.id &&
!g.leaderSkills?.assignments.some(a => a.leader === l.id))!;
const loser = player(g, f.moritani).leaders.filter(l => !l.dead && !l.usedAt &&
!g.leaderSkills?.assignments.some(a => a.leader === l.id)).sort((a, b) => a.strength - b.strength)[0];
assert.ok(winner && loser);
const revealed = reveal(g, [
{ actor: f.ecaz, action: { type: 'battlePlan', leader: winner.id, dial: 1, support: 1 } },
{ actor: f.moritani, action: { type: 'battlePlan', leader: loser.id, dial: 0, support: 0 } },
]);
const pending = settle(revealed, true);
assert.equal(pending.lastBattleContext!.winner, f.ecaz);
const before = reload(pending);
const killed = step(pending, { actor: f.moritani, action: {
type: 'decision', event: pending.lastBattleContext!.event, card: target.id,
} });
assert.equal(player(killed, f.ecaz).leaders.find(l => l.id === target.id)!.dead, true);
assert.equal(player(killed, f.ecaz).leaders.find(l => l.id === target.id)!.deaths, target.deaths + 1);
assert.equal(player(killed, f.moritani).spice, player(before, f.moritani).spice + target.strength);
const done = settle(killed);
const later = advance(done, s => s.turn === 3 && s.phase === 1 && clean(s), undefined, quiet);
assert.equal(player(later, f.moritani).traitors.includes(target.id), false);
assert.equal(later.traitorReserve!.includes(target.id), false);
inventory(later);
});
void test('original paired E3 Basic/Advanced two-to-six-seat setup conserves ecaz33, cache0, all12 and optional all14', () => {
const factions: FactionId[] = ['ecaz', 'moritani', 'guild', 'emperor', 'atreides', 'fremen'];
for (const advanced of [false, true]) for (const skills of [false, true]) for (let count = 2; count <= 6; count++) {
const f = setup({ advanced, skills, roster: factions.slice(0, count), tech: count >= 3, strongholds: advanced });
assert.equal(player(f.afterSetup, f.ecaz).leaders.length, 5);
assert.equal(player(f.afterSetup, f.moritani).leaders.length, 5);
assert.equal(player(f.afterSetup, f.ecaz).reserves, 14);
assert.equal(player(f.afterSetup, f.moritani).reserves, 14);
assert.equal(player(f.afterSetup, f.moritani).traitors.length, 1);
assert.equal(f.afterSetup.dukeVidal!.controller, null);
assert.equal(f.afterSetup.dukeVidal!.leader.dead, false);
assert.ok(!f.afterSetup.leaderSkills?.assignments.some(a => a.leader === DUKE_VIDAL_ID));
assert.equal(f.afterSetup.moritaniTerror!.tokens.filter(t => t.status === 'available').length, 6);
inventory(reload(f.afterSetup));
}
const basic = setup({ advanced: false, skills: true, roster: ['ecaz', 'moritani', 'harkonnen'] });
assert.equal(player(basic.afterSetup, 'harkonnen').traitors.length, 4); inventory(basic.afterSetup);
let blocked = createGame('PAIRHARKGUARD', newPlayer('e', 'Ecaz', 'ecaz'), true, ['ecaz']);
joinGame(blocked, newPlayer('m', 'Moritani', 'moritani'));
joinGame(blocked, newPlayer('h', 'Harkonnen', 'harkonnen'));
for (const p of blocked.players) blocked = applyAction(blocked, p.id, { type: 'ready' });
blocked.nexusCards = { cards: null, phase: null };
const before = structuredClone(blocked);
assert.throws(() => initializePairedNexusGameForAudit(blocked));
assert.deepEqual(blocked, before, 'Rejected capture composition cannot redeal original pieces.');
});
void test('original paired E3 skill offers reject shared Duke and foreign training immutably', () => {
let lobby = createGame('ORIGINALPAIREDE3', newPlayer('original-e', 'Ecaz', 'ecaz'), true, ['ecaz']);
for (const faction of ['moritani', 'guild', 'emperor'] as const) joinGame(lobby, newPlayer(`original-${faction}`, faction, faction));
lobby.nexusCards = { cards: null, phase: null };
for (const p of lobby.players) lobby = applyAction(lobby, p.id, { type: 'ready' });
const offered = initializeLeaderSkillsGameForAudit(lobby), actor = Object.keys(offered.leaderSkills!.offers)[0];
const own = viewGame(offered, actor).leaderSkills!, skill = own.offer!.cards.find(c => !own.unavailableSkills?.[c]); assert.ok(skill);
reject(offered, actor, { type: 'leaderSkill', event: own.offer!.event, skill, leader: DUKE_VIDAL_ID });
const foreignDisc = offered.players.find(p => p.id !== actor)!.leaders[0].id;
reject(offered, actor, { type: 'leaderSkill', event: own.offer!.event, skill, leader: foreignDisc });
});
for (const skills of [false, true]) void test(`${skills ? 'all14' : 'No-Skills'} qualifying original Great Maker/both-pile closing deals both native Cunning singletons`, () => {
const f = programme({ advanced: true, skills, discovery: true, homeworlds: true, tech: true, strongholds: true,
roster: ['ecaz', 'moritani', 'guild', 'fremen'] });
assert.ok(f.maker && f.alliance && f.drawing);
assert.equal(player(f.alliance, f.others[0]).ally, f.others[1]);
assert.equal(player(f.alliance, f.others[1]).ally, f.others[0]);
assert.equal(player(f.alliance, f.ecaz).ally, null); assert.equal(player(f.alliance, f.moritani).ally, null);
assert.ok(Object.values(f.afterSetup.nexusCards!.cards!.hands).every(c => c === null));
assert.ok(f.drawing.spiceDiscard[0].some(c => 'territory' in c));
assert.ok(f.drawing.spiceDiscard[1].some(c => 'territory' in c));
assert.equal(f.game.nexusCards!.cards!.hands[f.ecaz], 'ecaz');
assert.equal(f.game.nexusCards!.cards!.hands[f.moritani], 'moritani');
for (const draw of f.draws) {
reject(draw.after, draw.step.actor, draw.step.action);
policies(draw.before, draw.step.actor, after => {
const original = draw.before.nexusCards!.cards!, current = after.nexusCards!.cards!;
const held = current.hands[draw.step.actor];
assert.equal(current.deck.length, original.deck.length - (held === null ? 0 : 1));
if (held !== null) assert.equal(current.deck.includes(held), false);
});
}
assert.ok(f.firstMentat && f.claim);
assert.equal(f.game.strongholdCards!.owners.sietch_tabr, f.ecaz);
assert.equal(player(f.claim.after, f.ecaz).reserves, player(f.claim.before, f.ecaz).reserves - 1);
inventory(f.game);
});
for (const skills of [false, true]) void test(`${skills ? 'all14' : 'No-Skills'} quiet living-Duke Cunning is separate from training and allied Arrakis Occupy`, () => {
const f = programme({ skills, tech: true });
let g = shipWhenActive(f.game, f.ecaz, 'tueks_sietch:5', 1);
g = advance(g, s => s.phase === 6 && clean(s) && !s.battle, undefined, quiet);
const action = nexusEcazDukeAction(viewGame(g, f.ecaz)); assert.ok(action);
reject(g, f.moritani, action); reject(g, f.ecaz, { ...action, event: 'stale' });
policies(g, f.ecaz, after => {
assert.equal(after.dukeVidal!.controller, f.ecaz); assert.equal(after.dukeVidal!.leader.strength, 6);
assert.equal(after.nexusCards!.cards!.hands[f.ecaz], null);
assert.ok(!after.leaderSkills?.assignments.some(a => a.leader === DUKE_VIDAL_ID));
assert.equal(player(after, f.ecaz).leaders.length, 5);
assert.equal(player(after, f.ecaz).spice, player(g, f.ecaz).spice);
});
const claimed = step(g, { actor: f.ecaz, action });
reject(claimed, f.ecaz, action); inventory(claimed);
});
for (const skills of [false, true]) for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic even-FORCE'} ${skills ? 'all14' : 'No-Skills'} coalition pays the selected leader, kills each physical owner and transfers only the loser Tech`, () => {
const f = programme({ skills, advanced, tech: true, strongholds: advanced });
let g = advance(f.game, s => s.turn === 3 && s.phase === 1 && clean(s), undefined, quiet);
const at = g.spiceDeck.findIndex(c => 'worm' in c && !c.greatMaker && !c.suppressed); assert.ok(at >= 0);
g.spiceDeck.unshift(g.spiceDeck.splice(at, 1)[0]);
g = advance(g, s => !!s.nexus && !s.spiceWindow && !s.spiceResolution && clean(s), undefined, quiet);
const ally = f.others[1], opponent = f.others[0];
g = step(g, { actor: ally, action: { type: 'alliance', target: null } });
for (const [actor, target] of [[f.ecaz, ally], [ally, f.ecaz]]) g = step(g, { actor, action: { type: 'alliance', target } });
const amounts: Record<string, number> = { [f.ecaz]: advanced ? 4 : 3, [ally]: 4, [opponent]: 6 };
const shipped = new Set<string>();
for (let n = 0; !(g.phase === 6 && clean(g)) && n < 600; n++) {
if (g.phase === 5 && clean(g) && g.active && amounts[g.active] && !shipped.has(g.active)) {
const actor = g.active, arrival = ship(g, actor, 'sietch_tabr:14', amounts[actor]);
assert.equal(player(arrival.after, actor).reserves, player(arrival.before, actor).reserves - amounts[actor]);
assert.equal(player(arrival.after, actor).forces['sietch_tabr:14'],
(player(arrival.before, actor).forces['sietch_tabr:14'] ?? 0) + amounts[actor]);
assert.ok(player(arrival.after, actor).spice < player(arrival.before, actor).spice);
shipped.add(actor); g = arrival.after;
} else {
const next = nextPairedE3NexusStep(g) ?? quiet(g); assert.ok(next); g = step(g, next);
}
}
assert.equal(g.phase, 6); assert.equal(shipped.size, 3);
const opened = openBattle(g, 'sietch_tabr'), d = opened.decision; assert.ok(d?.kind === 'ecazBattleLead');
reject(opened, ally, { type: 'decision', event: d.event, lead: f.ecaz });
reject(opened, f.ecaz, { type: 'decision', event: d.event, lead: opponent });
const run = (state: Game, lead: string) => {
state = advance(state, s => !!s.battle && clean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false,
undefined, s => s.decision?.kind === 'leaderSkillVisibility' && s.decision.player === lead
? { actor: lead, action: { type: 'leaderSkillVisibility', event: s.decision.event, hide: true } } : quiet(s));
const profile = viewGame(state, lead).battle!.ecazOccupy!.profile!;
const assignment = state.leaderSkills?.assignments.find(a => a.owner === lead);
const own = assignment ? player(state, lead).leaders.find(l => l.id === assignment.leader)!
: player(state, lead).leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0];
const enemy = player(state, opponent).leaders.filter(l => !l.dead &&
!state.leaderSkills?.assignments.some(a => a.leader === l.id)).sort((a, b) => a.strength - b.strength)[0];
const revealed = reveal(state, [
{ actor: lead, action: { type: 'battlePlan', leader: own.id, dial: profile.fixedEcazDial + 3, support: advanced ? 3 : 0,
weapon: player(state, lead).hand.find(c => c.kind === 'worthless')?.id } },
{ actor: opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 1, support: advanced ? 1 : 0 } },
]), q = quote(revealed), done = settle(revealed);
assert.equal(q.winner, lead); assert.equal(done.lastBattleContext!.winner, lead);
if (skills) {
const selected = revealed.battle!.attacker === lead ? q.leaderSkillBonuses.attacker : q.leaderSkillBonuses.defender;
assert.equal(selected.bonus, lead === f.ecaz ? 3 : 0,
'Only the actual selected Warmaster disc with its original Worthless gets +3; the ally cannot borrow that training');
}
const holder = revealed.strongholdCards?.owners.sietch_tabr;
assert.deepEqual(q.strongholdIncome, advanced && holder === lead ? [{ player: lead, amount: 1 }] : []);
const fixed = advanced ? 3 : 2;
assert.equal(player(done, f.ecaz).tanks - player(revealed, f.ecaz).tanks, fixed);
assert.equal(player(done, ally).tanks - player(revealed, ally).tanks, 3);
assert.equal(player(done, opponent).tanks - player(revealed, opponent).tanks, 6);
for (const payment of q.payments) {
const income = q.strongholdIncome.filter(i => i.player === payment.player).reduce((a, i) => a + i.amount, 0);
assert.equal(player(done, payment.player).spice, player(revealed, payment.player).spice - payment.ownPayment + income + collectionIncome(done, payment.player));
}
assert.equal(done.techTokens![ownedTech(revealed.techTokens, opponent)[0]].owner, lead);
for (const original of revealed.players.filter(p => p.id !== opponent))
for (const token of ownedTech(revealed.techTokens, original.id))
assert.equal(done.techTokens![token].owner, original.id, 'Only actual loser tokens transfer.');
inventory(done);
};
policies(opened, f.ecaz, (after, action) => run(after, String(action.lead)));
for (const lead of [f.ecaz, ally]) run(step(opened, { actor: f.ecaz, action: { type: 'decision', event: d.event, lead } }), lead);
});
void test('native all14 post-loss assassination waits for real winner casualties, Suk effects and support, then pays printed death bounty and replaces privately at actual Mentat', () => {
const b = guildLoss({ skills: true, advanced: true, tech: true, strongholds: true });
const { pending, revealed, f, guild, target } = b, q = quote(revealed);
assert.equal(q.winner, guild); assert.ok(pending.decision?.kind === 'moritaniAssassinate');
assert.equal(player(pending, f.moritani).tanks, player(revealed, f.moritani).tanks + 1);
assert.equal(player(pending, guild).tanks, player(revealed, guild).tanks + 1);
assert.equal(player(pending, guild).reserves, player(revealed, guild).reserves + 1);
const income = q.strongholdIncome.filter(i => i.player === guild).reduce((a, i) => a + i.amount, 0);
assert.equal(player(pending, guild).spice, player(revealed, guild).spice - 2 + income);
assert.equal(pending.techTokens![ownedTech(revealed.techTokens, f.moritani)[0]].owner, f.moritani,
'Assassination pauses the winner suffix before the original automatic single-token transfer');
const d = pending.decision; assert.ok(d?.kind === 'moritaniAssassinate');
const action: Action = { type: 'decision', event: d.event, card: target };
reject(pending, guild, action); reject(pending, f.moritani, { ...action, card: b.enemy });
const beforeTarget = player(pending, guild).leaders.find(l => l.id === target)!;
let killed = step(pending, { actor: f.moritani, action });
assert.equal(player(killed, guild).leaders.find(l => l.id === target)!.dead, true);
assert.equal(player(killed, guild).leaders.find(l => l.id === target)!.deaths, beforeTarget.deaths + 1);
assert.equal(player(killed, f.moritani).spice, player(pending, f.moritani).spice + beforeTarget.strength);
assert.ok(!killed.leaderSkills!.assignments.some(a => a.leader === target));
assert.equal(killed.leaderSkills!.deck.filter(c => c === 'suk-graduate').length, 1);
killed = settle(killed);
assert.equal(killed.techTokens![ownedTech(revealed.techTokens, f.moritani)[0]].owner, guild);
const retired = target;
const later = advance(killed, s => s.turn === 3 && s.phase === 1 && clean(s), undefined, quiet);
assert.ok(!player(later, f.moritani).traitors.includes(retired));
assert.equal(player(later, f.moritani).traitors.length, 1);
assert.ok(!later.traitorReserve!.includes(retired));
assert.equal(later.moritaniAssassinate!.opportunities.filter(r => r.card === retired && r.stage === 'replaced').length, 1);
inventory(later);
policies(pending, f.moritani, after => {
const finished = settle(after); assert.equal(finished.lastBattleContext!.winner, guild);
assert.equal(finished.techTokens![ownedTech(revealed.techTokens, f.moritani)[0]].owner, guild);
const dead = player(after, guild).leaders.find(l => l.id === target)!.dead;
assert.equal(player(after, f.moritani).spice, player(pending, f.moritani).spice + (dead ? beforeTarget.strength : 0));
assert.equal(player(finished, guild).spice, player(pending, guild).spice + collectionIncome(finished, guild));
});
});
for (const skills of [false, true]) void test(`${skills ? 'all14' : 'No-Skills'} real Arrakis normal Traitor call forfeits native assassination for the rest of the game`, () => {
const b = guildLoss({ skills, advanced: true, tech: true }, true);
assert.equal(b.pending.moritaniAssassinate!.normalTraitorCall, true);
assert.equal(b.pending.lastBattleContext!.winner, b.f.moritani);
assert.ok(!b.pending.moritaniAssassinate!.opportunities.some(r => r.stage === 'choice'));
inventory(b.pending);
});
for (const skills of [false, true]) void test(`${skills ? 'all14' : 'No-Skills'} foreign Homeworld excludes normal Traitor calls but retains separate native assassination`, () => {
const b = guildLoss({ skills, advanced: true, tech: true, homeworlds: true });
reject(b.revealed, b.f.moritani, { type: 'traitorCall', call: true });
assert.equal(b.pending.lastBattleContext!.winner, b.guild);
const before = reload(b.pending), target = player(before, b.guild).leaders.find(l => l.id === b.target)!;
const action: Action = { type: 'decision', event: before.lastBattleContext!.event, card: b.target };
const killed = step(before, { actor: b.f.moritani, action });
assert.equal(player(killed, b.guild).leaders.find(l => l.id === b.target)!.dead, true);
assert.equal(player(killed, b.guild).leaders.find(l => l.id === b.target)!.deaths, target.deaths + 1);
assert.equal(player(killed, b.f.moritani).spice, player(before, b.f.moritani).spice + target.strength);
const done = settle(killed);
const home = homeworldForceGroups(homeworldContext(done), done.homeworlds!.custody!).find(h => h.id === 'homeworld:guild')!;
assert.equal(home.forces[b.f.moritani]?.normal ?? 0, 0);
assert.equal(player(done, b.f.moritani).tanks - player(b.revealed, b.f.moritani).tanks, 1);
inventory(done);
});
for (const skills of [false, true]) void test(`${skills ? 'all14' : 'No-Skills'} supply-only Moritani Cunning uses actual placement and entrant reveal after original Grumman declines`, () => {
const f = programme({ skills, advanced: true, tech: true });
let g = advance(f.game, s => !s.response && !s.phaseOpening && s.decision?.kind === 'moritaniPlacement', undefined, quiet);
const token = g.moritaniTerror!.tokens.find(t => t.kind === 'robbery' && t.status === 'available'); assert.ok(token);
const offer = viewGame(g, f.moritani).nexusMoritani; assert.ok(offer);
const action: Action = { type: 'decision', token: token.id, territory: 'sihaya_ridge', nexus: offer.event };
policies(g, f.moritani, after => {
const allowed = advance(after, s => !s.response && !s.phaseOpening, undefined, quiet);
assert.equal(player(allowed, f.moritani).spice, player(g, f.moritani).spice);
assert.equal(player(allowed, f.moritani).reserves, player(g, f.moritani).reserves);
assert.equal(allowed.moritaniTerror!.tokens.length, 6);
assert.ok(allowed.moritaniTerror!.tokens.filter(t => t.status === 'placed').every(t =>
t.location !== 'grumman' && !t.location?.startsWith('homeworld:')));
});
reject(g, f.ecaz, action); reject(g, f.moritani, { ...action, territory: 'grumman' });
reject(g, f.moritani, { ...action, territory: 'hidden_mobile_stronghold' });
reject(g, f.moritani, { ...action, territory: 'homeworld:guild' });
const before = reload(g);
g = step(g, { actor: f.moritani, action });
g = advance(g, s => !s.response && !s.phaseOpening, undefined, quiet);
assert.equal(g.moritaniTerror!.tokens.find(t => t.id === token.id)!.status, 'placed');
assert.equal(g.moritaniTerror!.tokens.find(t => t.id === token.id)!.location, 'sihaya_ridge');
assert.equal(g.nexusCards!.cards!.hands[f.moritani], null);
assert.equal(player(g, f.moritani).spice, player(before, f.moritani).spice);
assert.equal(player(g, f.moritani).reserves, player(before, f.moritani).reserves);
const guild = f.others[0];
g = advance(g, s => s.turn === 3 && s.phase === 5 && s.active === guild && clean(s), undefined, quiet);
const shipped = step(g, { actor: guild, action: { type: 'ship', territory: 'sihaya_ridge', sector: 9, amount: 1, allyPayment: 0 } });
g = advance(shipped, s => !s.response && !s.phaseOpening && s.decision?.kind === 'moritaniTerror', undefined, quiet);
const d = g.decision; assert.ok(d?.kind === 'moritaniTerror');
const moriBefore = player(g, f.moritani).spice, entrantBefore = player(g, guild).spice;
const revealAction: Action = { type: 'decision', reveal: true };
reject(g, guild, revealAction);
g = step(g, { actor: f.moritani, action: revealAction });
g = step(g, { actor: f.moritani, action: { type: 'decision', choice: 'spice' } });
g = advance(g, s => !s.response && !s.phaseOpening && !s.pendingTerrorEntry && clean(s), undefined, quiet);
assert.equal(g.moritaniTerror!.tokens.find(t => t.id === token.id)!.status, 'removed');
assert.ok(player(g, f.moritani).spice > moriBefore);
assert.equal(player(g, f.moritani).spice - moriBefore, entrantBefore - player(g, guild).spice);
assert.equal(player(g, f.moritani).spice - moriBefore, Math.ceil(entrantBefore / 2));
inventory(g);
});
void test('No-Skills original assassination pause retains the unfinished winner suffix, which settles every physical loss and payment exactly once', () => {
const b = guildLoss({ advanced: true, skills: false, tech: true, strongholds: true });
const q = quote(b.revealed), d = b.pending.decision; assert.ok(d?.kind === 'moritaniAssassinate');
const target = player(b.pending, b.guild).leaders.find(l => l.id === b.target)!;
const acted = step(b.pending, { actor: b.f.moritani, action: { type: 'decision', event: d.event, card: b.target } });
const done = settle(acted);
assert.equal(player(done, b.f.moritani).tanks - player(b.revealed, b.f.moritani).tanks, 1);
assert.equal(player(done, b.guild).tanks - player(b.revealed, b.guild).tanks, 2);
assert.equal(player(done, b.guild).forces['tueks_sietch:5'], 3);
assert.equal(player(done, b.guild).leaders.find(l => l.id === b.target)!.dead, true);
for (const payment of q.payments) {
const income = q.strongholdIncome.filter(i => i.player === payment.player).reduce((a, i) => a + i.amount, 0);
assert.equal(player(done, payment.player).spice, player(b.revealed, payment.player).spice - payment.ownPayment +
income + (payment.player === b.f.moritani ? target.strength : 0) + collectionIncome(done, payment.player));
}
assert.equal(done.techTokens![ownedTech(b.revealed.techTokens, b.f.moritani)[0]].owner, b.guild);
inventory(done);
const later = advance(done, s => s.turn === 3 && s.phase === 1 && clean(s), undefined, quiet);
assert.ok(!player(later, b.f.moritani).traitors.includes(b.target));
assert.equal(player(later, b.f.moritani).traitors.length, 1);
assert.equal(later.moritaniAssassinate!.opportunities.filter(r => r.card === b.target && r.stage === 'replaced').length, 1);
inventory(later);
});
void test('real Basic odd Ecaz FORCE remains unresolved before coalition lead, any debit, casualties or rewards', () => {
const f = programme({ advanced: false, skills: false, tech: true });
let g = advance(f.game, s => s.turn === 3 && s.phase === 1 && clean(s), undefined, quiet);
const at = g.spiceDeck.findIndex(c => 'worm' in c && !c.greatMaker && !c.suppressed); assert.ok(at >= 0);
g.spiceDeck.unshift(g.spiceDeck.splice(at, 1)[0]);
g = advance(g, s => !!s.nexus && !s.spiceWindow && !s.spiceResolution && clean(s), undefined, quiet);
const ally = f.others[1], opponent = f.others[0];
g = step(g, { actor: ally, action: { type: 'alliance', target: null } });
for (const [actor, target] of [[f.ecaz, ally], [ally, f.ecaz]]) g = step(g, { actor, action: { type: 'alliance', target } });
const amounts: Record<string, number> = { [f.ecaz]: 2, [ally]: 2, [opponent]: 2 }, shipped = new Set<string>();
for (let n = 0; !(g.phase === 6 && clean(g)) && n < 600; n++) {
if (g.phase === 5 && clean(g) && g.active && amounts[g.active] && !shipped.has(g.active)) {
const actor = g.active; g = ship(g, actor, 'sietch_tabr:14', amounts[actor]).after; shipped.add(actor);
} else {
const next = nextPairedE3NexusStep(g) ?? quiet(g); assert.ok(next); g = step(g, next);
}
}
assert.equal(player(g, f.ecaz).forces['sietch_tabr:14'], 3);
const choice = viewGame(g, g.active!).battleChoices.find(c => c.territory === 'sietch_tabr'); assert.ok(choice);
reject(g, choice.chooser, { type: 'chooseBattle', territory: 'sietch_tabr',
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker }, /Odd-force Basic Occupy/);
assert.equal(g.battle, null); inventory(g);
});
+17
View File
@@ -20,6 +20,7 @@ import {
stageAssassinationBattle,
} from './moritani-assassinate-fixture';
import { createStrongholdFactionsFixture } from './fixture-stronghold-factions';
import { createPairedE3NexusSetup } from './fixture-paired-e3-nexus-modules';
void test('Moritani Skills sample custody checks the physical skill and Traitor inventories after genuine setup', () => {
const game = completedMoritaniSkillsGame();
@@ -37,6 +38,22 @@ void test('Moritani Skills sample custody checks the physical skill and Traitor
assert.throws(() => verifySampleCustody(traitors, inventory), /traitor custody/);
});
for (const skills of [false, true]) void test(`paired native Ecaz loyalty remains one physical Traitor with ${skills ? 'all14 Skills' : 'no Skills'}`, () => {
const fixture = createPairedE3NexusSetup({ skills, homeworlds: false, discovery: false, tech: false, strongholds: false });
const inventory = sampleInventory(fixture.offered);
verifySampleCustody(fixture.afterSetup, inventory);
const loyaltyCard = fixture.afterSetup.ecazLoyalty?.card;
assert.ok(loyaltyCard);
const missing = structuredClone(fixture.afterSetup);
delete missing.ecazLoyalty;
assert.throws(() => verifySampleCustody(missing, inventory));
const duplicated = structuredClone(fixture.afterSetup);
duplicated.traitorReserve!.push(loyaltyCard);
assert.throws(() => verifySampleCustody(duplicated, inventory));
});
void test('Moritani assassination samples conserve retired Traitors after real battle and Mentat replacement', () => {
const start = assassinationGame();
const inventory = sampleInventory(start);