Compose native Homeworld Nexus optional modules
This commit is contained in:
1 parent
71bc325289
commit
2efa10a9d6
19 files changed
+1961
-62
No files matched your search
@@ -0,0 +1,373 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeLeaderSkillsGameForAudit, initializePairedNexusGameForAudit,
|
||||
joinGame, newPlayer, viewGame, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { territory } from '../game/board';
|
||||
import { treacheryDeck, type Card } from '../game/cards';
|
||||
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
|
||||
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
||||
import { DISCOVERY_CARD_PLACEMENTS } from '../game/discoveries';
|
||||
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
|
||||
import { greatMakerRideAction } from '../game/great-maker-options';
|
||||
import {
|
||||
nativeHomeworldArmy, nativeHomeworldClean, nativeHomeworldInventory, nativeHomeworldLeader,
|
||||
nativeHomeworldPlayer, nextNativeHomeworldModuleStep, frontNativeHomeworldSpice,
|
||||
} from './fixture-homeworld-native-e1e2-modules';
|
||||
import { nextDiscoveryPairedNexusE1SkillsStep } from './fixture-discovery-paired-nexus-e1-skills';
|
||||
import { nextDiscoveryPairedNexusE2SkillsStep } from './fixture-discovery-paired-nexus-e2-skills';
|
||||
import { nextPairedIxNexusModulesStep } from './fixture-paired-ix-nexus-modules';
|
||||
import { nextPairedChoamStep } from './fixture-paired-choam-nexus-modules';
|
||||
import { withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken } from './fixture-discovery-classic-nexus';
|
||||
|
||||
export type HomeworldPairedNexusStep = { actor: string; action: Action };
|
||||
export type HomeworldPairedNexusOptions = {
|
||||
/** Original fresh lobby or original turn-one, unassigned setup. Saved deals are authoritative. */
|
||||
initial?: Game;
|
||||
family?: 'ix' | 'choam';
|
||||
advanced?: boolean;
|
||||
skills?: boolean;
|
||||
discovery?: boolean;
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
/** Earn Arrakeen custody through a genuine first-turn three-marker reveal. */
|
||||
arrakeenClaim?: boolean;
|
||||
};
|
||||
export type HomeworldPairedNexusTransition = { before: Game; step: HomeworldPairedNexusStep; after: Game };
|
||||
export type HomeworldPairedNexusProgramme = {
|
||||
initial: Game; setup: Game; offered: Game | null; afterSetup: Game; game: Game;
|
||||
owner: string; native: string; guild: string; fremen: string; trainer: string | null;
|
||||
firstMarker: HomeworldPairedNexusTransition | null;
|
||||
entry: HomeworldPairedNexusTransition | null; vote: Game | null;
|
||||
ride: HomeworldPairedNexusTransition | null; alliance: Game; drawing: Game;
|
||||
draws: HomeworldPairedNexusTransition[]; actions: HomeworldPairedNexusStep[];
|
||||
};
|
||||
export const homeworldPairedNexusClean = nativeHomeworldClean;
|
||||
export const homeworldPairedNexusPlayer = nativeHomeworldPlayer;
|
||||
export const homeworldPairedNexusArmy = nativeHomeworldArmy;
|
||||
|
||||
/** Conservation covers the original caches as well as ordinary cards, every
|
||||
* Nexus singleton, trained discs and native/visitor physical counters. */
|
||||
export function homeworldPairedNexusInventory(game: Game): void {
|
||||
nativeHomeworldInventory(game);
|
||||
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
|
||||
...(game.ixSetupCards ?? []), ...(game.auction?.cards.slice(game.auction.index) ?? []),
|
||||
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? [])];
|
||||
assert.deepEqual(cards.map(c => c.id).sort(),
|
||||
[...treacheryDeck(game.expansions), ...(game.players.some(p => p.faction === 'richese') ? richeseCards() : [])]
|
||||
.map(c => c.id).sort());
|
||||
validateNexusCards(game.nexusCards!.cards!, game.players);
|
||||
const nx = game.nexusCards!.cards!;
|
||||
assert.deepEqual([...nx.deck, ...nx.discard, ...Object.values(nx.hands).filter(c => c !== null)].sort(), [...NEXUS_FACTIONS].sort());
|
||||
}
|
||||
function shuffleRolls(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) / (i + 1) * 0x100000000));
|
||||
[working[i], working[j]] = [working[j], working[i]];
|
||||
}
|
||||
return rolls;
|
||||
}
|
||||
/** Only word-sized original undealt lotteries are selected. UUID entropy is native. */
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
/** Original producers retain response priority. Owned Suk, substitution, cards,
|
||||
* Tech and Face Dance are explicit endpoints; no default replaces a human plan. */
|
||||
export function nextHomeworldPairedNexusStep(game: Game): HomeworldPairedNexusStep | null {
|
||||
if (homeworldPairedNexusClean(game) && game.nexusCards?.phase?.stage === 'drawing') {
|
||||
const phase = game.nexusCards.phase, actor = phase.eligible.find(id => !phase.done.includes(id)); assert.ok(actor);
|
||||
return { actor, action: { type: 'nexusCardChoice', turn: game.turn,
|
||||
card: game.nexusCards.cards!.hands[actor] ?? null, choice: 'keep', ownRedraws: 0 } };
|
||||
}
|
||||
if (!game.response && !game.phaseOpening && game.decision) {
|
||||
const d = game.decision;
|
||||
if (['sukRescue', 'ixSubstitution', 'battleCards', 'faceDance', 'strongholdCopy'].includes(d.kind) ||
|
||||
d.kind === 'techToken' && !!game.battle) return null;
|
||||
if (d.kind === 'techToken') return { actor: d.player, action: { type: 'decision', token: d.choices[0] } };
|
||||
if (d.kind === 'richeseUnbid')
|
||||
return { actor: d.player, action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
|
||||
// Both physical and ordinary Guild arrival windows must allow the declared programme.
|
||||
if (d.kind === 'guildShipment' || d.kind === 'homeworldShipmentGuild')
|
||||
return { actor: d.player, action: { type: 'decision', allow: true,
|
||||
...(d.kind === 'homeworldShipmentGuild' ? { event: d.event } : {}) } };
|
||||
if (d.kind === 'battleLosses') {
|
||||
const maximum = Math.max(...d.options.map(o => o.elite));
|
||||
return { actor: d.player, action: { type: 'decision', choice: d.options.findIndex(o => o.elite === maximum) } };
|
||||
}
|
||||
}
|
||||
if (game.discoveries) return game.expansions[0] === 'ix'
|
||||
? nextDiscoveryPairedNexusE1SkillsStep(game) : nextDiscoveryPairedNexusE2SkillsStep(game);
|
||||
if (game.leaderSkills) return nextNativeHomeworldModuleStep(game);
|
||||
return game.expansions[0] === 'ix' ? nextPairedIxNexusModulesStep(game) : nextPairedChoamStep(game);
|
||||
}
|
||||
export function stepHomeworldPairedNexus(game: Game, step: HomeworldPairedNexusStep,
|
||||
actions?: HomeworldPairedNexusStep[]): Game {
|
||||
actions?.push(structuredClone(step));
|
||||
return withClassicDiscoveryNexusLottery(() => applyAction(game, step.actor, step.action));
|
||||
}
|
||||
export function advanceHomeworldPairedNexus(state: Game, until: (game: Game) => boolean,
|
||||
actions?: HomeworldPairedNexusStep[]): Game {
|
||||
let game = structuredClone(state);
|
||||
for (let n = 0; n < 3000; n++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextHomeworldPairedNexusStep(game);
|
||||
assert.ok(step, `Original paired Homeworld programme needs its owned ${game.decision?.kind ?? 'private plan'} at ${game.turn}/${game.phase}`);
|
||||
game = stepHomeworldPairedNexus(game, step, actions);
|
||||
}
|
||||
throw Error('Original paired Homeworld/Nexus programme did not reach its selected boundary.');
|
||||
}
|
||||
export function settleHomeworldPairedNexus(game: Game, actions?: HomeworldPairedNexusStep[]): Game {
|
||||
return advanceHomeworldPairedNexus(game, g => homeworldPairedNexusClean(g) &&
|
||||
!g.pendingShipment && !g.pendingHomeworldShipment && !g.pendingTreacheryDiscard && !g.pendingChoamWorthless, actions);
|
||||
}
|
||||
function transition(game: Game, step: HomeworldPairedNexusStep, actions: HomeworldPairedNexusStep[]): HomeworldPairedNexusTransition {
|
||||
const before = structuredClone(game), after = stepHomeworldPairedNexus(game, step, actions);
|
||||
return { before, step: structuredClone(step), after };
|
||||
}
|
||||
|
||||
/** One initializer path for both human fresh lobbies and conserved original
|
||||
* unassigned setup. This is offline original play, not authenticated HTTP proof. */
|
||||
export function initializeHomeworldPairedNexus(options: HomeworldPairedNexusOptions = {}) {
|
||||
const family = options.family ?? (options.initial?.expansions[0] === 'choam' ? 'choam' : 'ix');
|
||||
const advanced = options.advanced ?? options.initial?.advanced ?? true;
|
||||
const skills = options.skills ?? (options.initial?.status === 'setup' ? !!options.initial.leaderSkills : true);
|
||||
const discovery = options.discovery ?? (options.initial ? options.initial.discoveryEnabled === true : false);
|
||||
const tech = options.tech ?? (options.initial ? !!options.initial.techTokens : true);
|
||||
const strongholds = options.strongholds ?? (options.initial ? !!options.initial.strongholdCards : advanced);
|
||||
const roster = family === 'ix' ? ['ixians', 'tleilaxu', 'guild', 'fremen'] as const
|
||||
: ['richese', 'choam', 'guild', 'fremen'] as const;
|
||||
let game = options.initial ? structuredClone(options.initial)
|
||||
: createGame('HOMEWORLDPAIREDNEXUS', newPlayer(roster[0], roster[0], roster[0]), advanced, [family]);
|
||||
if (!options.initial) for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
|
||||
assert.deepEqual(game.expansions, [family]); assert.equal(game.advanced, advanced);
|
||||
assert.deepEqual(game.players.map(p => p.faction).sort(), [...roster].sort());
|
||||
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 &&
|
||||
(!game.leaderSkills || game.leaderSkills.assignments.length === 0) && game.players.every(p =>
|
||||
!p.traitors.length && !p.faceDancers?.length && p.leaders.every(l => !l.dead && !l.usedAt)),
|
||||
'Continue only an original lobby or its original unassigned first-turn setup.');
|
||||
const initial = structuredClone(game), actions: HomeworldPairedNexusStep[] = [];
|
||||
const seat = (faction: string) => game.players.find(p => p.faction === faction)!.id;
|
||||
const owner = seat(roster[0]), native = seat(roster[1]), guild = seat('guild'), fremen = seat('fremen');
|
||||
const selected: LeaderSkillId[] = ['suk-graduate', 'prana-bindu-adept', 'swordmaster-of-ginaz', 'warmaster'];
|
||||
if (game.status === 'lobby') {
|
||||
for (const [type, enabled, present] of [
|
||||
['homeworlds', true, !!game.homeworlds], ['techTokens', tech, !!game.techTokens],
|
||||
['strongholdCards', strongholds, !!game.strongholdCards],
|
||||
] as const) if (enabled !== present)
|
||||
game = stepHomeworldPairedNexus(game, { actor: game.host, action: { type, enabled } }, actions);
|
||||
game.discoveryEnabled = discovery; game.nexusCards ??= { cards: null, phase: null };
|
||||
for (const p of game.players) if (!p.ready)
|
||||
game = stepHomeworldPairedNexus(game, { actor: p.id, action: { type: 'ready' } }, actions);
|
||||
const skillsSource = LEADER_SKILL_CARDS.map(c => c.id), rest = skillsSource.filter(c => !selected.includes(c));
|
||||
const skillsOrder = selected.flatMap(c => [c, rest.shift()!]).concat(rest);
|
||||
const deck = treacheryDeck(game.expansions), remaining = [...deck];
|
||||
const kinds: Card['kind'][] = family === 'ix' ? ['worthless', 'worthless', 'worthless', 'worthless']
|
||||
: ['worthless', 'special', 'worthless', 'worthless'];
|
||||
const prefix = kinds.map(kind => {
|
||||
const i = remaining.findIndex(c => c.kind === kind && (kind !== 'special' || c.effect !== 'karama')); assert.ok(i >= 0);
|
||||
return remaining.splice(i, 1)[0];
|
||||
});
|
||||
const rolls = [...(skills ? shuffleRolls(skillsSource, skillsOrder) : []),
|
||||
...(discovery ? Array.from({ length: 7 }, () => 0xffffffff) : []),
|
||||
...Array.from({ length: NEXUS_FACTIONS.length - 1 }, () => 0xffffffff),
|
||||
...shuffleRolls(deck.map(c => c.id), [...prefix, ...remaining].map(c => c.id))];
|
||||
game = lottery(rolls, () => skills ? initializeLeaderSkillsGameForAudit(game) : initializePairedNexusGameForAudit(game));
|
||||
}
|
||||
assert.equal(!!game.leaderSkills, skills); assert.equal(!!game.discoveries, discovery);
|
||||
assert.ok(game.homeworlds && game.nexusCards?.cards);
|
||||
assert.equal(!!game.techTokens, tech); assert.equal(!!game.strongholdCards, strongholds);
|
||||
const setup = structuredClone(game); let offered: Game | null = null, trainer: string | null = null;
|
||||
for (let n = 0; game.status === 'setup' && n < 250; n++) {
|
||||
let step: HomeworldPairedNexusStep;
|
||||
if (game.setupStage === 'leaderSkills') {
|
||||
offered ??= structuredClone(game);
|
||||
const actor = Object.keys(game.leaderSkills!.offers)[0], view = viewGame(game, actor).leaderSkills!;
|
||||
const wanted = selected[game.players.findIndex(p => p.id === actor)];
|
||||
const skill = options.initial?.status === 'setup' ? view.offer!.cards.find(c => !view.unavailableSkills?.[c])
|
||||
: view.offer!.cards.find(c => c === wanted && !view.unavailableSkills?.[c]);
|
||||
const eligible = new Set(view.eligibleLeaders.map(l => l.id));
|
||||
const leader = nativeHomeworldPlayer(game, actor).leaders.filter(l => eligible.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
|
||||
assert.ok(skill && leader); if (actor === owner) trainer = leader.id;
|
||||
step = { actor, action: { type: 'leaderSkill', event: view.offer!.event, skill, leader: leader.id } };
|
||||
} else if (game.decision?.kind === 'ixSetup') {
|
||||
const actor = game.decision.player, card = viewGame(game, actor).ixTechnology!.setup!.find(c => c.kind === 'worthless'); assert.ok(card);
|
||||
step = { actor, action: { type: 'decision', card: card.id } };
|
||||
} else {
|
||||
const next = nextHomeworldPairedNexusStep(game); assert.ok(next); step = next;
|
||||
if (step.action.type === 'traitor' && trainer) {
|
||||
const choices = nativeHomeworldPlayer(game, step.actor).traitorChoices;
|
||||
step.action.leader = choices.find(id => id !== trainer) ?? choices[0];
|
||||
}
|
||||
}
|
||||
// A legal original Fremen choice avoids an incidental third allied
|
||||
// stronghold at END Mentat; it never relocates already placed counters.
|
||||
if (step.action.type === 'fremenSetup') step.action.placements = {
|
||||
sietch_tabr: 0, false_wall_south: 10, false_wall_west: 0,
|
||||
};
|
||||
if (family === 'ix' && game.setupStage === 'forces' && trainer) {
|
||||
const held = new Set(game.players.flatMap(p => p.traitors));
|
||||
const source = traitorDeck(game.players, true).filter(id => !held.has(id)); assert.ok(source.includes(trainer));
|
||||
actions.push(structuredClone(step));
|
||||
game = lottery(shuffleRolls(source, [trainer, ...source.filter(id => id !== trainer)]), () => applyAction(game, step.actor, step.action));
|
||||
} else game = stepHomeworldPairedNexus(game, step, actions);
|
||||
}
|
||||
assert.equal(game.status, 'playing'); homeworldPairedNexusInventory(game);
|
||||
return { initial, setup, offered, afterSetup: structuredClone(game), game, owner, native, guild, fremen, trainer, actions };
|
||||
}
|
||||
|
||||
/** Turn one is played, not repositioned. Turn two's actual worm or Maker opens
|
||||
* an alliance, then actual unallied closing draws deal the matching singletons. */
|
||||
export function createHomeworldPairedNexusProgramme(options: HomeworldPairedNexusOptions = {}): HomeworldPairedNexusProgramme {
|
||||
const s = initializeHomeworldPairedNexus(options), actions = s.actions;
|
||||
let game = s.game;
|
||||
const discovery = !!game.discoveries, family = game.expansions[0];
|
||||
frontNativeHomeworldSpice(game, c => 'territory' in c && (discovery ? c.discovery === 'discovery-hagga-basin' : !c.discovery && c.territory === 'hagga_basin'), 0);
|
||||
if (game.advanced) frontNativeHomeworldSpice(game, c => 'territory' in c && !c.discovery && c.territory === 'broken_land', 1);
|
||||
const second = game.advanced ? 2 : 1;
|
||||
frontNativeHomeworldSpice(game, c => 'worm' in c && (discovery ? !!c.greatMaker : !c.greatMaker && !c.suppressed), second);
|
||||
frontNativeHomeworldSpice(game, c => 'territory' in c && !c.discovery && c.territory === 'rock_outcroppings', second + 1);
|
||||
if (game.advanced) frontNativeHomeworldSpice(game, c => 'territory' in c && !c.discovery && c.territory === 'oh_gap', second + 2);
|
||||
game = advanceHomeworldPairedNexus(game, g => g.phase === 1 && homeworldPairedNexusClean(g), actions);
|
||||
if (discovery) {
|
||||
for (let n = 0; !game.discoveries!.tokens.some(t => t.face === 'shrine' && t.status === 'placed') && n < 100; n++) {
|
||||
const step = nextHomeworldPairedNexusStep(game); assert.ok(step); actions.push(structuredClone(step));
|
||||
game = withClassicDiscoveryNexusToken(game, 'shrine', () => applyAction(game, step.actor, step.action));
|
||||
}
|
||||
assert.ok(game.discoveries!.tokens.some(t => t.face === 'shrine' && t.status === 'placed'));
|
||||
}
|
||||
let firstMarker: HomeworldPairedNexusTransition | null = null;
|
||||
const collector = family === 'ix' ? s.owner : s.guild;
|
||||
const needed = new Set(discovery ? [collector, ...(family === 'choam' ? [s.owner] : [])] : family === 'choam' ? [s.owner] : []);
|
||||
while (needed.size) {
|
||||
game = advanceHomeworldPairedNexus(game, g => g.turn === 1 && g.phase === 5 && homeworldPairedNexusClean(g) && needed.has(g.active!), actions);
|
||||
const actor = game.active!;
|
||||
if (family === 'choam' && actor === s.owner) {
|
||||
const site = options.arrakeenClaim ? 'arrakeen' : 'habbanya_ridge_sietch';
|
||||
const p = nativeHomeworldPlayer(game, actor), token = p.noField!.tokens.find(t => t.value === (options.arrakeenClaim ? 3 : 0))!;
|
||||
firstMarker = transition(game, { actor, action: { type: 'ship', territory: site,
|
||||
sector: territory(site).sectors[0], noField: token.id, event: p.noFieldEvent, allyPayment: 0 } }, actions);
|
||||
game = settleHomeworldPairedNexus(firstMarker.after, actions); firstMarker.after = structuredClone(game);
|
||||
game = stepHomeworldPairedNexus(game, { actor, action: { type: 'revealNoField', token: token.id,
|
||||
event: nativeHomeworldPlayer(game, actor).noFieldEvent } }, actions);
|
||||
} else {
|
||||
const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'], elite = family === 'ix' ? 1 : 0;
|
||||
const sources = nativeShipmentSources(viewGame(game, actor), 3, elite); assert.ok(sources);
|
||||
game = settleHomeworldPairedNexus(stepHomeworldPairedNexus(game, { actor, action: { type: 'ship', territory: placement.territory,
|
||||
sector: placement.sector, amount: 3, elite, allyPayment: 0, homeworldSources: sources } }, actions), actions);
|
||||
}
|
||||
needed.delete(actor);
|
||||
if (needed.size) game = stepHomeworldPairedNexus(game, { actor, action: { type: 'endMovement' } }, actions);
|
||||
}
|
||||
if (discovery) {
|
||||
game = advanceHomeworldPairedNexus(game, g => g.phase === 7 && homeworldPairedNexusClean(g), actions);
|
||||
const token = game.discoveries!.tokens.find(t => t.face === 'shrine' && t.status === 'placed')!;
|
||||
for (const reveal of [false, true]) game = stepHomeworldPairedNexus(game,
|
||||
{ actor: collector, action: { type: 'discovery', token: token.id, reveal } }, actions);
|
||||
}
|
||||
game = advanceHomeworldPairedNexus(game, g => g.turn === 2, actions);
|
||||
let entry: HomeworldPairedNexusTransition | null = null, vote: Game | null = null, ride: HomeworldPairedNexusTransition | null = null;
|
||||
if (discovery) {
|
||||
game = advanceHomeworldPairedNexus(game, g => g.decision?.kind === 'discoveryEntry' && g.decision.player === collector, actions);
|
||||
const view = viewGame(game, collector), offered = view.discoveryEntry!.sources;
|
||||
const sources = family === 'ix' ? offered.map(source => ({ ...source, normal: Math.min(source.normal, 1), elite: Math.min(source.elite, 1) })) : offered;
|
||||
const action = discoveryEntryMoveAction(view, sources); assert.ok(action);
|
||||
entry = transition(game, { actor: collector, action }, actions); game = entry.after;
|
||||
game = advanceHomeworldPairedNexus(game, g => g.decision?.kind === 'greatMakerVote', actions); vote = structuredClone(game);
|
||||
while (game.decision?.kind === 'greatMakerVote') game = stepHomeworldPairedNexus(game,
|
||||
{ actor: game.decision.player, action: { type: 'decision', event: game.decision.event, yes: true } }, actions);
|
||||
assert.ok(game.decision?.kind === 'greatMakerRide' && game.decision.player === s.fremen);
|
||||
const actionRide = greatMakerRideAction(viewGame(game, s.fremen), 'polar_sink', 0, 2, game.advanced ? 1 : 0); assert.ok(actionRide);
|
||||
ride = transition(game, { actor: s.fremen, action: actionRide }, actions); game = ride.after;
|
||||
}
|
||||
game = advanceHomeworldPairedNexus(game, g => !!g.nexus && !g.spiceWindow && !g.spiceResolution && homeworldPairedNexusClean(g), actions);
|
||||
for (const [actor, target] of [[s.guild, s.fremen], [s.fremen, s.guild]])
|
||||
game = stepHomeworldPairedNexus(game, { actor, action: { type: 'alliance', target } }, actions);
|
||||
const alliance = structuredClone(game);
|
||||
game = advanceHomeworldPairedNexus(game, g => g.nexusCards?.phase?.stage === 'drawing' && homeworldPairedNexusClean(g), actions);
|
||||
const drawing = structuredClone(game), draws: HomeworldPairedNexusTransition[] = [];
|
||||
for (const actor of [s.owner, s.native]) {
|
||||
const cards = game.nexusCards!.cards!, face = nativeHomeworldPlayer(game, actor).faction;
|
||||
const at = cards.deck.indexOf(face); assert.ok(at >= 0); cards.deck.unshift(cards.deck.splice(at, 1)[0]);
|
||||
const draw = transition(game, { actor, action: { type: 'nexusCardChoice', turn: game.turn,
|
||||
card: cards.hands[actor] ?? null, choice: 'draw', ownRedraws: 0 } }, actions);
|
||||
draws.push(draw); game = draw.after;
|
||||
}
|
||||
game = advanceHomeworldPairedNexus(game, g => g.phase === 5 && homeworldPairedNexusClean(g), actions);
|
||||
homeworldPairedNexusInventory(game);
|
||||
return { ...s, game, firstMarker, entry, vote, ride, alliance, drawing, draws, actions };
|
||||
}
|
||||
|
||||
/** Legal native sources, actual Guild allowances and custody settlement. */
|
||||
export function shipHomeworldPairedNexus(game: Game, actor: string, destination: string, amount: number, elite = 0,
|
||||
actions?: HomeworldPairedNexusStep[]): Game {
|
||||
const sources = nativeShipmentSources(viewGame(game, actor), amount, elite); assert.ok(sources);
|
||||
let action: Action;
|
||||
if (destination.startsWith('homeworld:')) {
|
||||
const choice = homeworldShipmentChoice(viewGame(game, actor), destination,
|
||||
Object.fromEntries(Object.entries(sources).filter(([, g]) => g.normal + g.elite > 0)));
|
||||
assert.ok(choice.action, choice.blocked ?? undefined); action = choice.action;
|
||||
} else action = { type: 'ship', territory: destination, sector: destination === 'shrine' ? 0 : territory(destination).sectors[0],
|
||||
amount, elite, allyPayment: 0, homeworldSources: sources };
|
||||
return settleHomeworldPairedNexus(stepHomeworldPairedNexus(game, { actor, action }, actions), actions);
|
||||
}
|
||||
export function openHomeworldPairedNexusBattle(game: Game, owner: string, opponent: string, location: string,
|
||||
hidden = true, actions?: HomeworldPairedNexusStep[]): Game {
|
||||
game = advanceHomeworldPairedNexus(game, g => g.phase === 6 && homeworldPairedNexusClean(g) && !!g.active, actions);
|
||||
assert.ok(game.active === owner || game.active === opponent);
|
||||
game = stepHomeworldPairedNexus(game, { actor: game.active!, action: { type: 'chooseBattle', territory: location,
|
||||
target: game.active === owner ? opponent : owner } }, actions);
|
||||
for (let n = 0; n < 200; n++) {
|
||||
if (homeworldPairedNexusClean(game) && game.battle && !game.battle.preparation && game.battle.preLeader?.closed !== false) return game;
|
||||
const next = nextHomeworldPairedNexusStep(game); assert.ok(next);
|
||||
if (next.action.type === 'leaderSkillVisibility') next.action.hide = next.actor === owner && hidden;
|
||||
game = stepHomeworldPairedNexus(game, next, actions);
|
||||
}
|
||||
throw Error('Original Homeworld battle did not reach its sealed-plan boundary.');
|
||||
}
|
||||
export function revealHomeworldPairedNexusPlans(game: Game, plans: readonly HomeworldPairedNexusStep[],
|
||||
actions?: HomeworldPairedNexusStep[]): Game {
|
||||
for (const plan of plans) {
|
||||
game = stepHomeworldPairedNexus(game, plan, actions);
|
||||
while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') {
|
||||
const next = nextHomeworldPairedNexusStep(game); assert.ok(next); game = stepHomeworldPairedNexus(game, next, actions);
|
||||
}
|
||||
}
|
||||
assert.ok(game.battle?.revealed); return game;
|
||||
}
|
||||
/** Owned cleanup is explicit, and reward/Face Dance order remains engine-owned. */
|
||||
export function finishHomeworldPairedNexusBattle(game: Game, actions?: HomeworldPairedNexusStep[]): Game {
|
||||
for (let n = 0; n < 600; n++) {
|
||||
if (!game.battle && homeworldPairedNexusClean(game) && !game.pendingTreacheryDiscard) return game;
|
||||
let next = nextHomeworldPairedNexusStep(game);
|
||||
if (!next && !game.response && !game.phaseOpening) {
|
||||
const d = game.decision; assert.ok(d);
|
||||
if (d.kind === 'battleCards') next = { actor: d.player, action: { type: 'decision', discard: [] } };
|
||||
else if (d.kind === 'techToken') next = { actor: d.player, action: { type: 'decision', token: d.choices[0] } };
|
||||
else if (d.kind === 'faceDance') next = { actor: d.player, action: { type: 'decision', reveal: false } };
|
||||
else throw Error(`Choose the physical ${d.kind} before continuing cleanup.`);
|
||||
}
|
||||
assert.ok(next); game = stepHomeworldPairedNexus(game, next, actions);
|
||||
}
|
||||
throw Error('Original paired Homeworld aftermath did not finish.');
|
||||
}
|
||||
export const homeworldPairedNexusLeader = nativeHomeworldLeader;
|
||||
@@ -0,0 +1,414 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame,
|
||||
type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { leaders, treacheryDeck } from '../game/cards';
|
||||
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
|
||||
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
|
||||
import { validateLeaderSkills } from '../game/leader-skills';
|
||||
import { validateDiscoveryState, DISCOVERY_CARD_PLACEMENTS } from '../game/discoveries';
|
||||
import { discoveryEntryMoveAction } from '../game/discovery-entry-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 { DUKE_VIDAL_ID } from '../game/duke-vidal';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { validateAmbassadors } from '../game/ecaz-ambassadors';
|
||||
import {
|
||||
nativeE3HomeworldPlayer as standaloneHomeworldNexusPlayer,
|
||||
nativeE3HomeworldPool as standaloneHomeworldNexusPool,
|
||||
nativeE3HomeworldPolicy as standaloneHomeworldNexusPolicy,
|
||||
type NativeE3HomeworldOptions,
|
||||
} from './fixture-homeworld-native-e3-modules';
|
||||
import {
|
||||
completeDiscoveryStandaloneNexusE3SkillsSetup, nextDiscoveryStandaloneNexusE3SkillsStep,
|
||||
type DiscoveryStandaloneNexusE3SkillsBoundary,
|
||||
} from './fixture-discovery-standalone-nexus-e3-skills';
|
||||
import { ecazNexusSkillsModulesCunningRequest } from './fixture-ecaz-nexus-skills-modules';
|
||||
import { withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken } from './fixture-discovery-classic-nexus';
|
||||
|
||||
export { standaloneHomeworldNexusPlayer, standaloneHomeworldNexusPool, standaloneHomeworldNexusPolicy };
|
||||
export type StandaloneHomeworldNexusOptions = Omit<NativeE3HomeworldOptions, 'skills' | 'assassination' | 'lethal'>;
|
||||
export type StandaloneHomeworldNexusStep = { actor: string; action: Action };
|
||||
export type StandaloneHomeworldNexusTransition = { before: Game; step: StandaloneHomeworldNexusStep; after: Game };
|
||||
export type StandaloneHomeworldNexusSetup = {
|
||||
initial: Game; offered: Game; afterSetup: Game; owner: string; opponent: string; trainer: string;
|
||||
actions: StandaloneHomeworldNexusStep[];
|
||||
};
|
||||
export type StandaloneHomeworldNexusClosing = StandaloneHomeworldNexusSetup & {
|
||||
firstStorm: Game; firstMentat: StandaloneHomeworldNexusTransition; alliance: Game;
|
||||
draw: StandaloneHomeworldNexusTransition; game: Game;
|
||||
};
|
||||
export type StandaloneHomeworldNexusBattle = {
|
||||
arrival: StandaloneHomeworldNexusTransition; settledArrival: Game; beforeBattle: Game;
|
||||
game: Game; plans: StandaloneHomeworldNexusStep[]; location: string;
|
||||
};
|
||||
export const standaloneHomeworldNexusClean = (g: Game) =>
|
||||
!g.response && !g.phaseOpening && !g.decision && !g.pendingTreacheryDiscard && !g.truthtrance;
|
||||
export const reloadStandaloneHomeworldNexus = (g: Game): Game => JSON.parse(JSON.stringify(g)) as Game;
|
||||
|
||||
/** The programme owns no alternate profile or producer. This is the same private
|
||||
* undealt module declaration and host actions used by the original HW fixtures.
|
||||
* Supplied seat IDs, already-ready seats and genuinely dealt setup offers survive. */
|
||||
export function createStandaloneHomeworldNexusLobby(options: StandaloneHomeworldNexusOptions = {}): Game {
|
||||
const faction = options.faction ?? options.initial?.players.find(p => ['ecaz', 'moritani'].includes(p.faction))?.faction ?? 'ecaz';
|
||||
const advanced = options.advanced ?? options.initial?.advanced ?? true;
|
||||
const roster = options.roster ?? [faction, 'guild', 'emperor'] as FactionId[];
|
||||
let g = options.initial ? reloadStandaloneHomeworldNexus(options.initial)
|
||||
: createGame('STANDALONEHOMENEXUS', newPlayer(roster[0], roster[0], roster[0]), advanced, ['ecaz']);
|
||||
if (!options.initial) for (const f of roster.slice(1)) joinGame(g, newPlayer(f, f, f));
|
||||
assert.equal(g.status, 'lobby'); assert.equal(g.advanced, advanced);
|
||||
assert.deepEqual(g.expansions, ['ecaz']);
|
||||
assert.equal(g.players.filter(p => p.faction === 'ecaz' || p.faction === 'moritani').length, 1);
|
||||
assert.equal(new Set(g.players.map(p => p.faction)).size, g.players.length);
|
||||
assert.ok(g.players.every(p => p.faction === faction || ['guild', 'emperor', 'atreides', 'fremen', 'beneGesserit',
|
||||
...(!advanced ? ['harkonnen'] : [])].includes(p.faction)));
|
||||
const tech = options.tech ?? !!g.techTokens, strongholds = options.strongholds ?? !!g.strongholdCards;
|
||||
assert.ok(g.players.length >= (tech ? 3 : 2) && g.players.length <= 6);
|
||||
assert.ok(!strongholds || advanced); assert.ok(!g.leaderSkills && !g.ecazTreachery);
|
||||
if (!g.homeworlds) g = applyAction(g, g.host, { type: 'homeworlds', enabled: true });
|
||||
if (!!g.techTokens !== tech) g = applyAction(g, g.host, { type: 'techTokens', enabled: tech });
|
||||
if (!!g.strongholdCards !== strongholds) g = applyAction(g, g.host, { type: 'strongholdCards', enabled: strongholds });
|
||||
g.discoveryEnabled = options.discovery ?? !!g.discoveryEnabled;
|
||||
g.nexusCards ??= { cards: null, phase: null };
|
||||
assert.ok(g.homeworlds?.custody === null && g.nexusCards.cards === null && g.nexusCards.phase === null);
|
||||
return g;
|
||||
}
|
||||
|
||||
/** Select only an original, not-yet-dealt shuffle. UUID entropy is untouched. */
|
||||
function permutation(source: readonly string[], desired: readonly string[]): number[] {
|
||||
const working = [...source], rolls: number[] = [];
|
||||
assert.deepEqual([...source].sort(), [...desired].sort());
|
||||
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 && cursor < rolls.length) array[0] = rolls[cursor++];
|
||||
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 stepStandaloneHomeworldNexus(g: Game, step: StandaloneHomeworldNexusStep,
|
||||
actions?: StandaloneHomeworldNexusStep[]): Game {
|
||||
actions?.push(structuredClone(step));
|
||||
// Actual JSON save continuation is the changed-path programme, not an input-copy assertion.
|
||||
return withClassicDiscoveryNexusLottery(() => applyAction(reloadStandaloneHomeworldNexus(g), step.actor, step.action));
|
||||
}
|
||||
export function nextStandaloneHomeworldNexusStep(g: Game): StandaloneHomeworldNexusStep | null {
|
||||
if (!g.response && !g.phaseOpening && !g.pendingTreacheryDiscard) {
|
||||
const d = g.decision;
|
||||
if (d?.kind === 'guildShipment') return { actor: d.player, action: { type: 'decision', allow: true } };
|
||||
if (d?.kind === 'homeworldShipmentGuild') return { actor: d.player, action: { type: 'decision', event: d.event, allow: true } };
|
||||
if (d?.kind === 'homeworldDefense') return { actor: d.player, action: { type: 'decision', event: d.event, use: false } };
|
||||
if (d?.kind === 'homeworldRevivalDeployment') return { actor: d.player, action: { type: 'decision', event: d.event, decline: true } };
|
||||
if (d && ['sukRescue', 'moritaniAssassinate', 'techToken', 'ecazBattleLead'].includes(d.kind)) return null;
|
||||
}
|
||||
const next = nextDiscoveryStandaloneNexusE3SkillsStep(g);
|
||||
if (next?.action.type === 'stormDial') next.action.amount = g.turn === 1 ? 0 : 1;
|
||||
return next;
|
||||
}
|
||||
export function advanceStandaloneHomeworldNexus(g: Game, until: (g: Game) => boolean,
|
||||
actions?: StandaloneHomeworldNexusStep[]): Game {
|
||||
for (let n = 0; n < 3000; n++) {
|
||||
if (until(g)) return g;
|
||||
assert.notEqual(g.status, 'finished');
|
||||
const next = nextStandaloneHomeworldNexusStep(g);
|
||||
assert.ok(next, 'Original owner must supply the sealed plan, physical rescue, assassination, coalition lead or winner Tech choice.');
|
||||
g = stepStandaloneHomeworldNexus(g, next, actions);
|
||||
}
|
||||
throw Error('Original standalone HW/Nexus chronology did not reach its owned boundary.');
|
||||
}
|
||||
export function assertStandaloneHomeworldNexusCustody(g: Game): void {
|
||||
homeworldGameIntegrity(g); validateLeaderSkills(g.leaderSkills!, g.players);
|
||||
validateNexusCards(g.nexusCards!.cards!, g.players);
|
||||
if (g.discoveries) validateDiscoveryState(g.discoveries);
|
||||
const skills = g.leaderSkills!, nx = g.nexusCards!.cards!;
|
||||
assert.deepEqual([...skills.deck, ...Object.values(skills.offers).flatMap(o => o.cards), ...skills.assignments.map(a => a.skill)].sort(),
|
||||
LEADER_SKILL_CARDS.map(c => c.id).sort());
|
||||
assert.deepEqual([...nx.deck, ...nx.discard, ...Object.values(nx.hands).filter((c): c is FactionId => 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());
|
||||
if (g.traitorReserve) {
|
||||
const retired = g.moritaniAssassinate?.opportunities.filter(o => o.stage === 'replaced').map(o => o.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());
|
||||
}
|
||||
// Physical native reserves and the separate Duke are produced only AFTER
|
||||
// original Traitor selection; unassigned setup has no home custody to count.
|
||||
if (!g.homeworlds?.custody) return;
|
||||
if (g.ecazAmbassadors) validateAmbassadors(g.ecazAmbassadors);
|
||||
const homes = 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, b) => a + b, 0) +
|
||||
visitors.reduce((n, f) => n + f.normal + f.elite, 0), 20, p.faction);
|
||||
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((a, b) => a + b, 0) +
|
||||
visitors.reduce((n, f) => n + f.elite, 0), p.faction === 'fremen' ? 3 : 5);
|
||||
assert.deepEqual(p.leaders.map(l => l.id).sort(), leaders(p.faction).map(l => l.id).sort());
|
||||
}
|
||||
assert.equal(g.dukeVidal!.leader.id, DUKE_VIDAL_ID);
|
||||
assert.ok(!skills.assignments.some(a => a.leader === DUKE_VIDAL_ID));
|
||||
}
|
||||
export function createStandaloneHomeworldNexusSetup(options: StandaloneHomeworldNexusOptions = {}): StandaloneHomeworldNexusSetup {
|
||||
const actions: StandaloneHomeworldNexusStep[] = [];
|
||||
const suppliedSetup = options.initial?.status === 'setup';
|
||||
let g = suppliedSetup ? reloadStandaloneHomeworldNexus(options.initial!) : createStandaloneHomeworldNexusLobby(options);
|
||||
const initial = reloadStandaloneHomeworldNexus(g);
|
||||
const owner = g.players.find(p => p.faction === 'ecaz' || p.faction === 'moritani')!.id;
|
||||
const opponent = g.players.find(p => p.faction === 'guild')?.id ?? g.players.find(p => p.id !== owner)!.id;
|
||||
if (!suppliedSetup) {
|
||||
for (const p of g.players) if (!p.ready) g = stepStandaloneHomeworldNexus(g, { actor: p.id, action: { type: 'ready' } }, actions);
|
||||
if (options.initial) g = initializeLeaderSkillsGameForAudit(g);
|
||||
else {
|
||||
const requested = options.skill ?? 'warmaster';
|
||||
const opposing: LeaderSkillId = requested === 'suk-graduate' ? 'swordmaster-of-ginaz' : 'suk-graduate';
|
||||
const passive: LeaderSkillId[] = ['master-of-assassins', 'killer-medic', 'prana-bindu-adept', 'swordmaster-of-ginaz', 'planetologist']
|
||||
.filter(s => s !== requested && s !== opposing) as LeaderSkillId[];
|
||||
const selected: LeaderSkillId[] = g.players.map(p => p.id === owner ? requested
|
||||
: p.id === opponent ? opposing : passive.shift()!);
|
||||
const source = LEADER_SKILL_CARDS.map(c => c.id), rest = source.filter(c => !selected.includes(c));
|
||||
assert.equal(new Set(selected).size, selected.length, 'A generated programme needs distinct requested physical skills.');
|
||||
const desired = selected.flatMap(c => [c, rest.shift()!]).concat(rest);
|
||||
const cards = treacheryDeck(['ecaz']), remaining = [...cards];
|
||||
const front = g.players.flatMap(p => {
|
||||
const kinds = [p.id === opponent && g.players.some(s => s.faction === 'moritani') ? 'projectile' : 'worthless',
|
||||
...(p.faction === 'harkonnen' ? ['shield'] : [])];
|
||||
return kinds.map(kind => {
|
||||
const selectedKind = kind === 'worthless' && !remaining.some(c => c.kind === 'worthless') ? 'shield' : kind;
|
||||
const index = remaining.findIndex(c => c.kind === selectedKind); assert.ok(index >= 0);
|
||||
return remaining.splice(index, 1)[0].id;
|
||||
});
|
||||
});
|
||||
const native = standaloneHomeworldNexusPlayer(g, owner).faction;
|
||||
const rolls = [...permutation(source, desired), ...(g.discoveryEnabled ? Array<number>(7).fill(0xffffffff) : []),
|
||||
...permutation(NEXUS_FACTIONS, [native, ...NEXUS_FACTIONS.filter(c => c !== native)]),
|
||||
...permutation(cards.map(c => c.id), [...front, ...remaining.map(c => c.id)])];
|
||||
g = lottery(rolls, () => initializeLeaderSkillsGameForAudit(g));
|
||||
}
|
||||
}
|
||||
assert.ok(g.status === 'setup' && g.turn === 1 && g.phase === 0 && ['prediction', 'leaderSkills'].includes(g.setupStage!) &&
|
||||
g.leaderSkills?.assignments.length === 0 && g.homeworlds && g.nexusCards?.cards,
|
||||
'Continue only ORIGINAL unassigned skill-first setup; never convert a played save.');
|
||||
const offered = reloadStandaloneHomeworldNexus(g);
|
||||
g = completeDiscoveryStandaloneNexusE3SkillsSetup(g, { initial: options.initial,
|
||||
skill: options.skill ?? (!options.initial ? 'warmaster' : undefined) }, actions);
|
||||
assertStandaloneHomeworldNexusCustody(g);
|
||||
const trainer = g.leaderSkills!.assignments.find(a => a.owner === owner)!.leader;
|
||||
return { initial, offered, afterSetup: g, owner, opponent, trainer, actions };
|
||||
}
|
||||
|
||||
/** Only ORIGINAL unplayed Spice Cards are reordered, never cards in either played pile. */
|
||||
export function orderStandaloneHomeworldNexusSpice(g: Game, worm = false): void {
|
||||
const kinds = [...(worm ? ['worm'] : []), ...Array<string>(g.advanced ? 2 : 1).fill('land')];
|
||||
for (const [position, kind] of kinds.entries()) {
|
||||
const index = g.spiceDeck.findIndex((c, i) => i >= position && (kind === 'worm'
|
||||
? 'worm' in c && !c.greatMaker : 'territory' in c && !c.discovery));
|
||||
assert.ok(index >= position); g.spiceDeck.splice(position, 0, g.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
}
|
||||
export function createStandaloneHomeworldNexusClosing(options: StandaloneHomeworldNexusOptions = {}): StandaloneHomeworldNexusClosing {
|
||||
const f = createStandaloneHomeworldNexusSetup(options);
|
||||
assert.ok(f.afterSetup.players.length >= 3, 'A qualifying native closing draw needs two other real alliance seats.');
|
||||
let g = reloadStandaloneHomeworldNexus(f.afterSetup);
|
||||
orderStandaloneHomeworldNexusSpice(g);
|
||||
g = advanceStandaloneHomeworldNexus(g, s => s.phase === 1 && standaloneHomeworldNexusClean(s), f.actions);
|
||||
const firstStorm = reloadStandaloneHomeworldNexus(g);
|
||||
g = advanceStandaloneHomeworldNexus(g, s => s.phase === 8 && standaloneHomeworldNexusClean(s), f.actions);
|
||||
let firstMentat: StandaloneHomeworldNexusTransition | undefined;
|
||||
while (g.turn === 1) {
|
||||
const before = reloadStandaloneHomeworldNexus(g), step = nextStandaloneHomeworldNexusStep(g); assert.ok(step);
|
||||
g = stepStandaloneHomeworldNexus(g, step, f.actions);
|
||||
if (g.turn === 2) firstMentat = { before, step, after: reloadStandaloneHomeworldNexus(g) };
|
||||
}
|
||||
assert.ok(firstMentat);
|
||||
g = advanceStandaloneHomeworldNexus(g, s => s.phase === 1 && standaloneHomeworldNexusClean(s), f.actions);
|
||||
orderStandaloneHomeworldNexusSpice(g, true);
|
||||
g = advanceStandaloneHomeworldNexus(g, s => s.nexus && !s.spiceWindow && !s.spiceResolution && standaloneHomeworldNexusClean(s), f.actions);
|
||||
const others = g.players.filter(p => p.id !== f.owner);
|
||||
for (const [actor, target] of [[others[0].id, others[1].id], [others[1].id, others[0].id]])
|
||||
g = stepStandaloneHomeworldNexus(g, { actor, action: { type: 'alliance', target } }, f.actions);
|
||||
const alliance = reloadStandaloneHomeworldNexus(g);
|
||||
g = advanceStandaloneHomeworldNexus(g, s => s.nexusCards?.phase?.stage === 'drawing' && standaloneHomeworldNexusClean(s), f.actions);
|
||||
assert.ok(g.nexusCards!.phase!.eligible.includes(f.owner));
|
||||
const before = reloadStandaloneHomeworldNexus(g), step: StandaloneHomeworldNexusStep = { actor: f.owner,
|
||||
action: { type: 'nexusCardChoice', turn: g.turn, card: g.nexusCards!.cards!.hands[f.owner], choice: 'draw', ownRedraws: 0 } };
|
||||
g = stepStandaloneHomeworldNexus(g, step, f.actions);
|
||||
return { ...f, firstStorm, firstMentat, alliance, draw: { before, step, after: reloadStandaloneHomeworldNexus(g) }, game: g };
|
||||
}
|
||||
/** The ORIGINAL undealt Treachery supply selects these two auction faces; only
|
||||
* real one-spice winning bids and native payment put them into Moritani's hand. */
|
||||
export function buyStandaloneHomeworldMoritaniArmament(f: StandaloneHomeworldNexusClosing, state: Game) {
|
||||
let g = advanceStandaloneHomeworldNexus(state, s => s.phase === 2 && standaloneHomeworldNexusClean(s), f.actions);
|
||||
const before = reloadStandaloneHomeworldNexus(g), cards: string[] = [], front: Game['deck'] = [];
|
||||
for (const kind of ['projectile', 'shield']) {
|
||||
const held = standaloneHomeworldNexusPlayer(g, f.owner).hand.find(c => c.kind === kind);
|
||||
if (held) { cards.push(held.id); continue; }
|
||||
const index = g.deck.findIndex(c => c.kind === kind); assert.ok(index >= 0);
|
||||
const card = g.deck.splice(index, 1)[0]; cards.push(card.id); front.push(card);
|
||||
}
|
||||
g.deck.unshift(...front);
|
||||
for (let n = 0; n < 320; n++) {
|
||||
if (cards.every(id => standaloneHomeworldNexusPlayer(g, f.owner).hand.some(c => c.id === id)))
|
||||
return { before, game: g, weapon: cards[0], defense: cards[1], bought: front.map(c => c.id) };
|
||||
const owned = nextStandaloneHomeworldNexusStep(g); assert.ok(owned);
|
||||
if (g.phase === 3 && standaloneHomeworldNexusClean(g) && g.auction && !g.auction.bid &&
|
||||
g.auction.active === f.owner && cards.includes(g.auction.cards[g.auction.index]?.id))
|
||||
owned.action = { type: 'bid', amount: 1 };
|
||||
g = stepStandaloneHomeworldNexus(g, owned, f.actions);
|
||||
}
|
||||
throw Error('The original paid auction did not deliver Moritani armament.');
|
||||
}
|
||||
|
||||
export function invadeStandaloneHomeworldNexus(g: Game, actor: string, destination: string, amount: number,
|
||||
actions?: StandaloneHomeworldNexusStep[]): StandaloneHomeworldNexusTransition & { settled: Game } {
|
||||
g = advanceStandaloneHomeworldNexus(g, s => s.phase === 5 && s.active === actor && standaloneHomeworldNexusClean(s), actions);
|
||||
const before = reloadStandaloneHomeworldNexus(g), view = viewGame(g, actor);
|
||||
const sources = nativeShipmentSources(view, amount, 0); assert.ok(sources);
|
||||
const selected = Object.fromEntries(Object.entries(sources).filter(([, f]) => f.normal + f.elite > 0));
|
||||
const choice = homeworldShipmentChoice(view, destination, selected); assert.ok(choice.action, choice.blocked ?? 'Original invasion unavailable.');
|
||||
const step = { actor, action: choice.action };
|
||||
g = stepStandaloneHomeworldNexus(g, step, actions);
|
||||
const after = reloadStandaloneHomeworldNexus(g);
|
||||
g = advanceStandaloneHomeworldNexus(g, s => !s.pendingHomeworldShipment && !s.pendingShipment && standaloneHomeworldNexusClean(s), actions);
|
||||
return { before, step, after, settled: g };
|
||||
}
|
||||
export function openStandaloneHomeworldNexusBattle(g: Game, location: string, first: string, second: string,
|
||||
actions?: StandaloneHomeworldNexusStep[]): Game {
|
||||
g = advanceStandaloneHomeworldNexus(g, s => s.phase === 6 && standaloneHomeworldNexusClean(s) && !!s.active, actions);
|
||||
assert.ok(g.active === first || g.active === second);
|
||||
g = stepStandaloneHomeworldNexus(g, { actor: g.active!, action: { type: 'chooseBattle', territory: location,
|
||||
target: g.active === first ? second : first } }, actions);
|
||||
return advanceStandaloneHomeworldNexus(g, s => !!s.battle && standaloneHomeworldNexusClean(s) &&
|
||||
!s.battle.preparation && s.battle.preLeader?.closed !== false, actions);
|
||||
}
|
||||
export function revealStandaloneHomeworldNexusPlans(g: Game, plans: StandaloneHomeworldNexusStep[],
|
||||
actions?: StandaloneHomeworldNexusStep[]): Game {
|
||||
for (const step of plans) {
|
||||
g = stepStandaloneHomeworldNexus(g, step, actions);
|
||||
if (!g.battle?.revealed) g = advanceStandaloneHomeworldNexus(g, s => !!s.battle && standaloneHomeworldNexusClean(s) &&
|
||||
!s.battle.preparation && s.battle.preLeader?.closed !== false, actions);
|
||||
}
|
||||
assert.ok(g.battle?.revealed); return g;
|
||||
}
|
||||
export function settleStandaloneHomeworldNexus(g: Game, actions?: StandaloneHomeworldNexusStep[]): DiscoveryStandaloneNexusE3SkillsBoundary {
|
||||
const choices: Partial<Record<NonNullable<Game['decision']>['kind'], DiscoveryStandaloneNexusE3SkillsBoundary['kind']>> = {
|
||||
battleLosses: 'losses', sukRescue: 'rescue', moritaniAssassinate: 'assassination', battleCards: 'cards', techToken: 'tech',
|
||||
};
|
||||
g = advanceStandaloneHomeworldNexus(g, s => !s.response && !s.phaseOpening &&
|
||||
(!!(s.decision && choices[s.decision.kind]) || !s.battle && standaloneHomeworldNexusClean(s)), actions);
|
||||
return { game: g, kind: g.decision && choices[g.decision.kind] || 'settled' };
|
||||
}
|
||||
export function createStandaloneHomeworldEcazCunning(options: StandaloneHomeworldNexusOptions = {}) {
|
||||
const f = createStandaloneHomeworldNexusClosing({ ...options, faction: 'ecaz', advanced: true,
|
||||
roster: ['ecaz', 'guild', 'beneGesserit'], tech: options.tech ?? true, strongholds: options.strongholds ?? true });
|
||||
const opponent = f.afterSetup.players.find(p => p.faction === 'beneGesserit')!.id;
|
||||
const arrival = invadeStandaloneHomeworldNexus(f.game, f.owner, 'homeworld:beneGesserit', 1, f.actions);
|
||||
let g = advanceStandaloneHomeworldNexus(arrival.settled, s => s.phase === 6 && standaloneHomeworldNexusClean(s) && !s.battle, f.actions);
|
||||
const before = reloadStandaloneHomeworldNexus(g), step = ecazNexusSkillsModulesCunningRequest(g, f.owner);
|
||||
g = stepStandaloneHomeworldNexus(g, step, f.actions);
|
||||
const cunning = { before, step, after: reloadStandaloneHomeworldNexus(g) };
|
||||
g = openStandaloneHomeworldNexusBattle(g, 'homeworld:beneGesserit', f.owner, opponent, f.actions);
|
||||
const trained = new Set(g.leaderSkills!.assignments.map(a => a.leader));
|
||||
const own = standaloneHomeworldNexusPlayer(g, f.owner).leaders.filter(l => !trained.has(l.id)).sort((a, b) => a.strength - b.strength)[0];
|
||||
const enemy = standaloneHomeworldNexusPlayer(g, opponent).leaders.filter(l => !trained.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
|
||||
assert.ok(own && enemy);
|
||||
const plans: StandaloneHomeworldNexusStep[] = [
|
||||
{ actor: f.owner, action: { type: 'battlePlan', leader: own.id, dial: 0, support: 0 } },
|
||||
{ actor: opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 3, support: 3 } },
|
||||
];
|
||||
return { ...f, opponent, arrival, cunning, game: g, plans, location: 'homeworld:beneGesserit' };
|
||||
}
|
||||
|
||||
/** First original loss: the unused living Guild trainer remains eligible for
|
||||
* native private assassination only AFTER its real same-home normal Suk rescue. */
|
||||
export function openStandaloneHomeworldMoritaniDeath(f: StandaloneHomeworldNexusClosing, state: Game, leader: string,
|
||||
first = false): StandaloneHomeworldNexusBattle {
|
||||
const arrival = invadeStandaloneHomeworldNexus(state, f.owner, 'homeworld:guild', 1, f.actions);
|
||||
const beforeBattle = advanceStandaloneHomeworldNexus(arrival.settled,
|
||||
s => s.phase === 6 && standaloneHomeworldNexusClean(s), f.actions);
|
||||
const g = openStandaloneHomeworldNexusBattle(beforeBattle, 'homeworld:guild', f.owner, f.opponent, f.actions);
|
||||
const trained = new Set(g.leaderSkills!.assignments.map(a => a.leader));
|
||||
const winner = standaloneHomeworldNexusPlayer(g, f.opponent).leaders.filter(l => !l.dead && !trained.has(l.id))
|
||||
.sort((a, b) => b.strength - a.strength)[0];
|
||||
const weapon = standaloneHomeworldNexusPlayer(g, f.opponent).hand.find(c => c.kind === 'projectile');
|
||||
assert.ok(winner && weapon, 'The genuine Guild starting deal must provide its living winner and retained projectile.');
|
||||
const worthless = first ? standaloneHomeworldNexusPlayer(g, f.owner).hand.find(c => c.kind === 'worthless')?.id : undefined;
|
||||
return { arrival, settledArrival: arrival.settled, beforeBattle, game: g, location: 'homeworld:guild', plans: [
|
||||
{ actor: f.owner, action: { type: 'battlePlan', leader, dial: 0, support: 0, weapon: worthless } },
|
||||
{ actor: f.opponent, action: { type: 'battlePlan', leader: winner.id, dial: first ? 1 : 0,
|
||||
support: g.advanced && first ? 1 : 0, weapon: weapon.id } },
|
||||
] };
|
||||
}
|
||||
|
||||
/** Four later Guild discs die by actual paid invasions, against an actually
|
||||
* auctioned native projectile and Shield. The assassinated fifth disc is never
|
||||
* restaged; the Guild must establish all-five deaths to revive its OWN trainer. */
|
||||
export function openStandaloneHomeworldGuildDeath(f: StandaloneHomeworldNexusClosing, state: Game, leader: string,
|
||||
weapon: string, defense: string): StandaloneHomeworldNexusBattle {
|
||||
const arrival = invadeStandaloneHomeworldNexus(state, f.opponent, 'homeworld:moritani', 1, f.actions);
|
||||
const beforeBattle = advanceStandaloneHomeworldNexus(arrival.settled,
|
||||
s => s.phase === 6 && standaloneHomeworldNexusClean(s), f.actions);
|
||||
const g = openStandaloneHomeworldNexusBattle(beforeBattle, 'homeworld:moritani', f.owner, f.opponent, f.actions);
|
||||
const own = standaloneHomeworldNexusPlayer(g, f.owner);
|
||||
assert.ok(own.hand.some(c => c.id === weapon) && own.hand.some(c => c.id === defense));
|
||||
const winner = own.leaders.filter(l => !l.dead &&
|
||||
!g.leaderSkills!.assignments.some(a => a.owner === f.owner && a.leader === l.id))
|
||||
.sort((a, b) => b.strength - a.strength)[0]; assert.ok(winner);
|
||||
const enemyWeapon = standaloneHomeworldNexusPlayer(g, f.opponent).hand.find(c => c.kind === 'projectile');
|
||||
return { arrival, settledArrival: arrival.settled, beforeBattle, game: g, location: 'homeworld:moritani', plans: [
|
||||
{ actor: f.owner, action: { type: 'battlePlan', leader: winner.id, dial: 1, support: g.advanced ? 1 : 0, weapon, defense } },
|
||||
{ actor: f.opponent, action: { type: 'battlePlan', leader, dial: 0, support: 0, weapon: enemyWeapon?.id } },
|
||||
] };
|
||||
}
|
||||
|
||||
/** Optional nested entry uses the original supply lottery, paid parent shipment,
|
||||
* real inspection/reveal and next-turn free move; native reserves never move at entry. */
|
||||
export function createStandaloneHomeworldNexusDiscovery(options: StandaloneHomeworldNexusOptions = {}) {
|
||||
const f = createStandaloneHomeworldNexusSetup({ ...options, discovery: true });
|
||||
let g = reloadStandaloneHomeworldNexus(f.afterSetup);
|
||||
const index = g.spiceDeck.findIndex(c => 'territory' in c && c.discovery === 'discovery-hagga-basin'); assert.ok(index >= 0);
|
||||
g.spiceDeck.unshift(g.spiceDeck.splice(index, 1)[0]);
|
||||
const position = g.spiceDeck.findIndex((c, i) => i > 0 && 'territory' in c && !c.discovery && c.territory !== 'hagga_basin');
|
||||
assert.ok(position > 0); g.spiceDeck.splice(1, 0, g.spiceDeck.splice(position, 1)[0]);
|
||||
g = advanceStandaloneHomeworldNexus(g, s => s.phase === 1 && standaloneHomeworldNexusClean(s), f.actions);
|
||||
for (let n = 0; !g.discoveries!.tokens.some(t => t.face === 'cistern' && t.status === 'placed') && n < 160; n++) {
|
||||
const step = nextStandaloneHomeworldNexusStep(g); assert.ok(step); f.actions.push(structuredClone(step));
|
||||
g = withClassicDiscoveryNexusToken(g, 'cistern', () => applyAction(reloadStandaloneHomeworldNexus(g), step.actor, step.action));
|
||||
}
|
||||
g = advanceStandaloneHomeworldNexus(g, s => s.phase === 5 && s.active === f.owner && standaloneHomeworldNexusClean(s), f.actions);
|
||||
const beforeShipment = reloadStandaloneHomeworldNexus(g), destination = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
|
||||
const sources = nativeShipmentSources(viewGame(g, f.owner), 2, 0); assert.ok(sources);
|
||||
const shipment: StandaloneHomeworldNexusStep = { actor: f.owner, action: { type: 'ship', territory: destination.territory,
|
||||
sector: destination.sector, amount: 2, elite: 0, allyPayment: 0, homeworldSources: sources } };
|
||||
g = stepStandaloneHomeworldNexus(g, shipment, f.actions);
|
||||
g = advanceStandaloneHomeworldNexus(g, s => !s.pendingShipment && standaloneHomeworldNexusClean(s), f.actions);
|
||||
const afterShipment = reloadStandaloneHomeworldNexus(g);
|
||||
g = advanceStandaloneHomeworldNexus(g, s => s.phase === 7 && standaloneHomeworldNexusClean(s), f.actions);
|
||||
const token = g.discoveries!.tokens.find(t => t.face === 'cistern' && t.status === 'placed')!.id;
|
||||
if (viewGame(g, f.owner).discoveries!.canInspect.includes(token))
|
||||
g = stepStandaloneHomeworldNexus(g, { actor: f.owner, action: { type: 'discovery', token, reveal: false } }, f.actions);
|
||||
g = stepStandaloneHomeworldNexus(g, { actor: f.owner, action: { type: 'discovery', token, reveal: true } }, f.actions);
|
||||
const revealed = reloadStandaloneHomeworldNexus(g);
|
||||
g = advanceStandaloneHomeworldNexus(g, s => s.turn === 2 && !s.response && !s.phaseOpening &&
|
||||
s.decision?.kind === 'discoveryEntry' && s.decision.player === f.owner, f.actions);
|
||||
const before = reloadStandaloneHomeworldNexus(g), view = viewGame(g, f.owner);
|
||||
const action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources); assert.ok(action);
|
||||
const step = { actor: f.owner, action }; g = stepStandaloneHomeworldNexus(g, step, f.actions);
|
||||
return { ...f, beforeShipment, shipment, afterShipment, revealed, token, entry: { before, step, after: g }, game: g };
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { territory } from '../game/board';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import { quotePhaseResources } from '../game/phase-resource-quote';
|
||||
import { quoteSpiceCollection } from '../game/board-resolution-quote';
|
||||
import { discoveryPairedNexusE2SkillsCunningAction } from './fixture-discovery-paired-nexus-e2-skills';
|
||||
import { pairedSkillsChoamCunningRequest, quotePairedSkillsBattle } from './fixture-paired-choam-nexus-skills';
|
||||
import {
|
||||
advanceHomeworldPairedNexus as advance, createHomeworldPairedNexusProgramme as programme,
|
||||
finishHomeworldPairedNexusBattle as finishBattle, homeworldPairedNexusArmy as army,
|
||||
homeworldPairedNexusClean as clean, homeworldPairedNexusInventory as inventory,
|
||||
homeworldPairedNexusLeader as leader, homeworldPairedNexusPlayer as player,
|
||||
openHomeworldPairedNexusBattle as openBattle, revealHomeworldPairedNexusPlans as revealPlans,
|
||||
settleHomeworldPairedNexus as settle, shipHomeworldPairedNexus as ship,
|
||||
stepHomeworldPairedNexus as step, type HomeworldPairedNexusStep,
|
||||
} from './fixture-homeworld-paired-nexus';
|
||||
|
||||
/** Every consumer begins with the original initializer, actual owned actions,
|
||||
* conserved unused lotteries and unplayed Spice order. No held card, board
|
||||
* position, wallet, alliance, outcome or authenticated receipt is assigned. */
|
||||
function reject(game: Game, actor: string, action: Action): void {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before, 'Rejected consumers leave physical stock and payments unchanged.');
|
||||
}
|
||||
function policies(game: Game, actor: string, select: (action: Action) => boolean,
|
||||
outcome: (after: Game, action: Action) => void): void {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, actor); view.players.find(p => p.id === actor)!.bot = difficulty;
|
||||
const action = botActions(view).find(select); assert.ok(action, `${difficulty} selects the actual owned window`);
|
||||
const after = applyAction(structuredClone(game), actor, action);
|
||||
outcome(after, action); inventory(after);
|
||||
}
|
||||
}
|
||||
function consumeSuboids(game: Game, owner: string, actions?: HomeworldPairedNexusStep[]): Game {
|
||||
const offer = viewGame(game, owner).nexusSuboids?.offer; assert.ok(offer && !offer.blocked);
|
||||
const action: Action = { type: 'nexusSuboids', event: offer.event };
|
||||
reject(game, owner, { ...action, event: 'obsolete-suboid-battle' });
|
||||
policies(game, owner, a => a.type === 'nexusSuboids', after => {
|
||||
const resolved = settle(after);
|
||||
assert.equal(resolved.nexusCards!.cards!.hands[owner], null);
|
||||
assert.equal(resolved.nexusCards!.cards!.discard.filter(c => c === 'ixians').length, 1);
|
||||
assert.deepEqual(player(resolved, owner).forces, player(game, owner).forces);
|
||||
assert.equal(viewGame(resolved, owner).battle!.ownForces!.normalFreeSupport, true);
|
||||
});
|
||||
const played = settle(step(game, { actor: owner, action }, actions), actions);
|
||||
assert.equal(played.nexusCards!.cards!.hands[owner], null);
|
||||
assert.equal(played.nexusCards!.cards!.discard.filter(c => c === 'ixians').length, 1);
|
||||
assert.equal(viewGame(played, owner).battle!.ownForces!.normalFreeSupport, true);
|
||||
reject(played, owner, action); inventory(played);
|
||||
return played;
|
||||
}
|
||||
function atMovement(game: Game, actor: string, turn = 2): Game {
|
||||
return advance(game, g => g.turn === turn && g.phase === 5 && g.active === actor && clean(g));
|
||||
}
|
||||
|
||||
void test('Basic paired E1 without Skills or Discovery genuinely draws matching Cunning, invades native Ix and preserves foreign-Homeworld Face Dance suppression', () => {
|
||||
const f = programme({ advanced: false, skills: false, discovery: false, tech: true, strongholds: false });
|
||||
assert.equal(player(f.alliance, f.guild).ally, f.fremen);
|
||||
assert.equal(player(f.alliance, f.fremen).ally, f.guild);
|
||||
policies(f.draws[0].before, f.owner, a => a.type === 'nexusCardChoice', after => {
|
||||
assert.equal(after.nexusCards!.cards!.hands[f.owner], 'ixians');
|
||||
assert.equal(player(after, f.owner).spice, player(f.draws[0].before, f.owner).spice);
|
||||
assert.deepEqual(after.homeworlds!.custody, f.draws[0].before.homeworlds!.custody);
|
||||
});
|
||||
for (const draw of f.draws) {
|
||||
assert.equal(draw.before.nexusCards!.cards!.hands[draw.step.actor], null);
|
||||
assert.equal(draw.after.nexusCards!.cards!.hands[draw.step.actor], player(draw.after, draw.step.actor).faction);
|
||||
assert.equal(player(draw.after, draw.step.actor).spice, player(draw.before, draw.step.actor).spice);
|
||||
}
|
||||
let game = ship(atMovement(f.game, f.guild), f.guild, 'homeworld:ixians', 3);
|
||||
game = consumeSuboids(openBattle(game, f.owner, f.guild, 'homeworld:ixians', false), f.owner);
|
||||
const own = leader(game, f.owner, true), before = structuredClone(game), lostTech = ownedTech(game.techTokens, f.guild);
|
||||
game = revealPlans(game, [{ actor: f.owner, action: { type: 'battlePlan', leader: own, dial: 0, support: 0 } },
|
||||
{ actor: f.guild, action: { type: 'battlePlan', leader: leader(game, f.guild), dial: 0, support: 0 } }]);
|
||||
game = finishBattle(game);
|
||||
assert.equal(game.lastBattleContext!.winner, f.owner);
|
||||
assert.deepEqual(army(game, f.owner, 'homeworld:ixians'), army(before, f.owner, 'homeworld:ixians'));
|
||||
assert.deepEqual(army(game, f.guild, 'homeworld:ixians'), { normal: 0, elite: 0 });
|
||||
assert.equal(player(game, f.guild).tanks, player(before, f.guild).tanks + 3);
|
||||
for (const token of lostTech) assert.equal(game.techTokens![token].owner, f.owner);
|
||||
assert.equal(player(game, f.owner).leaders.find(l => l.id === own)!.dead, false);
|
||||
assert.ok(player(game, f.native).faceDancers!.every(c => !c.revealed));
|
||||
reject(game, f.native, { type: 'decision', reveal: true, sources: { reserves: 1 }, sector: 0 });
|
||||
inventory(game);
|
||||
});
|
||||
|
||||
for (const visitor of [false, true]) void test(`Advanced matching Suboid Cunning and ${visitor ? 'visitor' : 'native'} typed Suk leave only real residual Cyborg Tanks available for equal Suboid exchange`, () => {
|
||||
const f = programme({ advanced: true, skills: true, discovery: false, tech: true, strongholds: true });
|
||||
assert.ok(f.trainer);
|
||||
const destination = visitor ? 'homeworld:guild' : 'homeworld:ixians';
|
||||
const sender = visitor ? f.owner : f.guild;
|
||||
let game = ship(atMovement(f.game, sender), sender, destination, visitor ? 4 : 3, visitor ? 3 : 0);
|
||||
game = consumeSuboids(openBattle(game, f.owner, f.guild, destination), f.owner);
|
||||
const before = structuredClone(game), enemyTech = ownedTech(game.techTokens, f.guild);
|
||||
game = revealPlans(game, [{ actor: f.owner, action: { type: 'battlePlan', leader: f.trainer, dial: 3, support: 0 } },
|
||||
{ actor: f.guild, action: { type: 'battlePlan', leader: leader(game, f.guild), dial: 0, support: 0 } }]);
|
||||
game = advance(game, g => g.decision?.kind === 'sukRescue');
|
||||
const d = game.decision; assert.ok(d?.kind === 'sukRescue');
|
||||
assert.deepEqual(army(game, f.owner, destination), army(before, f.owner, destination), 'Casualty commitment has not yet killed or moved physical counters.');
|
||||
policies(game, f.owner, a => a.type === 'decision' && a.choice !== undefined, (after, action) => {
|
||||
const rescue = d.options[Number(action.choice)], saved = rescue.normal + rescue.elite;
|
||||
assert.equal(player(after, f.owner).tanks, player(game, f.owner).tanks + 3 - saved);
|
||||
assert.equal(player(after, f.owner).elites!.tanks, player(game, f.owner).elites!.tanks + 3 - rescue.elite);
|
||||
assert.deepEqual(army(after, f.owner, 'homeworld:ixians'), visitor
|
||||
? { normal: army(game, f.owner, 'homeworld:ixians').normal,
|
||||
elite: army(game, f.owner, 'homeworld:ixians').elite + rescue.elite - (rescue.kept?.kind === 'elite' ? 1 : 0) }
|
||||
: { normal: army(game, f.owner, destination).normal, elite: army(game, f.owner, destination).elite - 3 + rescue.elite });
|
||||
});
|
||||
const choice = d.options.findIndex(o => o.normal === 0 && o.elite === 1 && o.kept?.kind === 'elite'); assert.ok(choice >= 0);
|
||||
const rescueAction: Action = { type: 'decision', event: d.event, choice };
|
||||
reject(game, f.guild, rescueAction);
|
||||
reject(game, f.owner, { ...rescueAction, event: 'obsolete-suk' });
|
||||
reject(game, f.owner, { ...rescueAction, destinations: { 'homeworld:ixians': { normal: 0, elite: 1 } } });
|
||||
game = step(JSON.parse(JSON.stringify(game)) as Game, { actor: f.owner, action: rescueAction });
|
||||
game = advance(game, g => g.decision?.kind === 'ixSubstitution');
|
||||
const physical = game.lastBattleContext!.sukRescue!.physical!;
|
||||
assert.equal(physical.source, visitor ? 'visitor-homeworld' : 'native-homeworld');
|
||||
assert.deepEqual(physical.saved, { normal: 0, elite: 1 });
|
||||
assert.deepEqual(physical.tanks, { normal: 0, elite: 2 });
|
||||
assert.deepEqual(physical.destinations, {});
|
||||
assert.deepEqual(game.pendingIxSubstitution!.losses, { [destination]: 2 });
|
||||
const residual = structuredClone(game), pool = army(game, f.owner, destination);
|
||||
const exchange: Action = { type: 'decision', sources: { [destination]: 1 }, recover: { [destination]: 1 } };
|
||||
reject(game, f.owner, { ...exchange, recover: { [destination]: 3 } });
|
||||
reject(game, f.owner, { ...exchange, sources: { reserves: 1 } });
|
||||
reject(game, f.owner, { ...exchange, recover: { 'homeworld:tleilaxu': 1 } });
|
||||
policies(game, f.owner, a => a.type === 'decision' && (a.decline === true || a.sources !== undefined), (after, action) => {
|
||||
const count = action.decline ? 0 : Object.values((action.recover ?? {}) as Record<string, number>).reduce((sum, n) => sum + n, 0);
|
||||
assert.equal(player(after, f.owner).elites!.tanks, player(residual, f.owner).elites!.tanks - count);
|
||||
assert.deepEqual(army(after, f.owner, destination), { normal: pool.normal - count, elite: pool.elite + count });
|
||||
assert.equal(player(after, f.owner).spice, player(residual, f.owner).spice);
|
||||
});
|
||||
game = step(game, { actor: f.owner, action: exchange });
|
||||
game = advance(game, g => !g.pendingIxSubstitution);
|
||||
assert.deepEqual(army(game, f.owner, destination), { normal: pool.normal - 1, elite: pool.elite + 1 });
|
||||
assert.equal(player(game, f.owner).elites!.tanks, player(residual, f.owner).elites!.tanks - 1);
|
||||
assert.equal(player(game, f.owner).tanks, player(residual, f.owner).tanks);
|
||||
assert.equal(player(game, f.owner).spice, player(residual, f.owner).spice);
|
||||
if (visitor) assert.deepEqual(army(game, f.owner, 'homeworld:ixians'), army(residual, f.owner, 'homeworld:ixians'));
|
||||
reject(game, f.owner, exchange);
|
||||
game = finishBattle(game);
|
||||
assert.equal(game.lastBattleContext!.winner, f.owner);
|
||||
for (const token of enemyTech) assert.equal(game.techTokens![token].owner, f.owner);
|
||||
assert.ok(player(game, f.native).faceDancers!.every(c => !c.revealed), 'Foreign Homeworld never opens the forbidden native replacement.');
|
||||
inventory(game);
|
||||
});
|
||||
|
||||
void test('actual Maker entry and matching nested Cunning preserve typed sources; cleanup precedes winner Tech and legal matching Face Dance without a borrowed-native grant', () => {
|
||||
const f = programme({ advanced: true, discovery: true, skills: true, tech: true, strongholds: true });
|
||||
assert.ok(f.entry && f.ride && f.vote && f.trainer);
|
||||
const entered = f.entry.after;
|
||||
assert.equal(player(entered, f.owner).forces['shrine:0'], 2);
|
||||
assert.equal(player(entered, f.owner).elites!.forces['shrine:0'], 1);
|
||||
assert.deepEqual(entered.homeworlds!.custody, f.entry.before.homeworlds!.custody);
|
||||
assert.equal(player(entered, f.owner).spice, player(f.entry.before, f.owner).spice);
|
||||
policies(f.entry.before, f.owner, a => a.type === 'decision' && a.accept === false, after => {
|
||||
assert.equal(player(after, f.owner).forces['shrine:0'] ?? 0, 0);
|
||||
assert.deepEqual(player(after, f.owner).forces, player(f.entry!.before, f.owner).forces);
|
||||
assert.deepEqual(after.homeworlds!.custody, f.entry!.before.homeworlds!.custody);
|
||||
});
|
||||
const rideBefore = player(f.ride.before, f.fremen), rideAfter = player(f.ride.after, f.fremen);
|
||||
assert.equal(rideAfter.reserves, rideBefore.reserves - 2);
|
||||
assert.equal(rideAfter.elites!.reserves, rideBefore.elites!.reserves - 1);
|
||||
assert.equal(rideAfter.forces['polar_sink:0'], (rideBefore.forces['polar_sink:0'] ?? 0) + 2);
|
||||
assert.equal(rideAfter.spice, rideBefore.spice);
|
||||
let game = ship(atMovement(f.game, f.native), f.native, 'shrine', 2);
|
||||
game = consumeSuboids(openBattle(game, f.owner, f.native, 'shrine'), f.owner);
|
||||
assert.ok(player(game, f.native).faceDancers!.some(c => c.leader === f.trainer && !c.revealed));
|
||||
const worthless = player(game, f.owner).hand.find(c => c.kind === 'worthless'); assert.ok(worthless);
|
||||
game = revealPlans(game, [{ actor: f.owner, action: { type: 'battlePlan', leader: f.trainer, dial: 0, support: 0, weapon: worthless.id } },
|
||||
{ actor: f.native, action: { type: 'battlePlan', leader: leader(game, f.native), dial: 0, support: 0 } }]);
|
||||
game = advance(game, g => g.decision?.kind === 'battleCards');
|
||||
assert.equal(game.techTokens!.axlotl.owner, f.native);
|
||||
policies(game, f.owner, a => a.type === 'decision' && a.discard !== undefined, (after, action) => {
|
||||
const discarded = action.discard as string[];
|
||||
assert.equal(player(after, f.owner).hand.some(c => c.id === worthless.id), !discarded.includes(worthless.id));
|
||||
assert.equal(after.discard.some(c => c.id === worthless.id), discarded.includes(worthless.id));
|
||||
});
|
||||
game = step(game, { actor: f.owner, action: { type: 'decision', discard: [worthless.id] } });
|
||||
game = advance(game, g => g.decision?.kind === 'faceDance');
|
||||
assert.equal(game.techTokens!.axlotl.owner, f.owner);
|
||||
assert.equal(player(game, f.owner).hand.some(c => c.id === worthless.id), false);
|
||||
assert.equal(game.discard.filter(c => c.id === worthless.id).length, 1);
|
||||
const before = structuredClone(game), home = army(game, f.native, 'homeworld:tleilaxu');
|
||||
const dance: Action = { type: 'decision', reveal: true, sources: { reserves: 1 }, sector: 0 };
|
||||
reject(game, f.owner, dance);
|
||||
reject(game, f.native, { ...dance, sources: { reserves: 3 } });
|
||||
policies(game, f.native, a => a.type === 'decision' && a.reveal !== undefined, (after, action) => {
|
||||
const count = action.reveal ? Object.values((action.sources ?? {}) as Record<string, number>).reduce((sum, n) => sum + n, 0) : 0;
|
||||
assert.equal(player(after, f.owner).leaders.find(l => l.id === f.trainer)!.dead, !!action.reveal);
|
||||
assert.equal(player(after, f.native).faceDancers!.find(c => c.leader === f.trainer)!.revealed, !!action.reveal);
|
||||
assert.equal(player(after, f.owner).forces['shrine:0'] ?? 0, action.reveal ? 0 : 2);
|
||||
assert.equal(player(after, f.native).forces['shrine:0'] ?? 0, count);
|
||||
assert.deepEqual(after.techTokens, before.techTokens);
|
||||
});
|
||||
game = step(JSON.parse(JSON.stringify(game)) as Game, { actor: f.native, action: dance });
|
||||
assert.equal(player(game, f.owner).forces['shrine:0'] ?? 0, 0);
|
||||
assert.equal(player(game, f.owner).reserves, player(before, f.owner).reserves + 2);
|
||||
assert.equal(player(game, f.owner).elites!.reserves, player(before, f.owner).elites!.reserves + 1);
|
||||
assert.equal(player(game, f.native).forces['shrine:0'], 1);
|
||||
assert.deepEqual(army(game, f.native, 'homeworld:tleilaxu'), { normal: home.normal - 1, elite: home.elite });
|
||||
assert.equal(player(game, f.owner).leaders.find(l => l.id === f.trainer)!.dead, true);
|
||||
assert.equal(game.leaderSkills!.assignments.some(a => a.leader === f.trainer), false);
|
||||
assert.equal(game.leaderSkills!.deck.filter(c => c === 'suk-graduate').length, 1);
|
||||
assert.deepEqual(game.techTokens, before.techTokens, 'Face Dance cannot redo the already settled winner reward.');
|
||||
inventory(game);
|
||||
});
|
||||
|
||||
void test('nested Richese matching pair charges one signed invoice, keeps markers separate until reveal and debits actual Advanced support before Suk and winner Tech', () => {
|
||||
const f = programme({ family: 'choam', advanced: true, skills: true, discovery: true, tech: true, strongholds: true });
|
||||
assert.ok(f.firstMarker && f.trainer);
|
||||
assert.equal(player(f.firstMarker.after, f.owner).spice, player(f.firstMarker.before, f.owner).spice - 1);
|
||||
assert.equal(player(f.firstMarker.after, f.owner).reserves, player(f.firstMarker.before, f.owner).reserves);
|
||||
let game = atMovement(f.game, f.owner);
|
||||
const before = structuredClone(game), request = discoveryPairedNexusE2SkillsCunningAction(game, f.owner);
|
||||
reject(game, f.owner, { ...request, nexus: 'obsolete-pair' });
|
||||
reject(game, f.owner, { ...request, revealedToken: request.noField });
|
||||
reject(game, f.owner, { ...request, smuggler: true });
|
||||
game = step(game, { actor: f.owner, action: request });
|
||||
assert.equal(player(game, f.owner).spice, player(before, f.owner).spice, 'Declaration alone does not debit the invoice.');
|
||||
game = settle(game);
|
||||
assert.equal(player(game, f.owner).spice, player(before, f.owner).spice - 1);
|
||||
assert.equal(player(game, f.guild).spice, player(before, f.guild).spice + 1);
|
||||
assert.equal(player(game, f.owner).forces['shrine:0'], 5);
|
||||
assert.equal(player(game, f.owner).reserves, player(before, f.owner).reserves - 5);
|
||||
assert.equal(player(game, f.owner).noField!.deployed!.tokenId, request.noField);
|
||||
assert.equal(game.nexusCards!.cards!.hands[f.owner], null);
|
||||
const deployed = structuredClone(game), event = player(game, f.owner).noFieldEvent;
|
||||
reject(game, f.owner, request);
|
||||
reject(game, f.owner, { type: 'revealNoField', event: 'obsolete-reveal', token: request.noField });
|
||||
game = step(JSON.parse(JSON.stringify(game)) as Game, { actor: f.owner,
|
||||
action: { type: 'revealNoField', event, token: request.noField } });
|
||||
assert.equal(player(game, f.owner).forces['shrine:0'], 8);
|
||||
assert.equal(player(game, f.owner).reserves, player(deployed, f.owner).reserves - 3);
|
||||
assert.equal(player(game, f.owner).spice, player(deployed, f.owner).spice);
|
||||
assert.equal(player(game, f.guild).spice, player(deployed, f.guild).spice);
|
||||
game = openBattle(game, f.owner, f.guild, 'shrine');
|
||||
const beforePlans = structuredClone(game), lostTech = ownedTech(game.techTokens, f.guild);
|
||||
game = revealPlans(game, [{ actor: f.owner, action: { type: 'battlePlan', leader: f.trainer, dial: 2, support: 2 } },
|
||||
{ actor: f.guild, action: { type: 'battlePlan', leader: leader(game, f.guild), dial: 0, support: 0 } }]);
|
||||
assert.equal(player(game, f.owner).spice, player(beforePlans, f.owner).spice, 'Sealed/revealed support is not charged before resolution.');
|
||||
game = advance(game, g => g.decision?.kind === 'sukRescue');
|
||||
assert.equal(player(game, f.owner).spice, player(beforePlans, f.owner).spice - 2);
|
||||
assert.equal(player(game, f.native).spice, player(beforePlans, f.native).spice,
|
||||
'Native CHOAM income waits for the original skill aftermath; payment has already settled.');
|
||||
for (const token of lostTech) assert.equal(game.techTokens![token].owner, f.guild);
|
||||
const d = game.decision; assert.ok(d?.kind === 'sukRescue');
|
||||
const choice = d.options.findIndex(o => o.normal === 2 && o.elite === 0 && o.kept?.kind === 'normal'); assert.ok(choice >= 0);
|
||||
const home = army(game, f.owner, 'homeworld:richese'), funds = new Map(game.players.map(p => [p.id, p.spice]));
|
||||
game = step(game, { actor: f.owner, action: { type: 'decision', event: d.event, choice } });
|
||||
assert.equal(player(game, f.owner).forces['shrine:0'], 7);
|
||||
assert.deepEqual(army(game, f.owner, 'homeworld:richese'), { normal: home.normal + 1, elite: 0 });
|
||||
assert.equal(player(game, f.owner).tanks, 0);
|
||||
for (const seat of game.players)
|
||||
assert.equal(seat.spice, funds.get(seat.id)! + (seat.id === f.native ? 1 : 0),
|
||||
'Rescue does not repeat/refund support; the original pending CHOAM income settles after skill cleanup.');
|
||||
game = finishBattle(game);
|
||||
assert.equal(game.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(player(game, f.guild).tanks, 3);
|
||||
for (const token of lostTech) assert.equal(game.techTokens![token].owner, f.owner);
|
||||
inventory(game);
|
||||
});
|
||||
|
||||
void test('real first-turn Arrakeen claim supplies source-local bank support after genuine pairing; held custody and phase-end industry pay only at their original boundaries', () => {
|
||||
const f = programme({ family: 'choam', advanced: true, discovery: false, skills: true, tech: true, strongholds: true, arrakeenClaim: true });
|
||||
assert.equal(f.game.strongholdCards!.owners.arrakeen, f.owner);
|
||||
const actors = new Set([f.owner, f.guild]);
|
||||
let game = f.game;
|
||||
while (actors.size) {
|
||||
game = advance(game, g => g.phase === 5 && clean(g) && actors.has(g.active!));
|
||||
const actor = game.active!;
|
||||
if (actor === f.owner) {
|
||||
const p = player(game, actor), offer = viewGame(game, actor).nexusRicheseCunning; assert.ok(offer && !offer.blocked);
|
||||
const request: Action = { type: 'ship', territory: 'arrakeen', sector: territory('arrakeen').sectors[0],
|
||||
noField: p.noField!.tokens.find(t => t.value === 0)!.id, revealedToken: p.noField!.tokens.find(t => t.value === 5)!.id,
|
||||
event: p.noFieldEvent, nexus: offer.event, allyPayment: 0 };
|
||||
game = settle(step(game, { actor, action: request }));
|
||||
game = step(game, { actor, action: { type: 'revealNoField', event: player(game, actor).noFieldEvent, token: request.noField } });
|
||||
} else game = ship(game, actor, 'arrakeen', 3);
|
||||
actors.delete(actor);
|
||||
if (actors.size) game = step(game, { actor, action: { type: 'endMovement' } });
|
||||
}
|
||||
const industry = game.techTokens!.heighliners, industryOwner = industry.owner; assert.ok(industryOwner);
|
||||
const credit = quotePhaseResources(game).credits.find(row => row.kind === 'tech' && row.token === 'heighliners'); assert.ok(credit);
|
||||
const beforeMovementEnd = structuredClone(game);
|
||||
game = openBattle(game, f.owner, f.guild, 'arrakeen');
|
||||
assert.equal(game.techTokens!.heighliners.spice, 0);
|
||||
assert.equal(player(game, industryOwner).spice, credit.balance);
|
||||
assert.equal(game.strongholdCards!.owners.arrakeen, f.owner, 'Contest does not transfer a held card before END Mentat.');
|
||||
assert.equal(viewGame(game, f.owner).battle!.strongholdEffects[f.owner], 'arrakeen');
|
||||
const beforePlans = structuredClone(game); assert.ok(f.trainer);
|
||||
game = revealPlans(game, [{ actor: f.owner, action: { type: 'battlePlan', leader: f.trainer, dial: 2, support: 2 } },
|
||||
{ actor: f.guild, action: { type: 'battlePlan', leader: leader(game, f.guild), dial: 0, support: 0 } }]);
|
||||
const quote = quotePairedSkillsBattle(game), payment = quote.payments.find(p => p.player === f.owner)!;
|
||||
assert.equal(payment.ownPayment, 0); assert.equal(payment.bankSupport, 2);
|
||||
game = advance(game, g => g.decision?.kind === 'sukRescue');
|
||||
assert.equal(player(game, f.owner).spice, player(beforePlans, f.owner).spice);
|
||||
assert.equal(player(game, f.native).spice, player(beforePlans, f.native).spice,
|
||||
'Bank support still earns the pending native income only after skill cleanup.');
|
||||
const d = game.decision; assert.ok(d?.kind === 'sukRescue');
|
||||
const choice = d.options.findIndex(o => o.normal === 2 && o.kept?.kind === 'normal'); assert.ok(choice >= 0);
|
||||
game = finishBattle(step(game, { actor: f.owner, action: { type: 'decision', event: d.event, choice } }));
|
||||
assert.equal(player(game, f.owner).forces[`arrakeen:${territory('arrakeen').sectors[0]}`], 7);
|
||||
assert.equal(player(game, f.owner).tanks, 0);
|
||||
assert.equal(game.strongholdCards!.owners.arrakeen, f.owner);
|
||||
assert.equal(beforeMovementEnd.techTokens!.heighliners.spice, ownedTech(beforeMovementEnd.techTokens, industryOwner).length);
|
||||
inventory(game);
|
||||
});
|
||||
|
||||
void test('actual CHOAM Special fuels matching Cunning once; original native payer support is charged at resolution and cannot use held Arrakeen from another source', () => {
|
||||
const f = programme({ family: 'choam', advanced: true, skills: true, discovery: false, tech: true, strongholds: true, arrakeenClaim: true });
|
||||
let game = atMovement(f.game, f.native);
|
||||
const fuel = player(game, f.native).hand.find(c => c.kind === 'special' && c.effect !== 'karama'); assert.ok(fuel);
|
||||
const action = pairedSkillsChoamCunningRequest(game, f.native, fuel.id);
|
||||
const before = structuredClone(game);
|
||||
reject(game, f.native, { ...action, nexus: 'obsolete-choam' });
|
||||
game = settle(step(game, { actor: f.native, action }));
|
||||
assert.equal(player(game, f.native).hand.some(c => c.id === fuel.id), false);
|
||||
assert.equal(game.discard.filter(c => c.id === fuel.id).length, 1);
|
||||
assert.equal(game.nexusCards!.cards!.hands[f.native], null);
|
||||
assert.equal(player(game, f.native).spice, player(before, f.native).spice);
|
||||
reject(game, f.native, action);
|
||||
game = ship(game, f.native, 'homeworld:guild', 3);
|
||||
game = openBattle(game, f.native, f.guild, 'homeworld:guild', false);
|
||||
assert.notEqual(viewGame(game, f.native).battle!.strongholdEffects[f.native], 'arrakeen');
|
||||
const ownLeader = leader(game, f.native, true), beforePlans = structuredClone(game);
|
||||
game = revealPlans(game, [{ actor: f.native, action: { type: 'battlePlan', leader: ownLeader, dial: 2, support: 2 } },
|
||||
{ actor: f.guild, action: { type: 'battlePlan', leader: leader(game, f.guild), dial: 0, support: 0 } }]);
|
||||
assert.equal(player(game, f.native).spice, player(beforePlans, f.native).spice);
|
||||
game = finishBattle(game);
|
||||
assert.equal(game.lastBattleContext!.winner, f.native);
|
||||
assert.equal(player(game, f.native).tanks, player(beforePlans, f.native).tanks + 2);
|
||||
assert.deepEqual(army(game, f.native, 'homeworld:guild'), { normal: 1, elite: 0 });
|
||||
// Collection may already have opened; separate its stronghold/harvest income from support.
|
||||
const collection = game.phase === 7 && clean(game) ? quoteSpiceCollection(game).receipts.find(r => r.player === f.native) : undefined;
|
||||
assert.equal(player(game, f.native).spice, player(beforePlans, f.native).spice - 2 +
|
||||
(collection?.strongholds ?? 0) + (collection?.collected ?? 0));
|
||||
assert.equal(game.discard.filter(c => c.id === fuel.id).length, 1);
|
||||
inventory(game);
|
||||
});
|
||||
|
||||
void test('genuine typed Shrine reinforcements make nested Suk precede only residual Cyborg substitution, with matching native Cunning already consumed', () => {
|
||||
const f = programme({ advanced: true, skills: true, discovery: true, tech: true, strongholds: true });
|
||||
assert.ok(f.trainer);
|
||||
const senders = new Set([f.owner, f.native]);
|
||||
let game = f.game;
|
||||
while (senders.size) {
|
||||
game = advance(game, g => g.turn === 2 && g.phase === 5 && clean(g) && senders.has(g.active!));
|
||||
const actor = game.active!;
|
||||
game = ship(game, actor, 'shrine', actor === f.owner ? 4 : 3, actor === f.owner ? 2 : 0);
|
||||
senders.delete(actor);
|
||||
if (senders.size) game = step(game, { actor, action: { type: 'endMovement' } });
|
||||
}
|
||||
assert.equal(player(game, f.owner).forces['shrine:0'], 6);
|
||||
assert.equal(player(game, f.owner).elites!.forces['shrine:0'], 3);
|
||||
game = consumeSuboids(openBattle(game, f.owner, f.native, 'shrine'), f.owner);
|
||||
const home = army(game, f.owner, 'homeworld:ixians'), priorTanks = player(game, f.owner).elites!.tanks;
|
||||
game = revealPlans(game, [{ actor: f.owner, action: { type: 'battlePlan', leader: f.trainer, dial: 3, support: 0 } },
|
||||
{ actor: f.native, action: { type: 'battlePlan', leader: leader(game, f.native), dial: 0, support: 0 } }]);
|
||||
game = advance(game, g => g.decision?.kind === 'sukRescue');
|
||||
const d = game.decision; assert.ok(d?.kind === 'sukRescue');
|
||||
const choice = d.options.findIndex(o => o.normal === 0 && o.elite === 1 && o.kept?.kind === 'elite'); assert.ok(choice >= 0);
|
||||
game = step(game, { actor: f.owner, action: { type: 'decision', event: d.event, choice } });
|
||||
game = advance(game, g => g.decision?.kind === 'ixSubstitution');
|
||||
assert.equal(player(game, f.owner).elites!.tanks, priorTanks + 2);
|
||||
assert.equal(player(game, f.owner).forces['shrine:0'], 4);
|
||||
assert.equal(player(game, f.owner).elites!.forces['shrine:0'], 1);
|
||||
assert.deepEqual(army(game, f.owner, 'homeworld:ixians'), home, 'The kept nested Cyborg is not also returned to native reserves.');
|
||||
assert.deepEqual(game.pendingIxSubstitution!.losses, { 'shrine:0': 2 });
|
||||
const before = structuredClone(game);
|
||||
const action: Action = { type: 'decision', sources: { 'shrine:0': 1 }, recover: { 'shrine:0': 1 } };
|
||||
reject(game, f.owner, { ...action, recover: { 'shrine:0': 3 } });
|
||||
reject(game, f.owner, { ...action, sources: { 'homeworld:ixians': 1 } });
|
||||
game = step(JSON.parse(JSON.stringify(game)) as Game, { actor: f.owner, action });
|
||||
game = advance(game, g => !g.pendingIxSubstitution);
|
||||
assert.equal(player(game, f.owner).forces['shrine:0'], 4);
|
||||
assert.equal(player(game, f.owner).elites!.forces['shrine:0'], 2);
|
||||
assert.equal(player(game, f.owner).elites!.tanks, player(before, f.owner).elites!.tanks - 1);
|
||||
assert.equal(player(game, f.owner).tanks, player(before, f.owner).tanks);
|
||||
assert.equal(player(game, f.owner).spice, player(before, f.owner).spice);
|
||||
assert.deepEqual(army(game, f.owner, 'homeworld:ixians'), home);
|
||||
game = finishBattle(game);
|
||||
assert.equal(game.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(player(game, f.native).tanks, 3);
|
||||
assert.equal(player(game, f.native).faceDancers!.find(c => c.leader === f.trainer)!.revealed, false);
|
||||
inventory(game);
|
||||
});
|
||||
|
||||
void test('no-Skills native Richese pair at a revealed nested site is capped by reserves spent through four actual earlier shipments, not virtual materialization', () => {
|
||||
const f = programme({ family: 'choam', advanced: true, skills: false, discovery: true, tech: true, strongholds: true });
|
||||
let game = f.game;
|
||||
// Keep the genuinely drawn matching singleton while four ordinary markers
|
||||
// consume 5 + 3 + 5 + 3 physical native counters through their actual seats.
|
||||
for (const [index, value] of [5, 3, 5, 3].entries()) {
|
||||
const turn = index + 2;
|
||||
if (turn > 2) {
|
||||
game = advance(game, g => g.turn === turn && g.phase === 1 && clean(g));
|
||||
// Only unused land faces are conserved; held Nexus, played worms and
|
||||
// custody remain untouched. Two owned strongholds do not win the game.
|
||||
const lands = game.spiceDeck.filter(c => 'territory' in c && !c.discovery).slice(0, 2);
|
||||
for (const [position, card] of lands.entries())
|
||||
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]);
|
||||
}
|
||||
game = atMovement(game, f.owner, turn);
|
||||
const destination = ['sietch_tabr', 'habbanya_ridge_sietch'].map(id => territory(id))
|
||||
.find(t => !t.sectors.includes(game.storm) && game.players.filter(p => p.id !== f.owner)
|
||||
.every(p => Object.entries(p.forces).every(([key, amount]) => !amount || !key.startsWith(`${t.id}:`)))); assert.ok(destination);
|
||||
const p = player(game, f.owner), token = p.noField!.tokens.find(t => t.value === value)!;
|
||||
game = settle(step(game, { actor: f.owner, action: { type: 'ship', territory: destination.id,
|
||||
sector: destination.sectors[0], noField: token.id, event: p.noFieldEvent, allyPayment: 0 } }));
|
||||
game = step(game, { actor: f.owner, action: { type: 'revealNoField', token: token.id, event: player(game, f.owner).noFieldEvent } });
|
||||
assert.equal(game.nexusCards!.cards!.hands[f.owner], 'richese');
|
||||
inventory(game);
|
||||
}
|
||||
game = advance(game, g => g.turn === 6 && g.phase === 1 && clean(g));
|
||||
const lands = game.spiceDeck.filter(c => 'territory' in c && !c.discovery).slice(0, 2);
|
||||
for (const [position, card] of lands.entries())
|
||||
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]);
|
||||
game = advance(game, g => g.turn === 6 && g.phase === 2 && clean(g));
|
||||
game = step(game, { actor: f.owner, action: { type: 'charity' } });
|
||||
game = atMovement(game, f.owner, 6);
|
||||
assert.equal(player(game, f.owner).reserves, 4);
|
||||
const p = player(game, f.owner), offer = viewGame(game, f.owner).nexusRicheseCunning; assert.ok(offer && !offer.blocked);
|
||||
const hidden = p.noField!.tokens.find(t => t.value === 5)!;
|
||||
const action: Action = { type: 'ship', territory: 'shrine', sector: 0, allyPayment: 0,
|
||||
noField: hidden.id, revealedToken: p.noField!.tokens.find(t => t.value === 0)!.id,
|
||||
event: p.noFieldEvent, nexus: offer.event };
|
||||
const before = structuredClone(game);
|
||||
game = settle(step(game, { actor: f.owner, action }));
|
||||
assert.equal(player(game, f.owner).forces['shrine:0'] ?? 0, 0);
|
||||
assert.equal(player(game, f.owner).reserves, 4);
|
||||
assert.equal(player(game, f.owner).noField!.deployed!.tokenId, hidden.id);
|
||||
assert.equal(game.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.equal(player(game, f.owner).spice, player(before, f.owner).spice - 1);
|
||||
assert.equal(player(game, f.guild).spice, player(before, f.guild).spice + 1);
|
||||
reject(game, f.owner, action);
|
||||
const shipped = structuredClone(game);
|
||||
game = step(JSON.parse(JSON.stringify(game)) as Game, { actor: f.owner,
|
||||
action: { type: 'revealNoField', token: hidden.id, event: player(game, f.owner).noFieldEvent } });
|
||||
assert.equal(player(game, f.owner).forces['shrine:0'], 4);
|
||||
assert.equal(player(game, f.owner).spice, player(shipped, f.owner).spice);
|
||||
assert.equal(player(game, f.guild).spice, player(shipped, f.guild).spice);
|
||||
assert.equal(player(game, f.owner).reserves, 0);
|
||||
assert.equal(player(game, f.owner).noField!.lastShipped, hidden.id);
|
||||
inventory(game);
|
||||
});
|
||||
@@ -0,0 +1,405 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame,
|
||||
type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import { strongholdControllers } from '../game/stronghold-cards';
|
||||
import { ecazOccupancyRelation } from '../game/ecaz-occupy';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
||||
import { nexusEcazDukeAction } from '../game/nexus-ecaz-duke-options';
|
||||
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
|
||||
import {
|
||||
advanceStandaloneHomeworldNexus as advance, assertStandaloneHomeworldNexusCustody as custody,
|
||||
buyStandaloneHomeworldMoritaniArmament as buyArmament, openStandaloneHomeworldGuildDeath as guildDeathBattle,
|
||||
createStandaloneHomeworldEcazCunning as ecazProgramme,
|
||||
createStandaloneHomeworldNexusClosing as closing, createStandaloneHomeworldNexusDiscovery as discovery,
|
||||
createStandaloneHomeworldNexusSetup as setup,
|
||||
nextStandaloneHomeworldNexusStep as next, openStandaloneHomeworldMoritaniDeath as deathBattle,
|
||||
orderStandaloneHomeworldNexusSpice as orderSpice, reloadStandaloneHomeworldNexus as reload,
|
||||
revealStandaloneHomeworldNexusPlans as reveal, settleStandaloneHomeworldNexus as settle,
|
||||
standaloneHomeworldNexusClean as clean, standaloneHomeworldNexusPlayer as player,
|
||||
standaloneHomeworldNexusPolicy as policy, standaloneHomeworldNexusPool as pool,
|
||||
stepStandaloneHomeworldNexus as step, type StandaloneHomeworldNexusStep,
|
||||
} from './fixture-homeworld-standalone-nexus';
|
||||
|
||||
function reject(g: Game, actor: string, action: Action, reason?: RegExp): void {
|
||||
const before = structuredClone(g);
|
||||
if (reason) assert.throws(() => applyAction(g, actor, action), reason);
|
||||
else assert.throws(() => applyAction(g, actor, action));
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
/** Every difficulty acts at a genuine window and demonstrates its physical
|
||||
* consumer transition. Applying an action without throwing is not the proof. */
|
||||
function legalPolicies(g: Game, actor: string, consume: (after: Game, action: Action) => void): void {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const offered = policy(g, actor, difficulty); assert.ok(offered[0], `${difficulty} needs an actual owned choice.`);
|
||||
const after = applyAction(reload(g), actor, offered[0]);
|
||||
consume(after, offered[0]); custody(after);
|
||||
}
|
||||
}
|
||||
function finishSettlement(g: Game, actions?: StandaloneHomeworldNexusStep[]): Game {
|
||||
for (let n = 0; n < 80; n++) {
|
||||
const boundary = settle(g, actions); g = boundary.game;
|
||||
if (boundary.kind === 'settled') return g;
|
||||
const d = g.decision; assert.ok(d);
|
||||
let action: Action;
|
||||
if (d.kind === 'battleLosses') action = { type: 'decision', choice: 0 };
|
||||
else if (d.kind === 'battleCards') action = { type: 'decision', discard: [] };
|
||||
else if (d.kind === 'sukRescue') action = { type: 'decision', event: d.event, choice: 0 };
|
||||
else if (d.kind === 'moritaniAssassinate') action = { type: 'decision', event: d.event, decline: true };
|
||||
else if (d.kind === 'techToken') {
|
||||
const target = d.choices[0];
|
||||
assert.equal(g.battle, null, 'Original battle disposal precedes mandatory winner Tech.');
|
||||
assert.ok(!g.pendingSukRescue && !g.pendingWinnerDiscards);
|
||||
for (const entry of g.lastBattleContext!.mandatoryDiscard?.entries ?? [])
|
||||
assert.ok(g.discard.some(c => c.id === entry.card), 'Played cards are physically cleaned before the reward.');
|
||||
reject(g, d.loser, { type: 'decision', token: target });
|
||||
reject(g, d.player, { type: 'decision', decline: true });
|
||||
legalPolicies(g, d.player, (after, selected) => {
|
||||
const token = selected.token as keyof NonNullable<Game['techTokens']>;
|
||||
assert.ok(d.choices.includes(token)); assert.equal(after.techTokens![token].owner, d.player);
|
||||
});
|
||||
action = { type: 'decision', token: target };
|
||||
} else throw Error(`Unexpected original settlement boundary ${d.kind}`);
|
||||
g = step(g, { actor: d.player, action }, actions);
|
||||
}
|
||||
throw Error('Original physical settlement did not finish.');
|
||||
}
|
||||
|
||||
void test('a genuine native closing deal and living unclaimed Ecaz Cunning coexist with real native Wallach custody, never Intrusion or Homeworld Occupy', () => {
|
||||
const f = ecazProgramme({ discovery: true });
|
||||
assert.equal(f.firstStorm.techTokens && ownedTech(f.firstStorm.techTokens, f.owner).length, 1);
|
||||
assert.deepEqual(f.firstMentat.after.strongholdCards!.owners, strongholdControllers(f.firstMentat.before.players, false));
|
||||
assert.equal(f.afterSetup.dukeVidal!.controller, null);
|
||||
assert.equal(f.afterSetup.dukeVidal!.leader.dead, false);
|
||||
assert.equal(player(f.alliance, f.owner).ally, null);
|
||||
assert.equal(f.draw.before.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.equal(f.draw.after.nexusCards!.cards!.hands[f.owner], 'ecaz');
|
||||
assert.ok(!f.draw.after.nexusCards!.cards!.deck.includes('ecaz'));
|
||||
legalPolicies(f.draw.before, f.owner, after => {
|
||||
assert.equal(after.nexusCards!.cards!.hands[f.owner], 'ecaz');
|
||||
assert.ok(!after.nexusCards!.cards!.deck.includes('ecaz'));
|
||||
});
|
||||
reject(f.draw.before, f.opponent, f.draw.step.action);
|
||||
reject(f.draw.after, f.owner, f.draw.step.action);
|
||||
|
||||
const shipper = player(f.arrival.before, f.owner), arrived = player(f.arrival.settled, f.owner);
|
||||
const sources = nativeShipmentSources(viewGame(f.arrival.before, f.owner), 1, 0); assert.ok(sources);
|
||||
const choice = homeworldShipmentChoice(viewGame(f.arrival.before, f.owner), f.location,
|
||||
Object.fromEntries(Object.entries(sources).filter(([, group]) => group.normal + group.elite > 0)));
|
||||
assert.equal(arrived.spice, shipper.spice - choice.cost);
|
||||
assert.equal(arrived.reserves, shipper.reserves - 1);
|
||||
assert.equal(pool(f.arrival.settled, f.owner, f.location).normal, 1);
|
||||
assert.deepEqual(pool(f.arrival.settled, f.opponent, f.location), pool(f.arrival.before, f.opponent, f.location));
|
||||
assert.ok(!f.arrival.settled.pendingAmbassador && !f.arrival.settled.pendingTerrorEntry);
|
||||
assert.notEqual(f.arrival.settled.decision?.kind, 'intrusion');
|
||||
assert.ok(!Object.keys(arrived.forces).some(key => key.startsWith('homeworld:')));
|
||||
assert.ok(!Object.keys(player(f.arrival.settled, f.opponent).advisors ?? {}).some(key => key.startsWith('homeworld:')));
|
||||
assert.equal(f.game.battle!.territory, f.location);
|
||||
custody(f.arrival.settled);
|
||||
|
||||
const action = nexusEcazDukeAction(viewGame(f.cunning.before, f.owner)); assert.ok(action);
|
||||
reject(f.cunning.before, f.opponent, action);
|
||||
reject(f.cunning.before, f.owner, { ...action, event: 'stale' });
|
||||
legalPolicies(f.cunning.before, f.owner, after => {
|
||||
assert.equal(after.dukeVidal!.controller, f.owner);
|
||||
assert.equal(after.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.equal(after.nexusCards!.cards!.discard.filter(c => c === 'ecaz').length, 1);
|
||||
});
|
||||
assert.equal(f.cunning.after.dukeVidal!.controller, f.owner);
|
||||
assert.equal(f.cunning.after.dukeVidal!.source, 'ecazNexus');
|
||||
assert.equal(f.cunning.after.dukeVidal!.leader.dead, false);
|
||||
assert.ok(!f.cunning.after.leaderSkills!.assignments.some(a => a.leader === DUKE_VIDAL_ID));
|
||||
assert.ok(player(f.cunning.after, f.owner).leaders.every(l => l.id !== DUKE_VIDAL_ID));
|
||||
reject(f.cunning.after, f.owner, action);
|
||||
reject(f.game, f.owner, action, /interaction|unavailable|current/i);
|
||||
|
||||
const revealed = reveal(f.game, f.plans, f.actions);
|
||||
assert.equal(player(revealed, f.opponent).spice, player(f.game, f.opponent).spice,
|
||||
'The real sealed plan does not debit dialed support at reveal.');
|
||||
const done = finishSettlement(revealed, f.actions);
|
||||
assert.equal(done.lastBattleContext!.winner, f.opponent);
|
||||
assert.equal(player(done, f.opponent).spice, player(revealed, f.opponent).spice - 3,
|
||||
'Free native strength never pays for three dialed support.');
|
||||
assert.equal(player(done, f.opponent).tanks, player(revealed, f.opponent).tanks + 3);
|
||||
assert.equal(pool(done, f.opponent, f.location).normal, pool(revealed, f.opponent, f.location).normal - 3);
|
||||
assert.equal(player(done, f.owner).tanks, player(revealed, f.owner).tanks + 1);
|
||||
assert.equal(pool(done, f.owner, f.location).normal, 0);
|
||||
assert.equal(done.dukeVidal!.leader.dead, false);
|
||||
assert.equal(done.leaderSkills!.assignments.find(a => a.owner === f.owner)!.leader, f.trainer);
|
||||
const token = ownedTech(revealed.techTokens, f.owner)[0]; assert.ok(token);
|
||||
assert.equal(done.techTokens![token].owner, f.opponent);
|
||||
custody(done);
|
||||
|
||||
// The genuine next Nexus changes the alliance, not the physical identity of
|
||||
// either counter pool. Paid Arrakis shipments may now occupy one territory;
|
||||
// those same seats still cannot use allied native-home shipment as Occupy.
|
||||
let g = advance(done, s => s.turn === 3 && s.phase === 1 && clean(s), f.actions);
|
||||
assert.equal(g.dukeVidal!.controller, null);
|
||||
orderSpice(g, true);
|
||||
g = advance(g, s => s.nexus && !s.spiceWindow && !s.spiceResolution && clean(s), f.actions);
|
||||
const guild = g.players.find(p => p.faction === 'guild')!.id;
|
||||
g = step(g, { actor: guild, action: { type: 'alliance', target: null } }, f.actions);
|
||||
for (const [actor, target] of [[f.owner, guild], [guild, f.owner]])
|
||||
g = step(g, { actor, action: { type: 'alliance', target } }, f.actions);
|
||||
assert.equal(ecazOccupancyRelation(g.players, f.owner, guild, { kind: 'territory', id: 'polar_sink' }), 'ecazAlliance');
|
||||
assert.equal(ecazOccupancyRelation(g.players, f.owner, guild, { kind: 'homeworld', id: 'homeworld:guild' }), 'different');
|
||||
const shipped: Record<string, boolean> = {};
|
||||
for (let n = 0; n < 240; n++) {
|
||||
if (shipped[f.owner] && shipped[guild] && g.phase >= 6 && clean(g)) break;
|
||||
if (g.phase === 5 && clean(g) && g.active && !shipped[g.active] && [f.owner, guild].includes(g.active)) {
|
||||
const actor = g.active, view = viewGame(g, actor), own = player(g, actor);
|
||||
const reserves = nativeShipmentSources(view, 1, 0); assert.ok(reserves);
|
||||
if (actor === f.owner) {
|
||||
const alliedHome = homeworldShipmentChoice(view, 'homeworld:guild', reserves);
|
||||
assert.equal(alliedHome.action, null);
|
||||
assert.ok(alliedHome.blocked);
|
||||
}
|
||||
const before = reload(g);
|
||||
g = step(g, { actor, action: { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1,
|
||||
elite: 0, allyPayment: 0, homeworldSources: reserves } }, f.actions);
|
||||
g = advance(g, s => !s.pendingShipment && clean(s), f.actions);
|
||||
assert.equal(player(g, actor).reserves, own.reserves - 1);
|
||||
assert.equal(player(g, actor).forces['polar_sink:0'], (player(before, actor).forces['polar_sink:0'] ?? 0) + 1);
|
||||
assert.ok(player(g, actor).spice < own.spice);
|
||||
shipped[actor] = true;
|
||||
} else {
|
||||
const owned = next(g); assert.ok(owned); g = step(g, owned, f.actions);
|
||||
}
|
||||
}
|
||||
assert.ok(shipped[f.owner] && shipped[guild]);
|
||||
assert.equal(player(g, f.owner).forces['polar_sink:0'], 1);
|
||||
assert.equal(player(g, guild).forces['polar_sink:0'], 1);
|
||||
assert.equal(g.battle, null, 'Original allied Arrakis Occupy is not a battle between those two seats.');
|
||||
custody(g);
|
||||
});
|
||||
|
||||
void test('original Moritani private rescue → assassination → exact death Skill return → card cleanup/Tech → private replacement precedes five actual own deaths and paid own revival', () => {
|
||||
const f = closing({ faction: 'moritani', advanced: true, discovery: true, tech: true, strongholds: true });
|
||||
assert.equal(f.draw.after.nexusCards!.cards!.hands[f.owner], 'moritani');
|
||||
const target = f.afterSetup.leaderSkills!.assignments.find(a => a.owner === f.opponent)!.leader;
|
||||
assert.equal(player(f.afterSetup, f.opponent).leaders.find(l => l.id === target)!.name, 'Master Bewt');
|
||||
assert.ok(player(f.afterSetup, f.owner).traitors.includes(target));
|
||||
const firstDisc = player(f.afterSetup, f.owner).leaders.find(l => l.id !== f.trainer)!.id;
|
||||
const discs = [firstDisc, ...player(f.afterSetup, f.opponent).leaders.map(l => l.id).filter(id => id !== target)];
|
||||
const purchased = buyArmament(f, f.game);
|
||||
assert.equal(player(purchased.game, f.owner).spice, player(purchased.before, f.owner).spice - purchased.bought.length);
|
||||
assert.ok(player(purchased.game, f.owner).hand.some(c => c.id === purchased.weapon));
|
||||
assert.ok(player(purchased.game, f.owner).hand.some(c => c.id === purchased.defense));
|
||||
let g = purchased.game;
|
||||
for (const [index, disc] of discs.entries()) {
|
||||
const battle = index === 0 ? deathBattle(f, g, disc, true)
|
||||
: guildDeathBattle(f, g, disc, purchased.weapon, purchased.defense);
|
||||
const visitor = index === 0 ? f.owner : f.opponent;
|
||||
assert.equal(player(battle.settledArrival, visitor).reserves, player(battle.arrival.before, visitor).reserves - 1);
|
||||
assert.equal(pool(battle.settledArrival, visitor, battle.location).normal, 1);
|
||||
assert.ok(player(battle.settledArrival, visitor).spice < player(battle.arrival.before, visitor).spice);
|
||||
const revealed = reveal(battle.game, battle.plans, f.actions);
|
||||
assert.equal(player(revealed, f.opponent).spice, player(battle.game, f.opponent).spice);
|
||||
g = revealed;
|
||||
if (index === 0) {
|
||||
let boundary = settle(g, f.actions);
|
||||
for (let n = 0; boundary.kind === 'losses' || boundary.kind === 'rescue'; n++) {
|
||||
assert.ok(n < 12);
|
||||
const d = boundary.game.decision; assert.ok(d);
|
||||
g = step(boundary.game, { actor: d.player, action: { type: 'decision', ...(d.kind === 'sukRescue' ? { event: d.event } : {}), choice: 0 } }, f.actions);
|
||||
boundary = settle(g, f.actions);
|
||||
}
|
||||
assert.equal(boundary.kind, 'assassination'); g = boundary.game;
|
||||
assert.equal(g.lastBattleContext!.sukRescue!.completed, true);
|
||||
assert.deepEqual(g.lastBattleContext!.sukRescue!.physical!.saved, { normal: 1, elite: 0 });
|
||||
assert.equal(player(g, f.opponent).tanks, player(revealed, f.opponent).tanks);
|
||||
assert.equal(pool(g, f.opponent, 'homeworld:guild').normal, pool(revealed, f.opponent, 'homeworld:guild').normal,
|
||||
'Native rescue saves its real dialed casualty back at the SAME home.');
|
||||
assert.equal(player(g, f.opponent).spice, player(revealed, f.opponent).spice - 1 +
|
||||
player(revealed, f.owner).leaders.find(l => l.id === disc)!.strength,
|
||||
'Ordinary paid support and the killed opposing-disc reward both settle before assassination.');
|
||||
assert.equal(player(g, f.owner).spice, player(revealed, f.owner).spice,
|
||||
'A native-home loss pays no Arrakis Worthless income; the separate assassination bounty is still pending.');
|
||||
assert.equal(player(g, f.owner).leaders.find(l => l.id === disc)!.dead, true);
|
||||
assert.equal(player(g, f.opponent).leaders.find(l => l.id === target)!.dead, false);
|
||||
assert.notEqual(revealed.battle!.plans[f.opponent].leader, target);
|
||||
const d = g.decision; assert.ok(d?.kind === 'moritaniAssassinate');
|
||||
const bounty = player(g, f.opponent).leaders.find(l => l.id === target)!.strength;
|
||||
const reserve = [...g.traitorReserve!], held = [...player(g, f.owner).traitors], before = reload(g);
|
||||
const pendingToken = ownedTech(g.techTokens, f.owner)[0]; assert.ok(pendingToken);
|
||||
assert.equal(g.techTokens![pendingToken].owner, f.owner, 'Winner reward has not bypassed the private aftermath.');
|
||||
const action: Action = { type: 'decision', event: d.event, card: target };
|
||||
reject(g, f.opponent, action);
|
||||
reject(g, f.owner, { ...action, event: 'stale' });
|
||||
reject(g, f.owner, { ...action, card: revealed.battle!.plans[f.opponent].leader });
|
||||
legalPolicies(g, f.owner, after => {
|
||||
assert.equal(player(after, f.opponent).leaders.find(l => l.id === target)!.dead, true);
|
||||
assert.equal(player(after, f.owner).spice, player(before, f.owner).spice + bounty);
|
||||
assert.equal(after.leaderSkills!.assignments.some(a => a.leader === target), false);
|
||||
assert.equal(after.leaderSkills!.deck.filter(s => s === 'suk-graduate').length, 1);
|
||||
});
|
||||
g = step(g, { actor: f.owner, action }, f.actions);
|
||||
assert.equal(player(g, f.opponent).leaders.find(l => l.id === target)!.deaths,
|
||||
player(before, f.opponent).leaders.find(l => l.id === target)!.deaths + 1);
|
||||
assert.deepEqual(g.homeworlds!.custody, before.homeworlds!.custody);
|
||||
assert.equal(player(g, f.opponent).tanks, player(before, f.opponent).tanks);
|
||||
assert.equal(g.leaderSkills!.assignments.some(a => a.leader === target), false);
|
||||
assert.equal(g.leaderSkills!.deck.filter(s => s === 'suk-graduate').length, 1);
|
||||
g = finishSettlement(g, f.actions);
|
||||
const worthless = battle.plans[0].action.weapon as string; assert.ok(worthless);
|
||||
assert.ok(g.discard.some(c => c.id === worthless));
|
||||
assert.ok(!player(g, f.owner).hand.some(c => c.id === worthless));
|
||||
const token = ownedTech(before.techTokens, f.owner)[0]; assert.ok(token);
|
||||
assert.equal(g.techTokens![token].owner, f.opponent);
|
||||
assert.equal(player(g, f.owner).spice, player(before, f.owner).spice + bounty);
|
||||
g = advance(g, s => s.phase === 8, f.actions);
|
||||
const replacement = g.moritaniAssassinate!.opportunities.at(-1)!;
|
||||
assert.equal(replacement.stage, 'replaced'); assert.equal(replacement.replacement, reserve[0]);
|
||||
assert.deepEqual(g.traitorReserve, reserve.slice(1));
|
||||
assert.deepEqual(player(g, f.owner).traitors, [...held.filter(c => c !== target), reserve[0]]);
|
||||
assert.equal('replacement' in viewGame(g, f.opponent).moritaniAssassinate!.history.at(-1)!, false);
|
||||
} else g = finishSettlement(g, f.actions);
|
||||
const victim = index === 0 ? f.owner : f.opponent, winner = index === 0 ? f.opponent : f.owner;
|
||||
assert.equal(g.lastBattleContext!.winner, winner);
|
||||
assert.equal(player(g, f.opponent).leaders.filter(l => l.dead).length, index + 1);
|
||||
assert.equal(player(g, victim).leaders.find(l => l.id === disc)!.deaths,
|
||||
player(battle.game, victim).leaders.find(l => l.id === disc)!.deaths + 1);
|
||||
assert.equal(player(g, victim).tanks, player(revealed, victim).tanks + 1);
|
||||
assert.equal(pool(g, victim, battle.location).normal, 0);
|
||||
if (index > 0) {
|
||||
assert.equal(player(g, f.owner).leaders.filter(l => l.dead).length, 1,
|
||||
'The actual native winner stays alive; auctioned Shield stops the original Guild projectile before its loser cleanup.');
|
||||
assert.equal(player(g, f.owner).tanks, player(revealed, f.owner).tanks + 1);
|
||||
assert.equal(pool(g, f.owner, battle.location).normal, pool(revealed, f.owner, battle.location).normal - 1);
|
||||
assert.equal(player(g, f.owner).spice, player(revealed, f.owner).spice - 1 +
|
||||
player(revealed, f.opponent).leaders.find(l => l.id === disc)!.strength,
|
||||
'The actual native winner pays support and receives the ordinary opposing-leader death reward once.');
|
||||
for (const card of [purchased.weapon, purchased.defense]) {
|
||||
assert.ok(player(g, f.owner).hand.some(c => c.id === card));
|
||||
assert.ok(!g.discard.some(c => c.id === card));
|
||||
}
|
||||
if (battle.plans[1].action.weapon) {
|
||||
assert.ok(g.discard.some(c => c.id === battle.plans[1].action.weapon));
|
||||
assert.ok(!player(g, f.opponent).hand.some(c => c.id === battle.plans[1].action.weapon));
|
||||
}
|
||||
} else {
|
||||
const weapon = battle.plans[1].action.weapon as string;
|
||||
assert.equal(player(g, f.opponent).hand.filter(c => c.id === weapon).length, 1);
|
||||
assert.ok(!g.discard.some(c => c.id === weapon));
|
||||
}
|
||||
custody(g);
|
||||
const turn = g.turn;
|
||||
g = advance(g, s => s.turn === turn + 1 && s.phase === 1 && clean(s), f.actions);
|
||||
orderSpice(g);
|
||||
g = advance(g, s => s.phase === 4 && clean(s), f.actions);
|
||||
if (index < discs.length - 1) reject(g, f.opponent, { type: 'reviveLeader', leader: target }, /All leaders must die/);
|
||||
}
|
||||
assert.equal(player(g, f.opponent).leaders.filter(l => l.dead).length, 5);
|
||||
assert.equal(g.leaderSkills!.assignments.some(a => a.owner === f.opponent), false);
|
||||
assert.equal(g.leaderSkills!.deck.filter(s => s === 'suk-graduate').length, 1);
|
||||
assert.equal(g.leaderSkills!.assignments.find(a => a.owner === f.owner)!.leader, f.trainer);
|
||||
const before = reload(g), dead = player(g, f.opponent).leaders.find(l => l.id === target)!;
|
||||
reject(g, f.owner, { type: 'reviveLeader', leader: target });
|
||||
g = step(g, { actor: f.opponent, action: { type: 'reviveLeader', leader: target } }, f.actions);
|
||||
g = advance(g, s => !!s.leaderSkills!.offers[f.opponent], f.actions);
|
||||
assert.equal(player(g, f.opponent).spice, player(before, f.opponent).spice - dead.strength);
|
||||
assert.equal(player(g, f.opponent).leaders.find(l => l.id === target)!.dead, false);
|
||||
assert.equal(player(g, f.opponent).leaders.filter(l => l.dead).length, 4);
|
||||
assert.equal(viewGame(g, f.owner).leaderSkills!.offer, null);
|
||||
assert.equal(viewGame(g, f.opponent).leaderSkills!.offer!.cards.length, 0);
|
||||
const event = viewGame(g, f.opponent).leaderSkills!.offer!.event;
|
||||
legalPolicies(g, f.opponent, after => {
|
||||
assert.equal(viewGame(after, f.opponent).leaderSkills!.offer!.cards.length, 2);
|
||||
assert.equal(player(after, f.opponent).spice, player(g, f.opponent).spice);
|
||||
});
|
||||
g = step(g, { actor: f.opponent, action: { type: 'leaderSkill', event, mode: 'draw' } }, f.actions);
|
||||
const offer = viewGame(g, f.opponent).leaderSkills!, skill = offer.offer!.cards.find(s => !offer.unavailableSkills?.[s]); assert.ok(skill);
|
||||
reject(g, f.owner, { type: 'leaderSkill', event, skill, leader: target });
|
||||
reject(g, f.opponent, { type: 'leaderSkill', event, skill, leader: DUKE_VIDAL_ID });
|
||||
g = step(g, { actor: f.opponent, action: { type: 'leaderSkill', event, skill, leader: target } }, f.actions);
|
||||
assert.equal(g.leaderSkills!.assignments.find(a => a.owner === f.opponent)!.leader, target);
|
||||
assert.equal(player(g, f.opponent).spice, player(before, f.opponent).spice - dead.strength);
|
||||
assert.equal(g.nexusCards!.cards!.hands[f.owner], 'moritani'); custody(g);
|
||||
});
|
||||
|
||||
void test('Basic native Moritani loss keeps normal same-home Suk but never gains Advanced private assassination', () => {
|
||||
const f = closing({ faction: 'moritani', advanced: false, discovery: false, tech: true });
|
||||
const disc = player(f.afterSetup, f.owner).leaders.find(l => l.id !== f.trainer)!.id;
|
||||
const battle = deathBattle(f, f.game, disc, true), revealed = reveal(battle.game, battle.plans, f.actions);
|
||||
const done = finishSettlement(revealed, f.actions);
|
||||
assert.equal(done.lastBattleContext!.winner, f.opponent);
|
||||
assert.equal(done.lastBattleContext!.sukRescue!.completed, true);
|
||||
assert.deepEqual(done.lastBattleContext!.sukRescue!.physical!.saved, { normal: 1, elite: 0 });
|
||||
assert.equal(pool(done, f.opponent, 'homeworld:guild').normal, pool(revealed, f.opponent, 'homeworld:guild').normal);
|
||||
const target = done.leaderSkills!.assignments.find(a => a.owner === f.opponent)!.leader;
|
||||
assert.equal(player(done, f.opponent).leaders.find(l => l.id === target)!.dead, false);
|
||||
assert.equal(done.leaderSkills!.assignments.find(a => a.leader === target)!.skill, 'suk-graduate');
|
||||
custody(done);
|
||||
});
|
||||
|
||||
for (const faction of ['ecaz', 'moritani'] as const) void test(`${faction} original Discovery shipment/reveal/free nested entry keeps all native reserve identity while all12 Nexus stay undealt`, () => {
|
||||
const f = discovery({ faction, advanced: false, roster: [faction, 'guild', 'emperor', 'beneGesserit'], tech: true });
|
||||
assert.equal(player(f.afterShipment, f.owner).reserves, player(f.beforeShipment, f.owner).reserves - 2);
|
||||
assert.ok(player(f.afterShipment, f.owner).spice < player(f.beforeShipment, f.owner).spice);
|
||||
assert.equal(pool(f.afterShipment, f.owner, `homeworld:${faction}`).normal, player(f.afterShipment, f.owner).reserves);
|
||||
assert.equal(f.revealed.discoveries!.tokens.find(t => t.id === f.token)!.revealedTurn, 1);
|
||||
assert.equal(player(f.entry.before, f.owner).forces['gara_kulon:8'], 2);
|
||||
assert.equal(player(f.entry.after, f.owner).forces['gara_kulon:8'] ?? 0, 0);
|
||||
assert.equal(player(f.entry.after, f.owner).forces['cistern:0'], 2);
|
||||
assert.equal(player(f.entry.after, f.owner).spice, player(f.entry.before, f.owner).spice);
|
||||
assert.equal(player(f.entry.after, f.owner).reserves, player(f.entry.before, f.owner).reserves);
|
||||
assert.deepEqual(f.entry.after.homeworlds!.custody, f.entry.before.homeworlds!.custody);
|
||||
assert.ok(Object.values(f.entry.after.nexusCards!.cards!.hands).every(c => c === null));
|
||||
const action = discoveryEntryMoveAction(viewGame(f.entry.before, f.owner), viewGame(f.entry.before, f.owner).discoveryEntry!.sources); assert.ok(action);
|
||||
reject(f.entry.before, f.opponent, action);
|
||||
reject(f.entry.after, f.owner, action);
|
||||
legalPolicies(f.entry.before, f.owner, (after, offered) => {
|
||||
assert.equal(player(after, f.owner).spice, player(f.entry.before, f.owner).spice);
|
||||
assert.equal(player(after, f.owner).reserves, player(f.entry.before, f.owner).reserves);
|
||||
if (offered.groups) assert.equal(player(after, f.owner).forces['cistern:0'], 2);
|
||||
else assert.equal(player(after, f.owner).forces['gara_kulon:8'], 2);
|
||||
assert.notEqual(after.decision?.kind, 'discoveryEntry');
|
||||
});
|
||||
custody(f.entry.after);
|
||||
});
|
||||
|
||||
void test('original supplied offers keep human choices while Duke training, Advanced Harkonnen and paired E3 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 });
|
||||
const trained = f.afterSetup.leaderSkills!.assignments.find(a => a.owner === f.owner)!;
|
||||
const own = viewGame(f.offered, f.owner).leaderSkills!;
|
||||
assert.ok(own.offer!.cards.includes(trained.skill));
|
||||
assert.ok(own.eligibleLeaders.some(l => l.id === trained.leader));
|
||||
reject(f.offered, f.owner, { type: 'leaderSkill', event: own.offer!.event, skill: trained.skill, leader: DUKE_VIDAL_ID });
|
||||
const unsupported = own.offer!.cards.includes('mentat') ? 'warmaster' : 'mentat';
|
||||
if (!own.offer!.cards.includes(unsupported)) reject(f.offered, f.owner,
|
||||
{ type: 'leaderSkill', event: own.offer!.event, skill: unsupported, leader: trained.leader });
|
||||
legalPolicies(f.offered, f.owner, after => {
|
||||
const assignment = after.leaderSkills!.assignments.find(a => a.owner === f.owner); assert.ok(assignment);
|
||||
assert.ok(own.offer!.cards.includes(assignment.skill));
|
||||
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));
|
||||
g = applyAction(g, g.host, { type: 'homeworlds', enabled: true });
|
||||
g.nexusCards = { cards: null, phase: null };
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => initializeLeaderSkillsGameForAudit(g)); assert.deepEqual(g, before);
|
||||
assert.equal(g.homeworlds!.custody, null);
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { MOBILE_STRONGHOLD, territory } from '../game/board';
|
||||
import { quoteSpiceCollection } from '../game/board-resolution-quote';
|
||||
@@ -267,18 +267,3 @@ void test('representative native bot Face Dance consumes only actual paired rese
|
||||
custody(after, f.afterSetup);
|
||||
});
|
||||
|
||||
void test('original authenticated lobby and undealt CLI setup continue to actor-bound real Nexus Cunning without redealing', () => {
|
||||
const lobby = createGame('PAIREDIXAUTH', newPlayer('authenticated-ix', 'Ix', 'ixians'), true, ['ix']);
|
||||
for (const [id, faction] of [['authenticated-tl', 'tleilaxu'], ['authenticated-guild', 'guild'], ['authenticated-emperor', 'emperor']] as const)
|
||||
joinGame(lobby, newPlayer(id, faction, faction));
|
||||
const first = createPairedIxNexusModulesFixture({ initial: lobby, tech: true, strongholds: false });
|
||||
const continued = createPairedIxNexusModulesFixture({ initial: first.initial });
|
||||
assert.deepEqual(player(continued.afterSetup, first.ixians).hand, player(first.afterSetup, first.ixians).hand,
|
||||
'The original Ix setup offer and actual first deal are continued, not replaced');
|
||||
const empowered = useSuboids(continued);
|
||||
const revealed = revealPairedIxNexusModulesBattle(empowered, continued.planActions);
|
||||
assert.equal(quoteStrongholdFactionsBattle(revealed).winner, 'authenticated-ix');
|
||||
assert.equal(revealed.nexusSuboidHistory![0].owner, 'authenticated-ix');
|
||||
assert.equal(player(revealed, 'authenticated-tl').ally, null);
|
||||
custody(revealed, continued.afterSetup);
|
||||
});
|
||||
Reference in new issue
Block a user