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