Compose original mixed and paired Nexus optional modules
This commit is contained in:
1 parent
0e1797d4e0
commit
05ab2c92b0
30 files changed
+2268
-203
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user