Compose classic Nexus Skills with original Tech and Strongholds
This commit is contained in:
1 parent
03aedc14d7
commit
449e27b415
17 files changed
+1764
-23
No files matched your search
@@ -153,7 +153,7 @@ export function initializeClassicNexusSkillsPaymentsSetup(options: ClassicNexusS
|
||||
assert.ok(game.players.some(p => p.faction === faction));
|
||||
if (game.status === 'setup') {
|
||||
assert.ok(classicNexusLeaderSkillsProfile(game));
|
||||
assert.equal(game.setupStage, 'leaderSkills');
|
||||
assert.ok(game.setupStage === 'prediction' || game.setupStage === 'leaderSkills');
|
||||
assert.equal(game.turn, 1); assert.equal(game.phase, 0);
|
||||
assert.ok(game.players.every(p => !p.traitors.length && !p.traitorChoices.length));
|
||||
assert.equal(game.leaderSkills!.assignments.length, 0);
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, handLimit, initializeLeaderSkillsGameForAudit,
|
||||
joinGame, newPlayer, viewGame, type Action, type Game, type Player,
|
||||
} from '../game/engine';
|
||||
import { territory } from '../game/board';
|
||||
import type { Card } from '../game/cards';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
|
||||
import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote';
|
||||
import { createTechTokens, ownedTech } from '../game/tech-tokens';
|
||||
import { createStrongholdCards, type StrongholdId } from '../game/stronghold-cards';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
|
||||
export type NexusSkillsModulesBattleStep = { actor: string; action: Action };
|
||||
export type NexusSkillsModulesBattleOptions = {
|
||||
/** Original undealt authenticated CLI setup, or fresh classic lobby. Never redealt. */
|
||||
initial?: Game;
|
||||
advanced?: boolean;
|
||||
seatIds?: string[];
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
kind?: Exclude<StrongholdId, 'hidden_mobile_stronghold'>;
|
||||
ownerElites?: number;
|
||||
/** Earn a second token through a real first-turn battle, never assign ownership. */
|
||||
opponentTokens?: 'one' | 'several';
|
||||
opponentNexus?: 'moritani';
|
||||
skill?: LeaderSkillId;
|
||||
band?: 'normal' | 'skilled';
|
||||
ownerDial?: number;
|
||||
support?: number;
|
||||
opponentDial?: number;
|
||||
opponentSupport?: number;
|
||||
ownerWeapon?: Card['kind'];
|
||||
ownerDefense?: Card['kind'];
|
||||
opponentWeapon?: Card['kind'];
|
||||
opponentDefense?: Card['kind'];
|
||||
traitors?: 'owner' | 'opponent' | 'both';
|
||||
counter?: boolean;
|
||||
secondBattle?: boolean;
|
||||
};
|
||||
export type NexusSkillsModulesBattleFixture = {
|
||||
initial: Game;
|
||||
afterSetup: Game;
|
||||
beforeFirstStorm: Game;
|
||||
firstStormStep: NexusSkillsModulesBattleStep;
|
||||
afterFirstStorm: Game;
|
||||
warmupBeforeBattle?: Game;
|
||||
warmupPlanActions?: NexusSkillsModulesBattleStep[];
|
||||
warmupAfterBattle?: Game;
|
||||
beforeFirstMentat: Game;
|
||||
firstMentatStep: NexusSkillsModulesBattleStep;
|
||||
afterFirstMentat: Game;
|
||||
beforeAlliance: Game;
|
||||
allianceActions: NexusSkillsModulesBattleStep[];
|
||||
beforeDraw: Game;
|
||||
drawAction: NexusSkillsModulesBattleStep;
|
||||
afterDraw: Game;
|
||||
beforeBattle: Game;
|
||||
battleAction: NexusSkillsModulesBattleStep;
|
||||
game: Game;
|
||||
owner: string;
|
||||
opponent: string;
|
||||
observer: string;
|
||||
partner: string;
|
||||
trainer: string;
|
||||
skill: LeaderSkillId;
|
||||
band: 'normal' | 'skilled';
|
||||
kind: Exclude<StrongholdId, 'hidden_mobile_stronghold'>;
|
||||
key: string;
|
||||
secondKey?: string;
|
||||
counter?: string;
|
||||
planActions: NexusSkillsModulesBattleStep[];
|
||||
actions: NexusSkillsModulesBattleStep[];
|
||||
staging: string[];
|
||||
};
|
||||
export function nexusSkillsModulesBattlePlayer(game: Game, actor: string): Player {
|
||||
const player = game.players.find(seat => seat.id === actor);
|
||||
assert.ok(player, `Missing original classic seat ${actor}`);
|
||||
return player;
|
||||
}
|
||||
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
|
||||
|
||||
/** Scoped original shuffle/action entropy; generic non-word requests retain native forwarding. */
|
||||
function entropy<T>(operation: () => T, rolls: number[] = []): 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) {
|
||||
for (let index = 0; index < array.length; index++) array[index] = rolls[cursor++] ?? 0xffffffff;
|
||||
} else Reflect.apply(native, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return operation(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
function offerRolls(selected: LeaderSkillId[]): number[] {
|
||||
const working = LEADER_SKILL_CARDS.map(card => card.id);
|
||||
const remainder = working.filter(skill => !selected.includes(skill));
|
||||
const desired = selected.flatMap(skill => [skill, remainder.shift()!]);
|
||||
desired.push(...remainder);
|
||||
const rolls: number[] = [];
|
||||
for (let index = working.length - 1; index > 0; index--) {
|
||||
const from = working.indexOf(desired[index]);
|
||||
assert.ok(from >= 0 && from <= index);
|
||||
rolls.push(Math.floor((from + 0.5) * 0x100000000 / (index + 1)));
|
||||
[working[index], working[from]] = [working[from], working[index]];
|
||||
}
|
||||
return rolls;
|
||||
}
|
||||
export function nextNexusSkillsModulesBattleStep(game: Game): NexusSkillsModulesBattleStep | null {
|
||||
if (clean(game) && game.nexusCards?.phase?.stage === 'drawing') {
|
||||
const phase = game.nexusCards.phase;
|
||||
const actor = phase.eligible.find(id => !phase.done.includes(id));
|
||||
if (actor) return { actor, action: { type: 'nexusCardChoice', turn: game.turn,
|
||||
card: game.nexusCards.cards!.hands[actor], choice: 'keep', ownRedraws: 0 } };
|
||||
}
|
||||
if (!game.phaseOpening && !game.response) {
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'leaderSkillVisibility')
|
||||
return { actor: decision.player, action: { type: 'leaderSkillVisibility', event: decision.event, hide: false } };
|
||||
if (decision?.kind === 'mentatQuestion')
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } };
|
||||
if (decision?.kind === 'wormRide')
|
||||
return { actor: decision.player, action: { type: 'decision', accept: false } };
|
||||
if (decision?.kind === 'nexusFremenCunningOffer' || decision?.kind === 'nexusFremenCunningRide')
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event, accept: false } };
|
||||
if (decision?.kind === 'nexusChoamInspection')
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event, inspect: false } };
|
||||
if (decision?.kind === 'moritaniRetention' && decision.source === 'nexus')
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event, keep: null } };
|
||||
if (decision?.kind === 'techToken')
|
||||
return { actor: decision.player, action: { type: 'decision', token: decision.choices[0] } };
|
||||
if (decision?.kind === 'faceDance')
|
||||
return { actor: decision.player, action: { type: 'decision', accept: false } };
|
||||
if (decision?.kind === 'sukRescue') {
|
||||
const choice = decision.options.map((option, index) => ({ index, saved: option.normal + option.elite, kept: !!option.kept }))
|
||||
.sort((a, b) => b.saved - a.saved || Number(b.kept) - Number(a.kept))[0].index;
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event, choice } };
|
||||
}
|
||||
}
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
export function stepNexusSkillsModulesBattle(game: Game, next = nextNexusSkillsModulesBattleStep(game)): Game {
|
||||
assert.ok(next, 'Original private plan requires a human action');
|
||||
return entropy(() => applyAction(game, next.actor, next.action));
|
||||
}
|
||||
export function advanceNexusSkillsModulesBattle(game: Game, until: (state: Game) => boolean,
|
||||
actions: NexusSkillsModulesBattleStep[] = []): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const next = nextNexusSkillsModulesBattleStep(game); assert.ok(next, 'Human plan boundary before requested state');
|
||||
actions.push(structuredClone(next));
|
||||
game = stepNexusSkillsModulesBattle(game, next);
|
||||
}
|
||||
throw Error('Original Skills/Nexus continuation did not reach its boundary');
|
||||
}
|
||||
/** Reorder undealt singleton Spice Cards only. Discards keep their original history. */
|
||||
function orderSpice(game: Game, kinds: ('land' | 'worm')[], staging: string[]): void {
|
||||
const remainder = [...game.spiceDeck];
|
||||
const front = kinds.map(kind => {
|
||||
const index = remainder.findIndex(card => kind === 'land' ? 'territory' in card : 'worm' in card);
|
||||
assert.ok(index >= 0, `Original undealt ${kind} must still exist`);
|
||||
return remainder.splice(index, 1)[0];
|
||||
});
|
||||
game.spiceDeck = [...front, ...remainder];
|
||||
staging.push(`Controlled conserved original undealt Spice order: ${kinds.join(', ')}; not natural shuffle history`);
|
||||
}
|
||||
function reserveBoard(game: Game): void {
|
||||
for (const seat of game.players) {
|
||||
seat.reserves += Object.values(seat.forces).reduce((sum, count) => sum + count, 0);
|
||||
seat.forces = {};
|
||||
if (seat.elites) {
|
||||
seat.elites.reserves += Object.values(seat.elites.forces).reduce((sum, count) => sum + count, 0);
|
||||
seat.elites.forces = {};
|
||||
}
|
||||
if (seat.advisors) seat.advisors = {};
|
||||
}
|
||||
}
|
||||
function place(game: Game, actor: string, key: string, count: number): void {
|
||||
const seat = nexusSkillsModulesBattlePlayer(game, actor);
|
||||
assert.ok(seat.reserves - (seat.elites?.reserves ?? 0) >= count);
|
||||
seat.reserves -= count;
|
||||
seat.forces[key] = (seat.forces[key] ?? 0) + count;
|
||||
}
|
||||
function hold(game: Game, actor: string, matches: (card: Card) => boolean, staging: string[], used: string[]): string {
|
||||
const seat = nexusSkillsModulesBattlePlayer(game, actor);
|
||||
let card = seat.hand.find(card => matches(card) && !used.includes(card.id));
|
||||
if (!card) {
|
||||
const from = game.deck.findIndex(card => matches(card) && !used.includes(card.id));
|
||||
const donor = from < 0 ? game.players.find(seat => seat.hand.some(card => matches(card) && !used.includes(card.id))) : undefined;
|
||||
assert.ok(from >= 0 || donor, 'Required original card must remain in deck/hand custody');
|
||||
assert.ok(seat.hand.length < handLimit(seat));
|
||||
card = from >= 0 ? game.deck.splice(from, 1)[0]
|
||||
: donor!.hand.splice(donor!.hand.findIndex(card => matches(card) && !used.includes(card.id)), 1)[0];
|
||||
seat.hand.push(card);
|
||||
staging.push(`Conserved physical ${card.id}: ${donor?.id ?? 'original deck'} → ${actor} hand`);
|
||||
}
|
||||
used.push(card.id);
|
||||
return card.id;
|
||||
}
|
||||
function traitor(game: Game, actor: string, identity: string, staging: string[]): void {
|
||||
const seat = nexusSkillsModulesBattlePlayer(game, actor);
|
||||
if (seat.traitors.includes(identity)) return;
|
||||
const returned = seat.traitors[0];
|
||||
const from = game.traitorReserve?.indexOf(identity) ?? -1;
|
||||
const donor = game.players.find(seat => seat.traitors.includes(identity));
|
||||
assert.ok(returned && (from >= 0 || donor));
|
||||
if (from >= 0) game.traitorReserve!.splice(from, 1, returned);
|
||||
else donor!.traitors[donor!.traitors.indexOf(identity)] = returned;
|
||||
seat.traitors[0] = identity;
|
||||
staging.push(`Conserved original traitor ${identity}: ${donor?.id ?? 'reserve'} exchanged for ${actor}'s ${returned}`);
|
||||
}
|
||||
|
||||
/** Genuine original all14/base33/all12 setup → Storm → END Mentat → worm →
|
||||
* reciprocal alliance → closing Emperor draw → movement → human battle plans.
|
||||
* Only labeled conserved physical sources are relocated; no wallets, phases,
|
||||
* turn, skill effects, module owners, earned receipts or outcomes are assigned. */
|
||||
export function createNexusSkillsModulesBattleFixture(options: NexusSkillsModulesBattleOptions = {}): NexusSkillsModulesBattleFixture {
|
||||
const advanced = options.advanced ?? options.initial?.advanced ?? true;
|
||||
const band = options.band ?? 'skilled';
|
||||
const tech = options.tech ?? !!options.initial?.techTokens;
|
||||
const strongholds = options.strongholds ?? !!options.initial?.strongholdCards;
|
||||
const kind = options.kind ?? 'arrakeen';
|
||||
assert.ok(advanced || !strongholds, 'Original Stronghold Cards require Advanced rules');
|
||||
const actions: NexusSkillsModulesBattleStep[] = [], staging: string[] = [];
|
||||
let game: Game;
|
||||
if (options.initial) game = structuredClone(options.initial);
|
||||
else {
|
||||
const ids = options.seatIds ?? ['emperor', 'guild', 'atreides', 'fremen'];
|
||||
assert.ok(ids.length >= 3 && ids.length <= 6 && new Set(ids).size === ids.length);
|
||||
const factions: FactionId[] = ['emperor', 'guild', 'atreides', 'fremen', 'harkonnen', 'beneGesserit'];
|
||||
game = createGame('NEXUSSKILLSMODULESBATTLE', newPlayer(ids[0], factions[0], factions[0]), advanced, []);
|
||||
for (let index = 1; index < ids.length; index++) joinGame(game, newPlayer(ids[index], factions[index], factions[index]));
|
||||
}
|
||||
assert.equal(game.advanced, advanced);
|
||||
assert.deepEqual(game.expansions, []);
|
||||
assert.ok(!game.homeworlds);
|
||||
if (game.status === 'lobby') {
|
||||
if (tech && !game.techTokens) game = stepNexusSkillsModulesBattle(game, { actor: game.host, action: { type: 'techTokens', enabled: true } });
|
||||
if (strongholds && !game.strongholdCards) game = stepNexusSkillsModulesBattle(game, { actor: game.host, action: { type: 'strongholdCards', enabled: true } });
|
||||
}
|
||||
assert.equal(!!game.techTokens, tech); assert.equal(!!game.strongholdCards, strongholds);
|
||||
if (tech) assert.deepEqual(game.techTokens, createTechTokens(game.status === 'setup' ? game.players : []));
|
||||
if (strongholds) assert.deepEqual(game.strongholdCards, createStrongholdCards());
|
||||
assert.ok(game.players.length >= 3 && game.players.length <= 6);
|
||||
const owner = game.players.find(seat => seat.faction === 'emperor')?.id;
|
||||
const opponent = game.players.find(seat => seat.faction === 'guild')?.id;
|
||||
const observer = game.players.find(seat => seat.faction === 'atreides')?.id;
|
||||
assert.ok(owner && opponent && observer);
|
||||
const partner = game.players.find(seat => ![owner, opponent, observer].includes(seat.id))?.id ?? opponent;
|
||||
const desiredSkill = options.skill ?? 'warmaster';
|
||||
const passive: LeaderSkillId[] = ['sandmaster', 'smuggler', 'rihani-decipherer', 'prana-bindu-adept', 'killer-medic', 'master-of-assassins']
|
||||
.filter(skill => skill !== desiredSkill) as LeaderSkillId[];
|
||||
const selected = game.players.map(seat => seat.id === owner ? desiredSkill : passive.shift()!);
|
||||
if (game.status === 'lobby') {
|
||||
assert.ok(!game.leaderSkills, 'Never replace existing original skill custody');
|
||||
game.nexusCards ??= { cards: null, phase: null };
|
||||
assert.deepEqual(game.nexusCards, { cards: null, phase: null });
|
||||
for (const seat of game.players) if (!seat.ready) game = stepNexusSkillsModulesBattle(game, { actor: seat.id, action: { type: 'ready' } });
|
||||
game = entropy(() => initializeLeaderSkillsGameForAudit(game), offerRolls(selected));
|
||||
}
|
||||
assert.equal(game.status, 'setup', 'Only original fresh undealt setup/lobby is accepted');
|
||||
assert.equal(game.turn, 1); assert.equal(game.phase, 0);
|
||||
assert.ok(game.leaderSkills && game.nexusCards?.cards);
|
||||
assert.equal(game.leaderSkills.assignments.length, 0);
|
||||
const initial = structuredClone(game);
|
||||
for (let count = 0; game.status === 'setup' && count < 240; count++) {
|
||||
if (game.setupStage === 'leaderSkills') {
|
||||
const actor: string = Object.keys(game.leaderSkills!.offers)[0];
|
||||
const offer: NonNullable<Game['leaderSkills']>['offers'][string] = game.leaderSkills!.offers[actor];
|
||||
const view = viewGame(game, actor).leaderSkills!;
|
||||
const requested: LeaderSkillId = selected[game.players.findIndex(seat => seat.id === actor)];
|
||||
const legal: LeaderSkillId[] = offer.cards.filter(skill => !view.unavailableSkills?.[skill]);
|
||||
const skill: LeaderSkillId | undefined = legal.includes(requested) ? requested : legal[0];
|
||||
assert.ok(skill, 'Existing saved offer has no legal original skill; it cannot be redealt');
|
||||
if (actor === owner && options.skill) assert.equal(skill, options.skill, 'Requested skill must be in the original saved offer');
|
||||
const eligible = new Set(view.eligibleLeaders.map(leader => leader.id));
|
||||
const leader = nexusSkillsModulesBattlePlayer(game, actor).leaders.filter(leader => eligible.has(leader.id))
|
||||
.sort((a, b) => b.strength - a.strength)[0];
|
||||
assert.ok(leader);
|
||||
const next: NexusSkillsModulesBattleStep = { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } };
|
||||
actions.push(next); game = stepNexusSkillsModulesBattle(game, next);
|
||||
} else {
|
||||
const next = nextNexusSkillsModulesBattleStep(game); assert.ok(next);
|
||||
actions.push(next); game = stepNexusSkillsModulesBattle(game, next);
|
||||
}
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
const afterSetup = structuredClone(game);
|
||||
const assignment = game.leaderSkills!.assignments.find(skill => skill.owner === owner)!;
|
||||
const skill = assignment.skill, trainer = assignment.leader;
|
||||
let beforeFirstStorm!: Game, firstStormStep!: NexusSkillsModulesBattleStep, afterFirstStorm!: Game;
|
||||
while (!(game.phase === 1 && clean(game))) {
|
||||
const next = nextNexusSkillsModulesBattleStep(game); assert.ok(next);
|
||||
const before = structuredClone(game);
|
||||
actions.push(next); game = stepNexusSkillsModulesBattle(game, next);
|
||||
if (tech ? JSON.stringify(before.techTokens) !== JSON.stringify(game.techTokens) : before.phase === 0 && game.phase === 1) {
|
||||
beforeFirstStorm = before; firstStormStep = next; afterFirstStorm = structuredClone(game);
|
||||
}
|
||||
}
|
||||
assert.ok(beforeFirstStorm && firstStormStep && afterFirstStorm);
|
||||
orderSpice(game, ['land', 'land'], staging);
|
||||
game = advanceNexusSkillsModulesBattle(game, state => state.phase === 5 && clean(state), actions);
|
||||
reserveBoard(game);
|
||||
staging.push('After actual setup, conserve all board counters through their own reserves before first movement completion');
|
||||
let warmupBeforeBattle: Game | undefined, warmupPlanActions: NexusSkillsModulesBattleStep[] | undefined,
|
||||
warmupAfterBattle: Game | undefined;
|
||||
if (options.opponentTokens === 'several') {
|
||||
assert.ok(tech, 'Multiple rewards require selected original Tech Tokens');
|
||||
assert.equal(ownedTech(game.techTokens, opponent).length, 1);
|
||||
const donor = game.players.find(seat => seat.id !== opponent && ownedTech(game.techTokens, seat.id).length === 1);
|
||||
assert.ok(donor);
|
||||
for (const actor of [opponent, donor.id]) place(game, actor, 'arrakeen:10', 5);
|
||||
staging.push(`Conserved five ordinary counters each at Arrakeen for ${opponent}'s real first-turn token-winning battle against ${donor.id}`);
|
||||
game = advanceNexusSkillsModulesBattle(game, state => state.phase === 6 && clean(state), actions);
|
||||
const actor = game.active!; assert.ok([opponent, donor.id].includes(actor));
|
||||
warmupBeforeBattle = structuredClone(game);
|
||||
const choose: NexusSkillsModulesBattleStep = { actor, action: { type: 'chooseBattle', territory: 'arrakeen',
|
||||
target: actor === opponent ? donor.id : opponent } };
|
||||
actions.push(choose); game = stepNexusSkillsModulesBattle(game, choose);
|
||||
game = advanceNexusSkillsModulesBattle(game, state => !nextNexusSkillsModulesBattleStep(state), actions);
|
||||
const leader = (id: string, strongest: boolean) => {
|
||||
const candidates = nexusSkillsModulesBattlePlayer(game, id).leaders.filter(disc => !disc.dead && !disc.usedAt &&
|
||||
!game.leaderSkills!.assignments.some(assignment => assignment.leader === disc.id));
|
||||
candidates.sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength);
|
||||
assert.ok(candidates[0]); return candidates[0].id;
|
||||
};
|
||||
warmupPlanActions = [
|
||||
{ actor: opponent, action: { type: 'battlePlan', leader: leader(opponent, true), dial: 4,
|
||||
support: advanced ? 4 : 0, weapon: null, defense: null } },
|
||||
{ actor: donor.id, action: { type: 'battlePlan', leader: leader(donor.id, false), dial: 0,
|
||||
support: 0, weapon: null, defense: null } },
|
||||
];
|
||||
for (const plan of warmupPlanActions) {
|
||||
actions.push(plan); game = stepNexusSkillsModulesBattle(game, plan);
|
||||
while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') {
|
||||
const next = nextNexusSkillsModulesBattleStep(game); assert.ok(next);
|
||||
actions.push(next); game = stepNexusSkillsModulesBattle(game, next);
|
||||
}
|
||||
}
|
||||
game = finishNexusSkillsModulesBattle(game);
|
||||
assert.equal(game.lastBattleContext!.winner, opponent);
|
||||
assert.equal(ownedTech(game.techTokens, opponent).length, 2);
|
||||
warmupAfterBattle = structuredClone(game);
|
||||
}
|
||||
game = advanceNexusSkillsModulesBattle(game, state => state.phase === 8 && clean(state), actions);
|
||||
if (strongholds) {
|
||||
reserveBoard(game);
|
||||
place(game, owner, `${kind}:${territory(kind).sectors[0]}`, 1);
|
||||
staging.push(`Conserved one ordinary claimant at ${kind} before actual END Mentat; no Stronghold owner grant`);
|
||||
}
|
||||
let beforeFirstMentat!: Game, firstMentatStep!: NexusSkillsModulesBattleStep;
|
||||
while (game.turn === 1) {
|
||||
const next = nextNexusSkillsModulesBattleStep(game); assert.ok(next);
|
||||
const before = structuredClone(game);
|
||||
actions.push(next); game = stepNexusSkillsModulesBattle(game, next);
|
||||
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; }
|
||||
}
|
||||
const afterFirstMentat = structuredClone(game);
|
||||
if (strongholds) assert.equal(game.strongholdCards!.owners[kind], owner);
|
||||
game = advanceNexusSkillsModulesBattle(game, state => state.phase === 1 && clean(state), actions);
|
||||
orderSpice(game, ['worm', 'land', 'land'], staging);
|
||||
game = advanceNexusSkillsModulesBattle(game, state => state.nexus === true && !state.spiceWindow && clean(state), actions);
|
||||
const beforeAlliance = structuredClone(game);
|
||||
const allianceActions: NexusSkillsModulesBattleStep[] = [
|
||||
{ actor: observer, action: { type: 'alliance', target: partner } },
|
||||
{ actor: partner, action: { type: 'alliance', target: observer } },
|
||||
];
|
||||
for (const next of allianceActions) { actions.push(next); game = stepNexusSkillsModulesBattle(game, next); }
|
||||
game = advanceNexusSkillsModulesBattle(game, state => state.nexusCards?.phase?.stage === 'drawing', actions);
|
||||
const cards = game.nexusCards!.cards!;
|
||||
const from = cards.deck.indexOf('emperor'); assert.ok(from >= 0);
|
||||
cards.deck.unshift(cards.deck.splice(from, 1)[0]);
|
||||
staging.push('Conserved undealt Emperor Nexus singleton ordered first after genuine alliance; only actual closing draw changes hand custody');
|
||||
const beforeDraw = structuredClone(game);
|
||||
const drawAction: NexusSkillsModulesBattleStep = { actor: owner, action: {
|
||||
type: 'nexusCardChoice', turn: game.turn, card: cards.hands[owner], choice: 'draw', ownRedraws: 0,
|
||||
} };
|
||||
actions.push(drawAction); game = stepNexusSkillsModulesBattle(game, drawAction);
|
||||
const afterDraw = structuredClone(game);
|
||||
assert.equal(game.nexusCards!.cards!.hands[owner], 'emperor');
|
||||
if (options.opponentNexus) {
|
||||
const cards = game.nexusCards!.cards!;
|
||||
assert.ok(game.nexusCards!.phase?.eligible.includes(opponent), 'Requested original opponent draw requires an unallied closing recipient');
|
||||
const from = cards.deck.indexOf(options.opponentNexus); assert.ok(from >= 0);
|
||||
cards.deck.unshift(cards.deck.splice(from, 1)[0]);
|
||||
const draw: NexusSkillsModulesBattleStep = { actor: opponent, action: { type: 'nexusCardChoice', turn: game.turn,
|
||||
card: cards.hands[opponent], choice: 'draw', ownRedraws: 0 } };
|
||||
staging.push('Conserved undealt Moritani singleton ordered before Guild’s actual unallied closing draw');
|
||||
actions.push(draw); game = stepNexusSkillsModulesBattle(game, draw);
|
||||
assert.equal(game.nexusCards!.cards!.hands[opponent], 'moritani');
|
||||
}
|
||||
game = advanceNexusSkillsModulesBattle(game, state => state.phase === 5 && clean(state), actions);
|
||||
reserveBoard(game);
|
||||
const key = `${kind}:${territory(kind).sectors[0]}`;
|
||||
assert.ok(!territory(kind).sectors.includes(game.storm));
|
||||
for (const actor of [owner, opponent]) place(game, actor, key, 7);
|
||||
const eliteCount = options.ownerElites ?? 0;
|
||||
if (eliteCount) {
|
||||
const seat = nexusSkillsModulesBattlePlayer(game, owner);
|
||||
assert.ok(seat.elites && Number.isInteger(eliteCount) && eliteCount > 0 && seat.elites.reserves >= eliteCount);
|
||||
seat.reserves -= eliteCount; seat.elites.reserves -= eliteCount;
|
||||
seat.forces[key] += eliteCount; seat.elites.forces[key] = eliteCount;
|
||||
staging.push(`Conserved ${eliteCount} actual starred Sardaukar from own reserves to ${key}; distinct from the five temporary ordinary roles`);
|
||||
}
|
||||
const secondKey = options.secondBattle ? `carthag:${territory('carthag').sectors[0]}` : undefined;
|
||||
if (secondKey) {
|
||||
assert.ok(!territory('carthag').sectors.includes(game.storm));
|
||||
for (const actor of [owner, opponent]) place(game, actor, secondKey, 1);
|
||||
}
|
||||
staging.push(`Before actual turn-two movement completion, conserved seven ordinary counters per combatant at ${kind}; any starred counters are separately labeled`);
|
||||
if (secondKey) staging.push('Conserved one further ordinary counter per combatant at Carthag for genuine same-turn expiry battle');
|
||||
const used: string[] = [];
|
||||
const card = (actor: string, kind: Card['kind'] | undefined) => kind ? hold(game, actor, card => card.kind === kind, staging, used) : null;
|
||||
const ownerWeapon = card(owner, options.ownerWeapon ?? (skill === 'warmaster' ? 'worthless' : undefined));
|
||||
const ownerDefense = card(owner, options.ownerDefense);
|
||||
const opponentWeapon = card(opponent, options.opponentWeapon);
|
||||
const opponentDefense = card(opponent, options.opponentDefense);
|
||||
const counter = options.counter ? hold(game, opponent, card => card.effect === 'karama', staging, used) : undefined;
|
||||
const untrained = (actor: string) => {
|
||||
const leaders = nexusSkillsModulesBattlePlayer(game, actor).leaders.filter(leader => !leader.dead && !leader.usedAt &&
|
||||
!game.leaderSkills!.assignments.some(assignment => assignment.leader === leader.id));
|
||||
const leader = leaders.find(leader => leader.strength === 3) ?? leaders[0]; assert.ok(leader);
|
||||
return leader.id;
|
||||
};
|
||||
const ownerLeader = band === 'skilled' ? trainer : untrained(owner);
|
||||
const opponentLeader = untrained(opponent);
|
||||
if (options.traitors === 'owner' || options.traitors === 'both') traitor(game, owner, opponentLeader, staging);
|
||||
if (options.traitors === 'opponent' || options.traitors === 'both') traitor(game, opponent, ownerLeader, staging);
|
||||
game = advanceNexusSkillsModulesBattle(game, state => state.phase === 6 && clean(state), actions);
|
||||
const beforeBattle = structuredClone(game);
|
||||
const actor = game.active!; assert.ok([owner, opponent].includes(actor));
|
||||
const battleAction: NexusSkillsModulesBattleStep = { actor, action: { type: 'chooseBattle', territory: kind, target: actor === owner ? opponent : owner } };
|
||||
actions.push(battleAction); game = stepNexusSkillsModulesBattle(game, battleAction);
|
||||
const planActions: NexusSkillsModulesBattleStep[] = [
|
||||
{ actor: owner, action: { type: 'battlePlan', leader: ownerLeader, dial: options.ownerDial ?? 6,
|
||||
support: advanced ? options.support ?? 3 : 0, weapon: ownerWeapon, defense: ownerDefense } },
|
||||
{ actor: opponent, action: { type: 'battlePlan', leader: opponentLeader, dial: options.opponentDial ?? 0,
|
||||
support: advanced ? options.opponentSupport ?? 0 : 0, weapon: opponentWeapon, defense: opponentDefense } },
|
||||
];
|
||||
return { initial, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeAlliance, allianceActions,
|
||||
beforeDraw, drawAction, afterDraw, beforeBattle, battleAction, game, owner, opponent, observer, partner,
|
||||
beforeFirstStorm, firstStormStep, afterFirstStorm, warmupBeforeBattle, warmupPlanActions, warmupAfterBattle,
|
||||
trainer, skill, band, kind, key, secondKey, counter, planActions, actions, staging };
|
||||
}
|
||||
export function prepareNexusSkillsModulesBattle(fixture: NexusSkillsModulesBattleFixture, state = fixture.game): Game {
|
||||
let game = structuredClone(state);
|
||||
for (let count = 0; count < 160; count++) {
|
||||
const next = nextNexusSkillsModulesBattleStep(game);
|
||||
if (!next) { assert.ok(game.battle && !game.battle.revealed); return game; }
|
||||
if (next.action.type === 'leaderSkillVisibility') next.action.hide = next.actor === fixture.owner && fixture.band === 'skilled';
|
||||
game = stepNexusSkillsModulesBattle(game, next);
|
||||
}
|
||||
throw Error('Original skill posture/private plan boundary did not open');
|
||||
}
|
||||
export function beginNexusSkillsModulesCunning(fixture: NexusSkillsModulesBattleFixture, state = prepareNexusSkillsModulesBattle(fixture)): Game {
|
||||
const offer = viewGame(state, fixture.owner).nexusSardaukar?.offer;
|
||||
assert.ok(offer); assert.equal(offer.blocked, null);
|
||||
return stepNexusSkillsModulesBattle(state, { actor: fixture.owner, action: { type: 'nexusSardaukar', event: offer.event } });
|
||||
}
|
||||
export function revealNexusSkillsModulesBattle(fixture: NexusSkillsModulesBattleFixture,
|
||||
options: { state?: Game; cunning?: boolean } = {}): Game {
|
||||
let game = prepareNexusSkillsModulesBattle(fixture, options.state ?? fixture.game);
|
||||
if (options.cunning) game = advanceNexusSkillsModulesBattle(beginNexusSkillsModulesCunning(fixture, game), clean);
|
||||
for (const plan of fixture.planActions) {
|
||||
game = stepNexusSkillsModulesBattle(game, plan);
|
||||
while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') game = stepNexusSkillsModulesBattle(game);
|
||||
}
|
||||
assert.ok(game.battle?.revealed);
|
||||
return game;
|
||||
}
|
||||
export function finishNexusSkillsModulesBattle(state: Game,
|
||||
options: { stop?: 'losses' | 'rescue' | 'cleanup' | 'tech' | 'retention' | 'inspection'; callers?: string[] } = {}): Game {
|
||||
let game = structuredClone(state);
|
||||
for (let count = 0; count < 240; count++) {
|
||||
if (options.stop === 'losses' && game.decision?.kind === 'battleLosses') return game;
|
||||
if (options.stop === 'rescue' && game.decision?.kind === 'sukRescue') return game;
|
||||
if (options.stop === 'cleanup' && game.decision?.kind === 'battleCards') return game;
|
||||
if (options.stop === 'tech' && game.decision?.kind === 'techToken') return game;
|
||||
if (options.stop === 'retention' && game.decision?.kind === 'moritaniRetention') return game;
|
||||
if (options.stop === 'inspection' && game.decision?.kind === 'nexusChoamInspection') return game;
|
||||
if (!game.battle && clean(game) && !game.pendingTreacheryDiscard) return game;
|
||||
const next = nextNexusSkillsModulesBattleStep(game); assert.ok(next);
|
||||
if (next.action.type === 'traitorCall') next.action.call = options.callers?.includes(next.actor) ?? false;
|
||||
game = stepNexusSkillsModulesBattle(game, next);
|
||||
}
|
||||
throw Error('Original Skills/Nexus battle aftermath did not finish');
|
||||
}
|
||||
/** Quote the actual native source, including physical temporary roles and skill posture. */
|
||||
export function quoteNexusSkillsModulesBattle(game: Game) {
|
||||
const battle = game.battle; assert.ok(battle?.revealed);
|
||||
const side = (actor: string): ResolutionCombatant => {
|
||||
const seat = nexusSkillsModulesBattlePlayer(game, actor), view = viewGame(game, actor).battle!;
|
||||
assert.ok(view.ownForces);
|
||||
return { id: actor, faction: seat.faction, ally: seat.ally, spice: seat.spice, hand: seat.hand,
|
||||
plan: battle.plans[actor], leader: seat.leaders.find(leader => leader.id === battle.plans[actor].leader),
|
||||
forces: view.ownForces, stronghold: view.strongholdEffects[actor],
|
||||
leaderSkills: game.leaderSkills!.assignments.filter(assignment => assignment.owner === actor)
|
||||
.map(assignment => ({ skill: assignment.skill, leader: assignment.leader,
|
||||
faceUp: !battle.leaderSkillHidden?.[actor], captured: false })) };
|
||||
};
|
||||
return quoteBattleResolution({ advanced: game.advanced, turn: game.turn, territory: battle.territory,
|
||||
aggressor: viewGame(game, game.host).battle!.aggressor, attacker: side(battle.attacker), defender: side(battle.defender),
|
||||
voters: viewGame(game, game.host).battle!.traitorVoters.map(actor => ({ id: actor,
|
||||
beneficiary: [battle.attacker, battle.defender].includes(actor) ? actor : nexusSkillsModulesBattlePlayer(game, actor).ally!,
|
||||
called: battle.traitorCalls[actor] ?? false, traitors: nexusSkillsModulesBattlePlayer(game, actor).traitors })),
|
||||
participants: game.players, physicalCards: [...game.deck, ...game.discard, ...game.players.flatMap(seat => seat.hand)],
|
||||
pendingAuditorPresent: false, pendingRetentionPresent: false, nexusMoritani: true });
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, handLimit, joinGame, newPlayer, viewGame,
|
||||
type Action, type Game } from '../game/engine';
|
||||
import { distance, TERRITORIES, territory } from '../game/board';
|
||||
import { botActions } from '../game/bots';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import type { Card } from '../game/cards';
|
||||
import type { LeaderSkillId } from '../game/leader-skill-cards';
|
||||
import { STRONGHOLD_CARDS } from '../game/stronghold-cards';
|
||||
import { ownedTech, TECH_TOKENS, type TechId } from '../game/tech-tokens';
|
||||
import { classicNexusSkillsPaymentsInventory, classicNexusSkillsPaymentsPlayer,
|
||||
initializeClassicNexusSkillsPaymentsSetup, nextClassicNexusSkillsPaymentsNativeStep,
|
||||
type ClassicNexusSkillsPaymentStep } from './fixture-classic-nexus-skills-payments';
|
||||
|
||||
export type NexusSkillsModulesPaymentKind = 'emperor-revival' | 'fremen-revival' | 'emperor-purchase' | 'native-shipment';
|
||||
export type NexusSkillsModulesPaymentStep = ClassicNexusSkillsPaymentStep;
|
||||
export type NexusSkillsModulesPaymentsOptions = {
|
||||
/** Original lobby or turn-one, phase-zero unassigned Skills setup; never redealt. */
|
||||
initial?: Game;
|
||||
advanced?: boolean;
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
seatIds?: string[];
|
||||
ownerFaction?: FactionId;
|
||||
kind?: NexusSkillsModulesPaymentKind;
|
||||
skill?: LeaderSkillId;
|
||||
nativeGuild?: boolean;
|
||||
tanks?: number;
|
||||
eliteTanks?: number;
|
||||
amount?: number;
|
||||
};
|
||||
export type NexusSkillsModulesPaymentsFixture = {
|
||||
initial: Game;
|
||||
afterSetup: Game;
|
||||
beforeFirstMentat: Game;
|
||||
firstMentatStep: NexusSkillsModulesPaymentStep;
|
||||
afterFirstMentat: Game;
|
||||
beforeClosingDraw: Game;
|
||||
closingDraw: NexusSkillsModulesPaymentStep;
|
||||
afterClosingDraw: Game;
|
||||
beforePayment: Game;
|
||||
game: Game;
|
||||
actor: string;
|
||||
opponent: string;
|
||||
kind: NexusSkillsModulesPaymentKind;
|
||||
paymentAction: Action;
|
||||
amount: number;
|
||||
cost: number;
|
||||
token: TechId;
|
||||
tokenOwner: string | null;
|
||||
tokenCount: number;
|
||||
destination?: string;
|
||||
actions: NexusSkillsModulesPaymentStep[];
|
||||
staging: string[];
|
||||
};
|
||||
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.truthtrance;
|
||||
export const nexusSkillsModulesPaymentsPlayer = classicNexusSkillsPaymentsPlayer;
|
||||
|
||||
/** Scalar rolls stabilize physical deck/Storm order; UUID byte arrays stay native. */
|
||||
function originalRandom<T>(run: () => T): T {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const native = crypto.getRandomValues.bind(crypto);
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1) array[0] = 0xffffffff;
|
||||
else Reflect.apply(native, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return run(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
export function nextNexusSkillsModulesPaymentsNativeStep(game: Game): NexusSkillsModulesPaymentStep | null {
|
||||
if (clean(game) && game.status === 'finished') return null;
|
||||
if (game.truthtrance && !game.phaseOpening && !game.response) {
|
||||
for (const player of game.players) {
|
||||
const view = viewGame(game, player.id);
|
||||
view.players.find(p => p.id === player.id)!.bot = 'Easy';
|
||||
const action = botActions(view)[0];
|
||||
if (action) return { actor: player.id, action };
|
||||
}
|
||||
throw Error('The actual Truthtrance must expose its native priority/question/answer action');
|
||||
}
|
||||
if (!game.phaseOpening && !game.response && game.decision?.kind === 'faceDance')
|
||||
return { actor: game.decision.player, action: { type: 'decision', reveal: false } };
|
||||
return nextClassicNexusSkillsPaymentsNativeStep(game);
|
||||
}
|
||||
export function advanceNexusSkillsModulesPaymentsStep(state: Game, actions?: NexusSkillsModulesPaymentStep[]): Game {
|
||||
const next = nextNexusSkillsModulesPaymentsNativeStep(state);
|
||||
assert.ok(next, 'The native continuation stopped for a human battle plan');
|
||||
actions?.push(structuredClone(next));
|
||||
return applyAction(state, next.actor, next.action);
|
||||
}
|
||||
export function advanceNexusSkillsModulesPayments(state: Game, predicate: (game: Game) => boolean,
|
||||
actions?: NexusSkillsModulesPaymentStep[]): Game {
|
||||
let game = state;
|
||||
for (let n = 0; n < 1800; n++) {
|
||||
if (predicate(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
game = advanceNexusSkillsModulesPaymentsStep(game, actions);
|
||||
}
|
||||
throw Error('Original Nexus/Skills/module continuation did not reach its boundary');
|
||||
}
|
||||
export function advanceNexusSkillsModulesPaymentsToPhase(state: Game, phase: number,
|
||||
actions?: NexusSkillsModulesPaymentStep[]): Game {
|
||||
const turn = state.turn;
|
||||
return advanceNexusSkillsModulesPayments(state, game => {
|
||||
assert.equal(game.turn, turn);
|
||||
return game.phase >= phase && clean(game);
|
||||
}, actions);
|
||||
}
|
||||
|
||||
export function initializeNexusSkillsModulesPaymentsSetup(options: NexusSkillsModulesPaymentsOptions = {}): Game {
|
||||
const kind = options.kind ?? 'emperor-revival';
|
||||
const ownerFaction = options.ownerFaction ?? 'atreides';
|
||||
let game = options.initial ? structuredClone(options.initial)
|
||||
: createGame('NEXUSSKILLSMODULEPAYMENTS', newPlayer(options.seatIds?.[0] ?? 'payer', ownerFaction, ownerFaction),
|
||||
options.advanced ?? !!options.strongholds);
|
||||
if (!options.initial) {
|
||||
const forbidden = kind === 'fremen-revival' ? 'fremen' : 'emperor';
|
||||
const count = options.seatIds?.length ?? 3;
|
||||
assert.ok(count >= 3 && count <= 6);
|
||||
const factions: FactionId[] = options.nativeGuild
|
||||
? ['guild', 'harkonnen', 'atreides', 'beneGesserit', 'fremen', 'emperor']
|
||||
: ['harkonnen', 'atreides', 'beneGesserit', 'guild', 'fremen', 'emperor'];
|
||||
for (const faction of factions) if (game.players.length < count && faction !== forbidden && faction !== ownerFaction)
|
||||
joinGame(game, newPlayer(options.seatIds?.[game.players.length] ?? faction, faction, faction));
|
||||
assert.equal(game.players.length, count);
|
||||
}
|
||||
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
|
||||
const tech = options.tech ?? (options.initial ? !!game.techTokens : true);
|
||||
const strongholds = options.strongholds ?? !!game.strongholdCards;
|
||||
assert.ok(tech || strongholds, 'Select original Tech and/or Advanced Strongholds');
|
||||
if (game.status === 'lobby') {
|
||||
if (tech && !game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
|
||||
if (strongholds && !game.strongholdCards) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true });
|
||||
}
|
||||
assert.equal(!!game.techTokens, tech);
|
||||
assert.equal(!!game.strongholdCards, strongholds);
|
||||
if (options.nativeGuild) assert.ok(game.players.some(player => player.faction === 'guild'));
|
||||
return initializeClassicNexusSkillsPaymentsSetup({ initial: game, ownerFaction,
|
||||
kind: kind === 'native-shipment' ? 'emperor-revival' : kind, advanced: options.advanced, skill: options.skill });
|
||||
}
|
||||
function reserveBoard(game: Game, staging: string[]): void {
|
||||
for (const player of game.players) {
|
||||
player.reserves += Object.values(player.forces).reduce((a, b) => a + b, 0);
|
||||
player.forces = {};
|
||||
if (player.elites) {
|
||||
player.elites.reserves += Object.values(player.elites.forces).reduce((a, b) => a + b, 0);
|
||||
player.elites.forces = {};
|
||||
}
|
||||
if (player.advisors) player.advisors = {};
|
||||
}
|
||||
staging.push('Conserved every original board counter into its own reserves; no wallet, phase, module owner or earned receipt assigned.');
|
||||
}
|
||||
function orderUndealtSpice(game: Game, kinds: ('land' | 'worm')[], staging: string[]): void {
|
||||
const front: Game['spiceDeck'] = [];
|
||||
for (const kind of kinds) {
|
||||
const index = game.spiceDeck.findIndex(card => kind === 'land' ? 'territory' in card : 'worm' in card);
|
||||
assert.ok(index >= 0, 'Only actual still-undealt physical Spice Cards may be ordered');
|
||||
front.push(game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
game.spiceDeck.unshift(...front);
|
||||
staging.push(`Ordered original undealt Spice Card singletons: ${kinds.join(', ')}; discard custody unchanged.`);
|
||||
}
|
||||
/** Relocates an actual original card, retaining its identity and the 33-card inventory. */
|
||||
export function holdNexusSkillsModulesPaymentsCard(game: Game, actor: string, kind: Card['kind'] | 'karama' | 'truthtrance', staging: string[]): Card {
|
||||
const player = nexusSkillsModulesPaymentsPlayer(game, actor);
|
||||
const matches = (card: Card) => card.kind === kind || card.effect === kind;
|
||||
const held = player.hand.find(matches);
|
||||
if (held) return held;
|
||||
assert.ok(player.hand.length < handLimit(player));
|
||||
const index = game.deck.findIndex(matches);
|
||||
const donor = index < 0 ? game.players.find(p => p.id !== actor && p.hand.some(matches)) : undefined;
|
||||
assert.ok(index >= 0 || donor, `An original ${kind} card must remain in real deck/hand custody`);
|
||||
const card = index >= 0 ? game.deck.splice(index, 1)[0]
|
||||
: donor!.hand.splice(donor!.hand.findIndex(matches), 1)[0];
|
||||
player.hand.push(card);
|
||||
staging.push(`Conserved original ${card.id}: ${donor?.id ?? 'undealt deck'} custody to ${actor}'s hand.`);
|
||||
return card;
|
||||
}
|
||||
|
||||
/** Full original setup, first Storm assignment, first END Mentat claim and real
|
||||
* unallied closing draw precede the payment. Only physical positioning/order is staged. */
|
||||
export function createNexusSkillsModulesPaymentsFixture(options: NexusSkillsModulesPaymentsOptions = {}): NexusSkillsModulesPaymentsFixture {
|
||||
const initial = initializeNexusSkillsModulesPaymentsSetup(options);
|
||||
return originalRandom(() => {
|
||||
const kind = options.kind ?? 'emperor-revival';
|
||||
const actor = initial.players.find(p => p.faction === (options.ownerFaction ?? 'atreides'))!.id;
|
||||
const opponent = initial.players.find(p => p.id !== actor)!.id;
|
||||
const actions: NexusSkillsModulesPaymentStep[] = [], staging: string[] = [];
|
||||
let game = initial;
|
||||
while (game.status === 'setup') {
|
||||
let next = nextNexusSkillsModulesPaymentsNativeStep(game); assert.ok(next);
|
||||
if (game.setupStage === 'leaderSkills' && next.actor === actor && options.skill) {
|
||||
const view = viewGame(game, actor).leaderSkills!;
|
||||
assert.ok(view.offer!.cards.includes(options.skill), 'Explicit skill must exist in the saved original offer');
|
||||
assert.ok(!view.unavailableSkills?.[options.skill]);
|
||||
next = { actor, action: { ...next.action, skill: options.skill } };
|
||||
}
|
||||
actions.push(structuredClone(next)); game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
const afterSetup = structuredClone(game);
|
||||
reserveBoard(game, staging);
|
||||
if (game.strongholdCards) {
|
||||
const player = nexusSkillsModulesPaymentsPlayer(game, actor);
|
||||
assert.ok(player.reserves - (player.elites?.reserves ?? 0) >= 1);
|
||||
player.reserves--; player.forces['arrakeen:10'] = 1;
|
||||
staging.push(`Conserved one ordinary ${actor} reserve counter to Arrakeen before the real first END Mentat claim.`);
|
||||
}
|
||||
game = advanceNexusSkillsModulesPaymentsToPhase(game, 1, actions);
|
||||
orderUndealtSpice(game, ['land', 'land'], staging);
|
||||
game = advanceNexusSkillsModulesPaymentsToPhase(game, 8, actions);
|
||||
let beforeFirstMentat: Game | undefined, firstMentatStep: NexusSkillsModulesPaymentStep | undefined;
|
||||
while (game.turn === 1) {
|
||||
const before = structuredClone(game), next = nextNexusSkillsModulesPaymentsNativeStep(game); assert.ok(next);
|
||||
game = advanceNexusSkillsModulesPaymentsStep(game, actions);
|
||||
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; }
|
||||
}
|
||||
assert.ok(beforeFirstMentat && firstMentatStep);
|
||||
const afterFirstMentat = structuredClone(game);
|
||||
if (game.strongholdCards) assert.equal(game.strongholdCards.owners.arrakeen, actor);
|
||||
game = advanceNexusSkillsModulesPaymentsToPhase(game, 1, actions);
|
||||
reserveBoard(game, staging);
|
||||
orderUndealtSpice(game, ['worm', 'land', 'land'], staging);
|
||||
let allied = false;
|
||||
for (let n = 0; game.nexusCards?.phase?.stage !== 'drawing' && n < 240; n++) {
|
||||
if (game.nexus && !game.spiceWindow && !game.spiceResolution && clean(game) && !allied) {
|
||||
const others = game.players.filter(p => p.id !== actor); assert.ok(others.length >= 2);
|
||||
for (const [from, to] of [[others[0].id, others[1].id], [others[1].id, others[0].id]]) {
|
||||
const action: Action = { type: 'alliance', target: to };
|
||||
actions.push({ actor: from, action }); game = applyAction(game, from, action);
|
||||
}
|
||||
allied = true;
|
||||
}
|
||||
game = advanceNexusSkillsModulesPaymentsStep(game, actions);
|
||||
}
|
||||
assert.ok(allied); assert.equal(game.nexusCards?.phase?.stage, 'drawing');
|
||||
const card = kind === 'fremen-revival' ? 'fremen' : 'emperor';
|
||||
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf(card); assert.ok(index >= 0);
|
||||
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
|
||||
staging.push(`Ordered original undealt ${card} Nexus singleton before a genuine closing draw; no card grant.`);
|
||||
const beforeClosingDraw = structuredClone(game);
|
||||
const closingDraw: NexusSkillsModulesPaymentStep = { actor, action: { type: 'nexusCardChoice', turn: game.turn,
|
||||
card: cards.hands[actor], choice: 'draw', ownRedraws: 0 } };
|
||||
actions.push(closingDraw); game = applyAction(game, actor, closingDraw.action);
|
||||
assert.equal(game.nexusCards!.cards!.hands[actor], card);
|
||||
const afterClosingDraw = structuredClone(game);
|
||||
game = advanceNexusSkillsModulesPayments(game, state => state.nexusCards?.phase?.stage !== 'drawing', actions);
|
||||
game = advanceNexusSkillsModulesPaymentsToPhase(game, kind === 'native-shipment' ? 5 : kind === 'emperor-purchase' ? 3 : 4, actions);
|
||||
let paymentAction: Action, destination: string | undefined, cost = 0;
|
||||
const amount = kind === 'native-shipment' ? options.amount ?? 3 : kind === 'emperor-purchase' ? 1 : 3;
|
||||
if (kind === 'emperor-purchase') {
|
||||
for (let n = 0; game.decision?.kind !== 'auctionPayment' && n < 160; n++) {
|
||||
if (game.auction?.active === actor && !game.auction.bid) {
|
||||
const action: Action = { type: 'bid', amount: 2 };
|
||||
actions.push({ actor, action }); game = applyAction(game, actor, action);
|
||||
} else game = advanceNexusSkillsModulesPaymentsStep(game, actions);
|
||||
}
|
||||
assert.equal(game.decision?.kind, 'auctionPayment'); assert.equal(game.decision!.player, actor);
|
||||
assert.equal(game.auction!.bid, 2); assert.equal(game.auction!.bidder, actor);
|
||||
cost = game.auction!.bid;
|
||||
paymentAction = { type: 'nexusEmperorPurchase', event: viewGame(game, actor).nexusEmperorSecretAlly!.event };
|
||||
} else if (kind === 'native-shipment') {
|
||||
game = advanceNexusSkillsModulesPayments(game, state => state.active === actor && clean(state), actions);
|
||||
const player = nexusSkillsModulesPaymentsPlayer(game, actor);
|
||||
const target = TERRITORIES.find(t => t.type === 'stronghold' && !t.sectors.includes(game.storm)
|
||||
&& game.players.every(p => Object.keys(p.forces).every(key => !key.startsWith(`${t.id}:`)))
|
||||
&& (player.faction !== 'fremen' || territory('the_great_flat').sectors.some(from =>
|
||||
t.sectors.some(to => distance(`the_great_flat:${from}`, `${t.id}:${to}`) <= 2))));
|
||||
assert.ok(target, 'An empty native-legal stronghold destination must exist');
|
||||
destination = `${target.id}:${target.sectors[0]}`;
|
||||
cost = player.faction === 'fremen' ? 0 : player.faction === 'guild' ? Math.ceil(amount / 2) : amount;
|
||||
assert.ok(player.spice >= cost);
|
||||
paymentAction = { type: 'ship', territory: target.id, sector: target.sectors[0], amount, smuggler: false };
|
||||
} else {
|
||||
const player = nexusSkillsModulesPaymentsPlayer(game, actor), tanks = options.tanks ?? 6, elite = options.eliteTanks ?? 0;
|
||||
assert.ok(Number.isInteger(tanks) && tanks >= 0 && Number.isInteger(elite) && elite >= 0 && elite <= tanks);
|
||||
assert.ok(tanks - elite <= player.reserves - (player.elites?.reserves ?? 0) && elite <= (player.elites?.reserves ?? 0));
|
||||
player.reserves -= tanks; player.tanks += tanks;
|
||||
if (player.elites) { player.elites.reserves -= elite; player.elites.tanks += elite; }
|
||||
staging.push(`Conserved ${actor}: ${tanks} original reserve counters to Tanks, including ${elite} elites; no revival receipt staged.`);
|
||||
const view = viewGame(game, actor);
|
||||
paymentAction = { type: kind === 'emperor-revival' ? 'nexusEmperorRevive' : 'nexusFremenRevive',
|
||||
event: kind === 'emperor-revival' ? view.nexusEmperorSecretAlly!.event : view.nexusFremenRevival!.event, elite: elite ? 1 : 0 };
|
||||
}
|
||||
nexusSkillsModulesPaymentsInventory(game);
|
||||
const token: TechId = kind === 'native-shipment' ? 'heighliners' : kind === 'emperor-purchase' ? 'production' : 'axlotl';
|
||||
const tokenOwner = game.techTokens?.[token].owner ?? null;
|
||||
return { initial, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeClosingDraw, closingDraw,
|
||||
afterClosingDraw, beforePayment: structuredClone(game), game, actor, opponent, kind, paymentAction, amount, cost,
|
||||
token, tokenOwner, tokenCount: tokenOwner ? ownedTech(game.techTokens, tokenOwner).length : 0, destination, actions, staging };
|
||||
});
|
||||
}
|
||||
/** One original action only: exposes any actual interception/payment response. */
|
||||
export function payNexusSkillsModulesPaymentsFixture(fixture: NexusSkillsModulesPaymentsFixture, state = fixture.game): Game {
|
||||
return applyAction(state, fixture.actor, fixture.paymentAction);
|
||||
}
|
||||
export function finishNexusSkillsModulesPaymentsFixture(_fixture: NexusSkillsModulesPaymentsFixture, state: Game,
|
||||
actions?: NexusSkillsModulesPaymentStep[]): Game {
|
||||
return advanceNexusSkillsModulesPayments(state, game => clean(game) && !game.pendingRevival && !game.pendingShipment, actions);
|
||||
}
|
||||
export function nexusSkillsModulesPaymentsInventory(game: Game): void {
|
||||
classicNexusSkillsPaymentsInventory(game);
|
||||
if (game.techTokens) assert.deepEqual(Object.keys(game.techTokens).sort(), TECH_TOKENS.map(t => t.id).sort());
|
||||
if (game.strongholdCards) assert.deepEqual(Object.keys(game.strongholdCards.owners).sort(), STRONGHOLD_CARDS.map(c => c.id).sort());
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '../game/engine';
|
||||
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||
import { placeFixtureHand } from './fixture-hand';
|
||||
import { botActions } from '../game/bots';
|
||||
|
||||
const SEATS = [
|
||||
['e', 'Emperor seat', 'emperor'],
|
||||
@@ -481,3 +482,29 @@ void test('capturing an unskilled leader preserves the existing private identity
|
||||
assert.ok(g.log.every((entry) => !entry.text.includes('Captain Aramsham')));
|
||||
assertSkillCustody(g);
|
||||
});
|
||||
|
||||
void test('captured training excludes blocked Ghola and ordinary leader revivals while preserving a legal Collection pass', () => {
|
||||
const kept = applyAction(acceptCapture(captureOffer()), 'h', { type: 'decision', mode: 'keep' });
|
||||
kept.phase = 7;
|
||||
kept.decision = null;
|
||||
kept.response = null;
|
||||
kept.phaseOpening = null;
|
||||
kept.ready = kept.players.filter(player => player.id !== 'e').map(player => player.id);
|
||||
const emperor = kept.players.find(player => player.id === 'e')!;
|
||||
const ghola = baseDeck().find(card => card.effect === 'ghola')!;
|
||||
placeFixtureHand(kept, kept.players.indexOf(emperor), [ghola]);
|
||||
emperor.bot = 'Medium';
|
||||
const before = structuredClone(kept);
|
||||
const view = viewGame(kept, 'e');
|
||||
assert.deepEqual(view.ghola.leaders, []);
|
||||
assert.deepEqual(view.revival.leaders, []);
|
||||
assert.equal(view.ghola.available, true);
|
||||
assert.equal(view.ghola.maxForces, 5, 'Captured training does not prohibit physical force Ghola');
|
||||
const action = botActions(view).find(action => action.type === 'ready');
|
||||
assert.ok(action);
|
||||
const done = applyAction(kept, 'e', action);
|
||||
assert.equal(done.phase, 8);
|
||||
assert.equal(done.players.find(player => player.id === 'e')!.hand.some(card => card.id === ghola.id), true);
|
||||
assert.deepEqual(kept, before);
|
||||
assertSkillCustody(done);
|
||||
});
|
||||
@@ -0,0 +1,411 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||
import { casualtyOptions, maxCombatDial } from '../game/combat';
|
||||
import { TECH_TOKENS, ownedTech } from '../game/tech-tokens';
|
||||
import { strongholdControllers } from '../game/stronghold-cards';
|
||||
import { nexusInventory } from './fixture-nexus-cards';
|
||||
import {
|
||||
advanceNexusSkillsModulesBattle, beginNexusSkillsModulesCunning,
|
||||
createNexusSkillsModulesBattleFixture, finishNexusSkillsModulesBattle,
|
||||
nexusSkillsModulesBattlePlayer as player, prepareNexusSkillsModulesBattle,
|
||||
quoteNexusSkillsModulesBattle, revealNexusSkillsModulesBattle, stepNexusSkillsModulesBattle,
|
||||
type NexusSkillsModulesBattleFixture,
|
||||
} from './fixture-nexus-skills-modules-battles';
|
||||
|
||||
function custody(game: Game): void {
|
||||
nexusInventory(game);
|
||||
const skills = game.leaderSkills!;
|
||||
assert.deepEqual([...skills.deck, ...skills.assignments.map(assignment => assignment.skill),
|
||||
...Object.values(skills.offers).flatMap(offer => offer.cards)].sort(), LEADER_SKILL_CARDS.map(card => card.id).sort());
|
||||
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(seat => seat.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index) ?? [])];
|
||||
assert.equal(cards.length, 33); assert.equal(new Set(cards.map(card => card.id)).size, 33);
|
||||
for (const seat of game.players) {
|
||||
assert.equal(seat.reserves + seat.tanks + Object.values(seat.forces).reduce((sum, count) => sum + count, 0), 20);
|
||||
if (seat.elites) {
|
||||
assert.equal(seat.elites.reserves + seat.elites.tanks + Object.values(seat.elites.forces).reduce((sum, count) => sum + count, 0),
|
||||
seat.faction === 'fremen' ? 3 : 5);
|
||||
assert.ok(seat.elites.reserves <= seat.reserves && seat.elites.tanks <= seat.tanks);
|
||||
for (const [key, count] of Object.entries(seat.elites.forces)) assert.ok(count <= (seat.forces[key] ?? 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
function reject(game: Game, actor: string, action: Action): void {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
function moneyAtCleanup(fixture: NexusSkillsModulesBattleFixture, revealed: Game, cleanup: Game): void {
|
||||
const quote = quoteNexusSkillsModulesBattle(revealed);
|
||||
assert.equal(cleanup.decision?.kind, 'battleCards');
|
||||
assert.equal(cleanup.lastBattleContext!.winner, quote.winner);
|
||||
for (const seat of revealed.players) {
|
||||
const debit = quote.payments.filter(payment => payment.player === seat.id).reduce((sum, payment) => sum + payment.ownPayment, 0);
|
||||
const income = quote.strongholdIncome.filter(payment => payment.player === seat.id).reduce((sum, payment) => sum + payment.amount, 0);
|
||||
const bounty = quote.bounty?.player === seat.id ? quote.bounty.amount : 0;
|
||||
assert.equal(player(cleanup, seat.id).spice, seat.spice - debit + income + bounty,
|
||||
'Actual payer debit and printed-disc bounty settle once; the bank subsidy is not personal income');
|
||||
}
|
||||
assert.deepEqual(cleanup.techTokens, revealed.techTokens, 'Winner Tech reward waits for original card cleanup');
|
||||
assert.deepEqual(cleanup.strongholdCards, fixture.afterFirstMentat.strongholdCards, 'Battle does not acquire or steal a held Stronghold Card');
|
||||
custody(cleanup);
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) for (const count of [3, 4, 5, 6]) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} Tech ${count}: original Storm assignment, saved hands/offers, alliance and closing Emperor draw are replayable`, () => {
|
||||
const fixture = createNexusSkillsModulesBattleFixture({ advanced, tech: true, strongholds: advanced,
|
||||
seatIds: Array.from({ length: count }, (_, index) => `human-${advanced}-${count}-${index}`) });
|
||||
for (const game of [fixture.initial, fixture.afterSetup, fixture.afterFirstStorm, fixture.afterFirstMentat, fixture.afterDraw, fixture.beforeBattle]) custody(game);
|
||||
assert.ok(TECH_TOKENS.some(token => fixture.beforeFirstStorm.techTokens![token.id].owner === null));
|
||||
const storm = stepNexusSkillsModulesBattle(fixture.beforeFirstStorm, fixture.firstStormStep);
|
||||
assert.deepEqual(storm, fixture.afterFirstStorm);
|
||||
assert.ok(TECH_TOKENS.every(token => storm.techTokens![token.id].owner));
|
||||
assert.equal(new Set(TECH_TOKENS.map(token => storm.techTokens![token.id].owner)).size, 3);
|
||||
const mentat = stepNexusSkillsModulesBattle(fixture.beforeFirstMentat, fixture.firstMentatStep);
|
||||
assert.deepEqual(mentat, fixture.afterFirstMentat);
|
||||
if (advanced) {
|
||||
assert.deepEqual(mentat.strongholdCards!.owners, strongholdControllers(fixture.beforeFirstMentat.players, false));
|
||||
assert.equal(mentat.strongholdCards!.owners.arrakeen, fixture.owner);
|
||||
assert.equal(strongholdControllers(fixture.beforeBattle.players, false).arrakeen, null,
|
||||
'Real later contested positions differ from retained first-Mentat custody');
|
||||
}
|
||||
let allied = fixture.beforeAlliance;
|
||||
for (const action of fixture.allianceActions) allied = stepNexusSkillsModulesBattle(allied, action);
|
||||
assert.equal(player(allied, fixture.observer).ally, fixture.partner);
|
||||
assert.equal(player(allied, fixture.partner).ally, fixture.observer);
|
||||
assert.equal(player(fixture.beforeDraw, fixture.owner).ally, null);
|
||||
const drawn = stepNexusSkillsModulesBattle(fixture.beforeDraw, fixture.drawAction);
|
||||
assert.deepEqual(drawn, fixture.afterDraw);
|
||||
assert.equal(drawn.nexusCards!.cards!.hands[fixture.owner], 'emperor');
|
||||
let saved = structuredClone(fixture.initial);
|
||||
while (saved.setupStage === 'prediction') saved = stepNexusSkillsModulesBattle(saved);
|
||||
const resumed = createNexusSkillsModulesBattleFixture({ initial: saved, skill: 'warmaster' });
|
||||
assert.deepEqual(resumed.initial, saved);
|
||||
assert.deepEqual(resumed.initial.players.map(seat => [seat.id, seat.hand]), saved.players.map(seat => [seat.id, seat.hand]));
|
||||
assert.deepEqual(resumed.initial.leaderSkills!.offers, saved.leaderSkills!.offers);
|
||||
const unavailable = LEADER_SKILL_CARDS.find(card => !saved.leaderSkills!.offers[fixture.owner]?.cards.includes(card.id))!;
|
||||
reject(fixture.beforeBattle, fixture.observer, fixture.battleAction.action);
|
||||
assert.throws(() => createNexusSkillsModulesBattleFixture({ initial: saved, skill: unavailable.id }));
|
||||
assert.deepEqual(saved, resumed.initial);
|
||||
});
|
||||
}
|
||||
|
||||
for (const band of ['normal', 'skilled'] as const) {
|
||||
void test(`held Arrakeen + ${band} Warmaster: bank two reduces debit, not three supported temporary-counter casualties or printed bounty`, () => {
|
||||
const fixture = createNexusSkillsModulesBattleFixture({ tech: true, strongholds: true, band, ownerWeapon: 'projectile', ownerDefense: 'worthless' });
|
||||
const revealed = revealNexusSkillsModulesBattle(fixture, { cunning: true });
|
||||
const forces = viewGame(revealed, fixture.owner).battle!.ownForces!;
|
||||
const quote = quoteNexusSkillsModulesBattle(revealed);
|
||||
assert.equal(forces.normal, 7); assert.equal(forces.elite, 0); assert.equal(forces.temporaryElite, 5);
|
||||
assert.equal(maxCombatDial(forces), 12);
|
||||
const payment = quote.payments.find(payment => payment.player === fixture.owner)!;
|
||||
assert.equal(payment.bankSupport, 2); assert.equal(payment.ownPayment, 1); assert.equal(payment.allyPayment, 0);
|
||||
assert.deepEqual(quote.casualties!.options.map(loss => [loss.normal, loss.elite, loss.paidNormal, loss.paidElite]),
|
||||
[[3, 0, 3, 0], [4, 0, 3, 0], [5, 0, 3, 0]]);
|
||||
const bonus = revealed.battle!.attacker === fixture.owner ? quote.leaderSkillBonuses.attacker : quote.leaderSkillBonuses.defender;
|
||||
assert.equal(bonus.bonus, band === 'skilled' ? 3 : 1);
|
||||
const opposingDisc = player(revealed, fixture.opponent).leaders.find(disc => disc.id === revealed.battle!.plans[fixture.opponent].leader)!;
|
||||
assert.equal(quote.bounty!.amount, opposingDisc.strength);
|
||||
let losses = finishNexusSkillsModulesBattle(revealed, { stop: 'losses' });
|
||||
assert.ok(losses.decision?.kind === 'battleLosses');
|
||||
reject(losses, fixture.opponent, { type: 'decision', choice: 2 });
|
||||
losses = applyAction(losses, fixture.owner, { type: 'decision', choice: 2 });
|
||||
assert.equal(player(losses, fixture.owner).tanks, player(revealed, fixture.owner).tanks + 5);
|
||||
assert.deepEqual(player(losses, fixture.owner).elites, player(revealed, fixture.owner).elites);
|
||||
assert.equal(losses.nexusSardaukarHistory!.at(-1)!.casualties!.outcome, 'complete');
|
||||
moneyAtCleanup(fixture, revealed, losses);
|
||||
const token = ownedTech(revealed.techTokens, fixture.opponent); assert.equal(token.length, 1);
|
||||
const done = finishNexusSkillsModulesBattle(losses);
|
||||
assert.equal(done.techTokens![token[0]].owner, fixture.owner);
|
||||
assert.deepEqual(ownedTech(done.techTokens, fixture.opponent), []);
|
||||
custody(done);
|
||||
});
|
||||
}
|
||||
|
||||
for (const band of ['normal', 'skilled'] as const) for (const support of [3, 6]) {
|
||||
void test(`${band} Suk with held Arrakeen support ${support}: original support eligibility survives bank subsidy and rescue settles before winner reward`, () => {
|
||||
const fixture = createNexusSkillsModulesBattleFixture({ tech: true, strongholds: true, skill: 'suk-graduate', band,
|
||||
support, ownerDial: support === 6 ? 10 : 6, ownerDefense: 'shield' });
|
||||
const revealed = revealNexusSkillsModulesBattle(fixture, { cunning: true });
|
||||
const quote = quoteNexusSkillsModulesBattle(revealed);
|
||||
const physicalLoss = support === 6 ? 6 : band === 'normal' ? 4 : 5;
|
||||
const payment = quote.payments.find(payment => payment.player === fixture.owner)!;
|
||||
assert.equal(payment.bankSupport, 2); assert.equal(payment.ownPayment, support - 2);
|
||||
if (support === 6) {
|
||||
assert.deepEqual(quote.casualties!.options, [{ normal: 6, elite: 0, paidNormal: 6, paidElite: 0 }]);
|
||||
assert.notDeepEqual(quote.casualties!.options, casualtyOptions(quote.casualties!.forces, 10, payment.ownPayment));
|
||||
}
|
||||
let game: Game;
|
||||
if (support === 3) {
|
||||
const losses = finishNexusSkillsModulesBattle(revealed, { stop: 'losses' });
|
||||
assert.ok(losses.decision?.kind === 'battleLosses');
|
||||
game = applyAction(losses, fixture.owner, { type: 'decision', choice: band === 'normal' ? 1 : 2 });
|
||||
} else game = finishNexusSkillsModulesBattle(revealed, { stop: band === 'skilled' ? 'rescue' : 'cleanup' });
|
||||
const before = player(revealed, fixture.owner);
|
||||
if (band === 'skilled') {
|
||||
const decision = game.decision; assert.ok(decision?.kind === 'sukRescue');
|
||||
const pending = game.pendingSukRescue!;
|
||||
assert.equal(pending.commitment.forces.temporaryElite, 5);
|
||||
assert.ok(pending.losses);
|
||||
assert.equal(pending.losses.normal, physicalLoss); assert.equal(pending.losses.paidNormal, support);
|
||||
assert.equal(game.nexusSardaukarHistory!.at(-1)!.casualties!.outcome, 'pending');
|
||||
assert.equal(player(game, fixture.owner).tanks, before.tanks);
|
||||
assert.deepEqual(game.techTokens, revealed.techTokens);
|
||||
const choice = decision.options.findIndex(option => option.normal === 3 && option.elite === 0 && option.kept?.key === fixture.key && option.kept.kind === 'normal');
|
||||
assert.ok(choice >= 0);
|
||||
const action: Action = { type: 'decision', event: decision.event, choice };
|
||||
reject(game, fixture.opponent, action);
|
||||
reject(game, fixture.owner, { ...action, event: `${decision.event}-stale` });
|
||||
game = applyAction(game, fixture.owner, action);
|
||||
reject(game, fixture.owner, action);
|
||||
}
|
||||
const saved = band === 'skilled' ? 3 : 1, kept = band === 'skilled' ? 1 : 0;
|
||||
assert.equal(player(game, fixture.owner).forces[fixture.key], 7 - physicalLoss + kept);
|
||||
assert.equal(player(game, fixture.owner).reserves, before.reserves + saved - kept);
|
||||
assert.equal(player(game, fixture.owner).tanks, before.tanks + physicalLoss - saved);
|
||||
assert.equal(player(game, fixture.owner).battleLosses, before.battleLosses + physicalLoss - saved);
|
||||
assert.deepEqual(player(game, fixture.owner).elites, before.elites);
|
||||
assert.equal(game.pendingSukRescue, null);
|
||||
assert.equal(game.lastBattleContext!.sukRescue!.completed, true);
|
||||
assert.equal(game.nexusSardaukarHistory!.at(-1)!.casualties!.outcome, 'complete');
|
||||
moneyAtCleanup(fixture, revealed, game);
|
||||
custody(finishNexusSkillsModulesBattle(game));
|
||||
});
|
||||
}
|
||||
|
||||
void test('actual starred Sardaukar block ordinary-as-Sardaukar Cunning; native typed Suk rescue keeps the selected star rather than creating an elite Tank', () => {
|
||||
const fixture = createNexusSkillsModulesBattleFixture({ tech: true, strongholds: true, skill: 'suk-graduate', ownerElites: 1,
|
||||
ownerDial: 5, support: 3, ownerDefense: 'shield' });
|
||||
const prepared = prepareNexusSkillsModulesBattle(fixture);
|
||||
const offer = viewGame(prepared, fixture.owner).nexusSardaukar!.offer!;
|
||||
reject(prepared, fixture.owner, { type: 'nexusSardaukar', event: offer.event });
|
||||
assert.equal(prepared.nexusCards!.cards!.hands[fixture.owner], 'emperor');
|
||||
const revealed = revealNexusSkillsModulesBattle(fixture, { state: prepared });
|
||||
const own = viewGame(revealed, fixture.owner).battle!.ownForces!;
|
||||
assert.equal(own.normal, 7); assert.equal(own.elite, 1); assert.equal(own.temporaryElite ?? 0, 0);
|
||||
let game = finishNexusSkillsModulesBattle(revealed, { stop: 'losses' });
|
||||
assert.ok(game.decision?.kind === 'battleLosses');
|
||||
const loss = game.decision.options.findIndex(option => option.normal === 4 && option.elite === 1); assert.ok(loss >= 0);
|
||||
game = applyAction(game, fixture.owner, { type: 'decision', choice: loss });
|
||||
const rescue = game.decision; assert.ok(rescue?.kind === 'sukRescue');
|
||||
const choice = rescue.options.findIndex(option => option.normal === 2 && option.elite === 1 && option.kept?.kind === 'elite'); assert.ok(choice >= 0);
|
||||
const original = player(revealed, fixture.owner);
|
||||
const physicalRescue = structuredClone(game);
|
||||
game = applyAction(game, fixture.owner, { type: 'decision', event: rescue.event, choice });
|
||||
assert.equal(player(game, fixture.owner).forces[fixture.key], 4);
|
||||
assert.equal(player(game, fixture.owner).elites!.forces[fixture.key], 1);
|
||||
assert.equal(player(game, fixture.owner).reserves, original.reserves + 2);
|
||||
assert.equal(player(game, fixture.owner).elites!.reserves, original.elites!.reserves);
|
||||
assert.equal(player(game, fixture.owner).tanks, original.tanks + 2);
|
||||
assert.equal(player(game, fixture.owner).elites!.tanks, original.elites!.tanks);
|
||||
moneyAtCleanup(fixture, revealed, game);
|
||||
custody(finishNexusSkillsModulesBattle(game));
|
||||
const normalChoice = rescue.options.findIndex(option => option.normal === 3 && option.elite === 0 && option.kept?.kind === 'normal');
|
||||
assert.ok(normalChoice >= 0);
|
||||
const ordinary = applyAction(physicalRescue, fixture.owner, { type: 'decision', event: rescue.event, choice: normalChoice });
|
||||
assert.equal(player(ordinary, fixture.owner).forces[fixture.key], 4);
|
||||
assert.equal(player(ordinary, fixture.owner).elites!.forces[fixture.key] ?? 0, 0);
|
||||
assert.equal(player(ordinary, fixture.owner).reserves, original.reserves + 2);
|
||||
assert.equal(player(ordinary, fixture.owner).elites!.reserves, original.elites!.reserves);
|
||||
assert.equal(player(ordinary, fixture.owner).tanks, original.tanks + 2);
|
||||
assert.equal(player(ordinary, fixture.owner).elites!.tanks, original.elites!.tanks + 1,
|
||||
'An unsaved actual starred casualty enters starred Tanks; temporary ordinary roles never do');
|
||||
moneyAtCleanup(fixture, revealed, ordinary);
|
||||
custody(finishNexusSkillsModulesBattle(ordinary));
|
||||
});
|
||||
|
||||
void test('held Carthag physical Shield provides poison protection after first-Mentat positions change, preserving the actual skilled Suk entitlement', () => {
|
||||
for (const strongholds of [false, true]) {
|
||||
const fixture = createNexusSkillsModulesBattleFixture({ tech: true, strongholds, kind: 'carthag', skill: 'suk-graduate',
|
||||
ownerDefense: 'shield', opponentWeapon: 'poison' });
|
||||
const revealed = revealNexusSkillsModulesBattle(fixture, { cunning: true });
|
||||
const quote = quoteNexusSkillsModulesBattle(revealed);
|
||||
const death = revealed.battle!.attacker === fixture.owner ? quote.leaderDeaths.attacker : quote.leaderDeaths.defender;
|
||||
assert.equal(death, !strongholds);
|
||||
assert.equal(!!quote.sukGraduate, strongholds);
|
||||
if (strongholds) {
|
||||
assert.equal(fixture.afterFirstMentat.strongholdCards!.owners.carthag, fixture.owner);
|
||||
assert.equal(strongholdControllers(fixture.beforeBattle.players, false).carthag, null);
|
||||
const rescue = finishNexusSkillsModulesBattle(revealed, { stop: 'rescue' });
|
||||
assert.equal(rescue.decision?.kind, 'sukRescue');
|
||||
const cleanup = finishNexusSkillsModulesBattle(rescue, { stop: 'cleanup' });
|
||||
assert.equal(player(cleanup, fixture.owner).leaders.find(disc => disc.id === fixture.trainer)!.dead, false);
|
||||
moneyAtCleanup(fixture, revealed, cleanup);
|
||||
} else {
|
||||
const cleanup = finishNexusSkillsModulesBattle(revealed, { stop: 'cleanup' });
|
||||
assert.equal(player(cleanup, fixture.owner).leaders.find(disc => disc.id === fixture.trainer)!.dead, true);
|
||||
assert.equal(cleanup.leaderSkills!.assignments.some(assignment => assignment.leader === fixture.trainer), false);
|
||||
assert.equal(cleanup.leaderSkills!.deck.filter(skill => skill === 'suk-graduate').length, 1);
|
||||
assert.equal(cleanup.pendingSukRescue ?? null, null);
|
||||
const printed = player(revealed, fixture.owner).leaders.find(disc => disc.id === fixture.trainer)!.strength;
|
||||
assert.equal(quote.bounty!.amount, printed);
|
||||
moneyAtCleanup(fixture, revealed, cleanup);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('several losing-owner tokens are actually earned by first-turn combat; original rescue, winner disposal, loser retention, mandatory token selection and inspection remain separate', () => {
|
||||
const fixture = createNexusSkillsModulesBattleFixture({ tech: true, strongholds: true, opponentTokens: 'several', opponentNexus: 'moritani',
|
||||
skill: 'suk-graduate', ownerDefense: 'shield', opponentDefense: 'shield' });
|
||||
assert.equal(fixture.warmupAfterBattle!.lastBattleContext!.winner, fixture.opponent);
|
||||
assert.equal(ownedTech(fixture.afterFirstStorm.techTokens, fixture.opponent).length, 1);
|
||||
const original = ownedTech(fixture.game.techTokens, fixture.opponent); assert.equal(original.length, 2);
|
||||
const revealed = revealNexusSkillsModulesBattle(fixture, { cunning: true });
|
||||
const cleanup = finishNexusSkillsModulesBattle(revealed, { stop: 'cleanup' });
|
||||
moneyAtCleanup(fixture, revealed, cleanup);
|
||||
const defense = fixture.planActions[0].action.defense!;
|
||||
const disposal: Action = { type: 'decision', discard: [defense] };
|
||||
reject(cleanup, fixture.opponent, disposal);
|
||||
const disposed = applyAction(cleanup, fixture.owner, disposal);
|
||||
assert.equal(player(disposed, fixture.owner).hand.some(card => card.id === defense), false);
|
||||
assert.equal(disposed.discard.filter(card => card.id === defense).length, 1);
|
||||
const retention = finishNexusSkillsModulesBattle(disposed, { stop: 'retention' });
|
||||
assert.ok(retention.decision?.kind === 'moritaniRetention' && retention.decision.source === 'nexus');
|
||||
assert.deepEqual(retention.techTokens, revealed.techTokens);
|
||||
const keep: Action = { type: 'decision', event: retention.decision.event, keep: fixture.planActions[1].action.defense! };
|
||||
reject(retention, fixture.owner, keep);
|
||||
reject(retention, fixture.opponent, { ...keep, event: `${retention.decision.event}-old` });
|
||||
const retained = applyAction(retention, fixture.opponent, keep);
|
||||
assert.equal(player(retained, fixture.opponent).hand.some(card => card.id === keep.keep), true);
|
||||
assert.equal(retained.nexusCards!.cards!.discard.filter(card => card === 'moritani').length, 1);
|
||||
for (const token of original) {
|
||||
const choice = finishNexusSkillsModulesBattle(retained, { stop: 'tech' });
|
||||
assert.ok(choice.decision?.kind === 'techToken');
|
||||
assert.deepEqual(choice.decision.choices, original);
|
||||
reject(choice, fixture.opponent, { type: 'decision', token });
|
||||
reject(choice, fixture.owner, { type: 'decision', decline: true });
|
||||
const selected = applyAction(choice, fixture.owner, { type: 'decision', token });
|
||||
assert.equal(selected.techTokens![token].owner, fixture.owner);
|
||||
assert.equal(selected.techTokens![original.find(id => id !== token)!].owner, fixture.opponent);
|
||||
const inspection = finishNexusSkillsModulesBattle(selected, { stop: 'inspection' });
|
||||
assert.ok(inspection.decision?.kind === 'nexusChoamInspection');
|
||||
assert.equal(viewGame(inspection, fixture.owner).nexusChoamInspection!.canInspect, false,
|
||||
'Spent Emperor Cunning is not a held CHOAM card and cannot inspect a fresh card');
|
||||
const decline: Action = { type: 'decision', event: inspection.decision.event, inspect: false };
|
||||
reject(inspection, fixture.opponent, decline);
|
||||
reject(inspection, fixture.owner, { ...decline, event: `${inspection.decision.event}-old` });
|
||||
const declined = applyAction(inspection, fixture.owner, decline);
|
||||
reject(declined, fixture.owner, decline);
|
||||
custody(finishNexusSkillsModulesBattle(declined));
|
||||
}
|
||||
});
|
||||
|
||||
void test('physical Karama cancellation leaves held Arrakeen support legal and does not fabricate temporary roles or spend Tech', () => {
|
||||
const fixture = createNexusSkillsModulesBattleFixture({ tech: true, strongholds: true, counter: true, ownerDial: 4, support: 4 });
|
||||
let game = beginNexusSkillsModulesCunning(fixture);
|
||||
assert.equal(game.response?.kind, 'nexusSardaukar');
|
||||
reject(game, fixture.owner, { type: 'card', card: fixture.counter!, mode: 'cancel' });
|
||||
game = applyAction(game, fixture.opponent, { type: 'card', card: fixture.counter!, mode: 'cancel' });
|
||||
game = advanceNexusSkillsModulesBattle(game, state => !state.response && !state.phaseOpening && !state.decision);
|
||||
assert.equal(game.nexusSardaukarLast!.stage, 'canceled');
|
||||
assert.equal(viewGame(game, fixture.owner).battle!.ownForces!.temporaryElite ?? 0, 0);
|
||||
assert.equal(game.discard.filter(card => card.id === fixture.counter).length, 1);
|
||||
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
|
||||
const revealed = revealNexusSkillsModulesBattle(fixture, { state: game });
|
||||
const payment = quoteNexusSkillsModulesBattle(revealed).payments.find(payment => payment.player === fixture.owner)!;
|
||||
assert.equal(payment.bankSupport, 2); assert.equal(payment.ownPayment, 2);
|
||||
moneyAtCleanup(fixture, revealed, finishNexusSkillsModulesBattle(revealed, { stop: 'cleanup' }));
|
||||
});
|
||||
|
||||
void test('Basic Tech keeps the original blocked Cunning offer, no Advanced support fee, and mandatory physical winner reward', () => {
|
||||
const fixture = createNexusSkillsModulesBattleFixture({ advanced: false, tech: true });
|
||||
const prepared = prepareNexusSkillsModulesBattle(fixture);
|
||||
const offer = viewGame(prepared, fixture.owner).nexusSardaukar!.offer!;
|
||||
reject(prepared, fixture.owner, { type: 'nexusSardaukar', event: offer.event });
|
||||
assert.equal(maxCombatDial(viewGame(prepared, fixture.owner).battle!.ownForces!), 7);
|
||||
const revealed = revealNexusSkillsModulesBattle(fixture);
|
||||
const quote = quoteNexusSkillsModulesBattle(revealed);
|
||||
assert.deepEqual(quote.payments, []); assert.equal(quote.casualties, null); assert.equal(quote.basicWinnerLosses, 6);
|
||||
const cleanup = finishNexusSkillsModulesBattle(revealed, { stop: 'cleanup' });
|
||||
assert.equal(player(cleanup, fixture.owner).tanks, player(revealed, fixture.owner).tanks + 6);
|
||||
assert.equal(cleanup.nexusCards!.cards!.hands[fixture.owner], 'emperor');
|
||||
moneyAtCleanup(fixture, revealed, cleanup);
|
||||
const token = ownedTech(revealed.techTokens, fixture.opponent); assert.equal(token.length, 1);
|
||||
const done = finishNexusSkillsModulesBattle(cleanup);
|
||||
assert.equal(done.techTokens![token[0]].owner, fixture.owner);
|
||||
custody(done);
|
||||
});
|
||||
|
||||
void test('a sealed plan and later battle reject stale Cunning; the physical spent group expires through actual END Mentat and next Storm', () => {
|
||||
const fixture = createNexusSkillsModulesBattleFixture({ tech: true, strongholds: true, secondBattle: true, ownerDial: 6, support: 3 });
|
||||
let sealed = prepareNexusSkillsModulesBattle(fixture);
|
||||
const event = viewGame(sealed, fixture.owner).nexusSardaukar!.offer!.event;
|
||||
sealed = applyAction(sealed, fixture.owner, { ...fixture.planActions[0].action, dial: 4, support: 4 });
|
||||
reject(sealed, fixture.owner, { type: 'nexusSardaukar', event });
|
||||
assert.equal(sealed.nexusCards!.cards!.hands[fixture.owner], 'emperor');
|
||||
const revealed = revealNexusSkillsModulesBattle(fixture, { cunning: true });
|
||||
const oldEvent = revealed.nexusSardaukarLast!.event;
|
||||
let game = finishNexusSkillsModulesBattle(revealed);
|
||||
const actor = game.active!;
|
||||
game = applyAction(game, actor, { type: 'chooseBattle', territory: 'carthag', target: actor === fixture.owner ? fixture.opponent : fixture.owner });
|
||||
game = prepareNexusSkillsModulesBattle(fixture, game);
|
||||
assert.equal(viewGame(game, fixture.owner).battle!.ownForces!.normal, 1);
|
||||
assert.equal(viewGame(game, fixture.owner).battle!.ownForces!.temporaryElite ?? 0, 0);
|
||||
reject(game, fixture.owner, { type: 'nexusSardaukar', event: oldEvent });
|
||||
const candidates = (id: string) => player(game, id).leaders.filter(disc => !disc.dead && !disc.usedAt &&
|
||||
!game.leaderSkills!.assignments.some(assignment => assignment.leader === disc.id));
|
||||
for (const id of [fixture.owner, fixture.opponent]) {
|
||||
game = applyAction(game, id, { type: 'battlePlan', leader: candidates(id)[0].id, dial: 0, support: 0, weapon: null, defense: null });
|
||||
while (game.phaseOpening || game.response || game.decision?.kind === 'fullPlanOffer') game = stepNexusSkillsModulesBattle(game);
|
||||
}
|
||||
game = finishNexusSkillsModulesBattle(game);
|
||||
game = advanceNexusSkillsModulesBattle(game, state => state.turn === 3 && state.phase === 1 && !state.response && !state.phaseOpening && !state.decision);
|
||||
assert.equal(viewGame(game, fixture.owner).nexusSardaukar!.active, false);
|
||||
assert.equal(game.nexusSardaukarHistory![0].receipt.turn, 2);
|
||||
assert.equal(game.nexusSardaukarHistory![0].casualties!.outcome, 'complete');
|
||||
reject(game, fixture.owner, { type: 'nexusSardaukar', event: oldEvent });
|
||||
custody(game);
|
||||
});
|
||||
|
||||
void test('four original legal policies rescue three physical ordinary counters and retain their native support budget before cleanup and Tech transfer', () => {
|
||||
const fixture = createNexusSkillsModulesBattleFixture({ tech: true, strongholds: true, skill: 'suk-graduate', support: 6,
|
||||
ownerDial: 10, ownerDefense: 'shield' });
|
||||
const revealed = revealNexusSkillsModulesBattle(fixture, { cunning: true });
|
||||
const rescue = finishNexusSkillsModulesBattle(revealed, { stop: 'rescue' });
|
||||
assert.ok(rescue.decision?.kind === 'sukRescue');
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const view = viewGame(rescue, fixture.owner);
|
||||
view.players.find(seat => seat.id === fixture.owner)!.bot = difficulty;
|
||||
const action = botActions(view).find(action => action.type === 'decision');
|
||||
assert.ok(action && typeof action.choice === 'number');
|
||||
const option: (typeof rescue.decision.options)[number] = rescue.decision.options[action.choice];
|
||||
assert.equal(option.normal, 3); assert.equal(option.elite, 0); assert.equal(option.kept?.key, fixture.key);
|
||||
const cleanup = applyAction(rescue, fixture.owner, action);
|
||||
assert.equal(player(cleanup, fixture.owner).forces[fixture.key], 2);
|
||||
assert.equal(player(cleanup, fixture.owner).tanks, player(revealed, fixture.owner).tanks + 3);
|
||||
assert.equal(player(cleanup, fixture.owner).spice, player(revealed, fixture.owner).spice - 4);
|
||||
moneyAtCleanup(fixture, revealed, cleanup);
|
||||
custody(finishNexusSkillsModulesBattle(cleanup));
|
||||
let prepared = prepareNexusSkillsModulesBattle(fixture);
|
||||
const openingView = viewGame(prepared, fixture.owner);
|
||||
openingView.players.find(seat => seat.id === fixture.owner)!.bot = difficulty;
|
||||
const cunning = botActions(openingView).find(action => action.type === 'nexusSardaukar'); assert.ok(cunning);
|
||||
prepared = applyAction(prepared, fixture.owner, cunning);
|
||||
prepared = advanceNexusSkillsModulesBattle(prepared, state => !state.response && !state.phaseOpening && !state.decision);
|
||||
const planView = viewGame(prepared, fixture.owner);
|
||||
planView.players.find(seat => seat.id === fixture.owner)!.bot = difficulty;
|
||||
const plan = botActions(planView).find(action => action.type === 'battlePlan'); assert.ok(plan);
|
||||
assert.equal(typeof plan.support, 'number');
|
||||
assert.ok(Number(plan.support) <= player(prepared, fixture.owner).spice + 2);
|
||||
const committed = applyAction(prepared, fixture.owner, plan);
|
||||
assert.ok(committed.battle!.plans[fixture.owner]);
|
||||
assert.equal(player(committed, fixture.owner).spice, player(prepared, fixture.owner).spice,
|
||||
'The legal bot seals its budget; no debit is taken before actual battle resolution');
|
||||
}
|
||||
});
|
||||
|
||||
void test('Advanced Strongholds without Tech retain the same original Skills/Nexus Arrakeen rescue and physical inventory', () => {
|
||||
const fixture = createNexusSkillsModulesBattleFixture({ tech: false, strongholds: true, skill: 'suk-graduate',
|
||||
support: 6, ownerDial: 10, ownerDefense: 'shield' });
|
||||
const revealed = revealNexusSkillsModulesBattle(fixture, { cunning: true });
|
||||
const quote = quoteNexusSkillsModulesBattle(revealed);
|
||||
assert.equal(quote.payments.find(payment => payment.player === fixture.owner)!.ownPayment, 4);
|
||||
const cleanup = finishNexusSkillsModulesBattle(revealed, { stop: 'cleanup' });
|
||||
assert.equal(player(cleanup, fixture.owner).tanks, player(revealed, fixture.owner).tanks + 3);
|
||||
assert.equal(cleanup.pendingTech ?? null, null);
|
||||
moneyAtCleanup(fixture, revealed, cleanup);
|
||||
custody(finishNexusSkillsModulesBattle(cleanup));
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
import { quoteSpiceCollection } from '../game/board-resolution-quote';
|
||||
import { forceRevivalQuote, forceRevivalRemaining } from '../game/revival';
|
||||
import {
|
||||
advanceNexusSkillsModulesPayments, advanceNexusSkillsModulesPaymentsStep,
|
||||
advanceNexusSkillsModulesPaymentsToPhase, createNexusSkillsModulesPaymentsFixture,
|
||||
finishNexusSkillsModulesPaymentsFixture, holdNexusSkillsModulesPaymentsCard,
|
||||
initializeNexusSkillsModulesPaymentsSetup, nexusSkillsModulesPaymentsInventory,
|
||||
nexusSkillsModulesPaymentsPlayer, payNexusSkillsModulesPaymentsFixture,
|
||||
type NexusSkillsModulesPaymentsFixture,
|
||||
} from './fixture-nexus-skills-modules-payments';
|
||||
|
||||
const wallets = (game: Game) => Object.fromEntries(game.players.map(player => [player.id, player.spice]));
|
||||
function rejected(game: Game, actor: string, action: Action): void {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before, 'A rejected private action cannot mutate cards, counters or original accounting');
|
||||
}
|
||||
function custody(before: Game, after: Game): void {
|
||||
assert.deepEqual(after.leaderSkills, before.leaderSkills);
|
||||
assert.deepEqual(after.strongholdCards, before.strongholdCards,
|
||||
'A return, purchase or shipment grants no new Stronghold revival/payment advantage');
|
||||
for (const player of before.players)
|
||||
assert.deepEqual(viewGame(after, player.id).leaderSkills!.assignments,
|
||||
viewGame(before, player.id).leaderSkills!.assignments);
|
||||
nexusSkillsModulesPaymentsInventory(after);
|
||||
}
|
||||
/** Quote city Collection independently when native empty Battle auto-skips. */
|
||||
function collectOnce(f: NexusSkillsModulesPaymentsFixture, paid: Game, amount: number): Game {
|
||||
const expected = wallets(paid);
|
||||
let ended = advanceNexusSkillsModulesPaymentsToPhase(paid, paid.phase + 1);
|
||||
if (ended.phase === 7)
|
||||
for (const receipt of quoteSpiceCollection(paid).receipts) expected[receipt.player] = receipt.balance;
|
||||
if (f.tokenOwner) expected[f.tokenOwner] += amount;
|
||||
assert.deepEqual(wallets(ended), expected, 'Only the original phase-end pile and separately quoted city collection become spendable');
|
||||
if (ended.techTokens) assert.equal(ended.techTokens[f.token].spice, 0);
|
||||
const collection = advanceNexusSkillsModulesPaymentsToPhase(ended, 7);
|
||||
const atCollection = wallets(collection);
|
||||
ended = advanceNexusSkillsModulesPaymentsToPhase(collection, 8);
|
||||
assert.deepEqual(wallets(ended), atCollection, 'Mentat cannot collect the same Tech pile again');
|
||||
if (ended.techTokens) assert.equal(ended.techTokens[f.token].spice, 0);
|
||||
const continued = advanceNexusSkillsModulesPaymentsStep(ended);
|
||||
assert.deepEqual(wallets(continued), atCollection);
|
||||
custody(paid, continued);
|
||||
return continued;
|
||||
}
|
||||
const configurations = [
|
||||
{ name: 'Basic Tech', advanced: false, tech: true, strongholds: false },
|
||||
{ name: 'Advanced Tech', advanced: true, tech: true, strongholds: false },
|
||||
{ name: 'Advanced Strongholds', advanced: true, tech: false, strongholds: true },
|
||||
{ name: 'Advanced Tech/Strongholds', advanced: true, tech: true, strongholds: true },
|
||||
] as const;
|
||||
|
||||
// Original Nexus Emperor extra-three and the recorded original Fremen free-three
|
||||
// interpretation remain distinct. E1 printed p5: free revivals trigger Axlotl,
|
||||
// payable only at Revival end (http://boardgame.bg/dune%20ixians%20and%20tleilaxu%20rulebook.pdf).
|
||||
for (const config of configurations) {
|
||||
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
|
||||
void test(`${config.name} original all14/33/12 ${kind} conserves physical returns, quota and phase-end Axlotl once`, () => {
|
||||
const f = createNexusSkillsModulesPaymentsFixture({ ...config, kind });
|
||||
assert.equal(f.afterSetup.leaderSkills!.assignments.length, f.afterSetup.players.length);
|
||||
assert.equal(f.beforeFirstMentat.turn, 1); assert.equal(f.afterFirstMentat.turn, 2);
|
||||
if (config.tech) {
|
||||
assert.ok(Object.values(f.afterFirstMentat.techTokens!).every(token => token.owner));
|
||||
}
|
||||
if (config.strongholds) {
|
||||
assert.equal(f.beforeFirstMentat.strongholdCards!.owners.arrakeen, null);
|
||||
assert.equal(f.afterFirstMentat.strongholdCards!.owners.arrakeen, f.actor);
|
||||
}
|
||||
assert.equal(f.beforeClosingDraw.nexusCards!.phase!.stage, 'drawing');
|
||||
assert.equal(f.beforeClosingDraw.nexusCards!.cards!.hands[f.actor], null);
|
||||
assert.equal(f.afterClosingDraw.nexusCards!.cards!.hands[f.actor], kind === 'emperor-revival' ? 'emperor' : 'fremen');
|
||||
const before = nexusSkillsModulesPaymentsPlayer(f.game, f.actor);
|
||||
rejected(f.game, f.opponent, f.paymentAction);
|
||||
rejected(f.game, f.actor, { ...f.paymentAction, event: 'stale' });
|
||||
let paid = payNexusSkillsModulesPaymentsFixture(f);
|
||||
let actor = nexusSkillsModulesPaymentsPlayer(paid, f.actor);
|
||||
assert.equal(actor.reserves, before.reserves + 3); assert.equal(actor.tanks, before.tanks - 3);
|
||||
assert.deepEqual(wallets(paid), wallets(f.game), 'A free return does not collect the accrued token or mint personal income');
|
||||
assert.equal(actor.revived, kind === 'fremen-revival' ? 3 : before.revived);
|
||||
assert.equal(actor.freeForcesRevived ?? 0, kind === 'fremen-revival' ? 3 : (before.freeForcesRevived ?? 0));
|
||||
assert.equal(paid.nexusCards!.cards!.hands[f.actor], null);
|
||||
assert.equal(paid.nexusCards!.cards!.discard.filter(card => card === (kind === 'emperor-revival' ? 'emperor' : 'fremen')).length, 1);
|
||||
const receipt = kind === 'emperor-revival' ? paid.nexusEmperorSecretHistory![0] : paid.nexusFremenRevivalHistory![0];
|
||||
assert.equal(receipt.owner, f.actor); assert.equal(receipt.turn, 2); assert.equal(receipt.phase, 4);
|
||||
assert.equal(receipt.before.tanks - receipt.after.tanks, 3);
|
||||
assert.equal(receipt.before.spice, receipt.after.spice);
|
||||
if (config.tech) {
|
||||
assert.equal(paid.techTokens!.axlotl.spice, f.tokenCount);
|
||||
assert.equal(paid.techTokens!.axlotl.triggeredTurn, paid.turn);
|
||||
}
|
||||
custody(f.game, paid); rejected(paid, f.actor, f.paymentAction);
|
||||
if (kind === 'fremen-revival') {
|
||||
assert.equal(forceRevivalRemaining(paid, actor), 0);
|
||||
rejected(paid, f.actor, { type: 'revive', amount: 1 });
|
||||
} else {
|
||||
assert.equal(forceRevivalRemaining(paid, actor), 3);
|
||||
const quote = forceRevivalQuote(paid, actor, 3);
|
||||
assert.equal(quote.cost, 2, 'A Stronghold cannot increase the original Atreides two-free allowance');
|
||||
paid = applyAction(paid, f.actor, { type: 'revive', amount: 3 });
|
||||
paid = finishNexusSkillsModulesPaymentsFixture(f, paid);
|
||||
actor = nexusSkillsModulesPaymentsPlayer(paid, f.actor);
|
||||
assert.equal(actor.reserves, before.reserves + 6); assert.equal(actor.tanks, before.tanks - 6);
|
||||
assert.equal(actor.spice, before.spice - quote.cost);
|
||||
assert.equal(actor.revived, 3); assert.equal(actor.freeForcesRevived, 2);
|
||||
if (config.tech) assert.equal(paid.techTokens!.axlotl.spice, f.tokenCount, 'A second genuine free batch cannot accrue Axlotl twice');
|
||||
}
|
||||
custody(f.game, paid);
|
||||
const mentat = collectOnce(f, paid, f.tokenCount);
|
||||
assert.equal(nexusSkillsModulesPaymentsPlayer(mentat, f.actor).revived, 3);
|
||||
const nextTurn = advanceNexusSkillsModulesPayments(mentat, game => game.turn === 3);
|
||||
const nextRevival = advanceNexusSkillsModulesPaymentsToPhase(nextTurn, 4);
|
||||
assert.equal(nexusSkillsModulesPaymentsPlayer(nextRevival, f.actor).revived, 0);
|
||||
assert.equal(nexusSkillsModulesPaymentsPlayer(nextRevival, f.actor).freeForcesRevived ?? 0, 0);
|
||||
assert.deepEqual(nextRevival.nexusEmperorSecretHistory, paid.nexusEmperorSecretHistory);
|
||||
assert.deepEqual(nextRevival.nexusFremenRevivalHistory, paid.nexusFremenRevivalHistory);
|
||||
});
|
||||
}
|
||||
|
||||
void test(`${config.name} normal Emperor bank-auction purchase delivers the real lot once without payment, industry or normal Banker award`, () => {
|
||||
const f = createNexusSkillsModulesPaymentsFixture({ ...config, kind: 'emperor-purchase', skill: 'spice-banker' });
|
||||
const auction = f.game.auction!, lot = auction.cards[auction.index];
|
||||
const buyer = nexusSkillsModulesPaymentsPlayer(f.game, f.actor);
|
||||
assert.equal(auction.bid, 2); assert.equal(auction.bidder, f.actor); assert.equal(auction.allyPayment ?? 0, 0);
|
||||
assert.ok(buyer.spice >= 2); assert.ok(!buyer.hand.some(card => card.id === lot.id));
|
||||
assert.ok(f.game.leaderSkills!.assignments.some(a => a.owner === f.actor && a.skill === 'spice-banker'));
|
||||
rejected(f.game, f.opponent, f.paymentAction);
|
||||
const view = viewGame(f.game, f.actor); view.players.find(p => p.id === f.actor)!.bot = 'Hard';
|
||||
const bot = botActions(view).find(action => action.type === 'nexusEmperorPurchase');
|
||||
assert.deepEqual(bot, f.paymentAction);
|
||||
const paid = applyAction(f.game, f.actor, bot!);
|
||||
assert.deepEqual(wallets(paid), wallets(f.game));
|
||||
assert.equal(nexusSkillsModulesPaymentsPlayer(paid, f.actor).hand.filter(card => card.id === lot.id).length, 1);
|
||||
const receipt = paid.nexusEmperorPurchaseHistory![0];
|
||||
assert.equal(receipt.card, lot.id); assert.equal(receipt.price, 2);
|
||||
assert.equal(receipt.beforeSpice, buyer.spice); assert.equal(receipt.afterSpice, buyer.spice);
|
||||
assert.equal(paid.nexusEmperorPurchaseHistory!.length, 1);
|
||||
assert.deepEqual(paid.techTokens, f.game.techTokens);
|
||||
assert.equal(viewGame(paid, f.actor).spiceBankerIncome, null);
|
||||
assert.equal(paid.spiceBankerIncome, undefined);
|
||||
assert.equal(paid.currentAuctionSale?.spiceBankerIncomePayment, undefined);
|
||||
custody(f.game, paid); rejected(paid, f.actor, f.paymentAction);
|
||||
const continued = advanceNexusSkillsModulesPaymentsToPhase(paid, 4);
|
||||
assert.equal(nexusSkillsModulesPaymentsPlayer(continued, f.actor).hand.filter(card => card.id === lot.id).length, 1);
|
||||
assert.deepEqual(continued.nexusEmperorPurchaseHistory, paid.nexusEmperorPurchaseHistory);
|
||||
assert.deepEqual(continued.techTokens, f.game.techTokens); custody(f.game, continued);
|
||||
});
|
||||
|
||||
void test(`${config.name} source-clear ordinary non-Guild shipment uses native full price and phase-end Heighliners`, () => {
|
||||
const f = createNexusSkillsModulesPaymentsFixture({ ...config, kind: 'native-shipment' });
|
||||
const before = nexusSkillsModulesPaymentsPlayer(f.game, f.actor);
|
||||
const paid = finishNexusSkillsModulesPaymentsFixture(f, payNexusSkillsModulesPaymentsFixture(f));
|
||||
const after = nexusSkillsModulesPaymentsPlayer(paid, f.actor);
|
||||
assert.equal(after.faction, 'atreides'); assert.equal(f.cost, f.amount);
|
||||
assert.equal(after.spice, before.spice - f.cost);
|
||||
assert.equal(after.reserves, before.reserves - f.amount);
|
||||
assert.equal(after.forces[f.destination!], f.amount); assert.equal(after.shipped, true);
|
||||
assert.equal(paid.nexusCards!.cards!.hands[f.actor], 'emperor', 'Ordinary shipping cannot spend a private unused Nexus singleton');
|
||||
if (config.tech) {
|
||||
assert.equal(paid.techTokens!.heighliners.spice, f.tokenCount);
|
||||
assert.equal(paid.techTokens!.heighliners.triggeredTurn, paid.turn);
|
||||
}
|
||||
custody(f.game, paid); rejected(paid, f.actor, f.paymentAction);
|
||||
collectOnce(f, paid, f.tokenCount);
|
||||
});
|
||||
}
|
||||
|
||||
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
|
||||
void test(`Advanced combined ${kind} real bot returns one original elite and respects the shared per-turn cap`, () => {
|
||||
const f = createNexusSkillsModulesPaymentsFixture({ kind, advanced: true, tech: true, strongholds: true,
|
||||
ownerFaction: kind === 'emperor-revival' ? 'fremen' : 'emperor', eliteTanks: 2 });
|
||||
const view = viewGame(f.game, f.actor);
|
||||
const offer = kind === 'emperor-revival' ? view.nexusEmperorSecretAlly!.revival : view.nexusFremenRevival!;
|
||||
assert.deepEqual(offer.eliteOptions, [0, 1]);
|
||||
rejected(f.game, f.actor, { ...f.paymentAction, elite: 2 });
|
||||
view.players.find(p => p.id === f.actor)!.bot = 'Hard';
|
||||
const bot = botActions(view).find(action => action.type === f.paymentAction.type); assert.ok(bot);
|
||||
const paid = applyAction(f.game, f.actor, bot);
|
||||
const before = nexusSkillsModulesPaymentsPlayer(f.game, f.actor), after = nexusSkillsModulesPaymentsPlayer(paid, f.actor);
|
||||
assert.equal(after.elites!.reserves, before.elites!.reserves + 1);
|
||||
assert.equal(after.elites!.tanks, before.elites!.tanks - 1); assert.equal(after.elites!.revived, 1);
|
||||
assert.equal(after.reserves, before.reserves + 3); assert.equal(after.tanks, before.tanks - 3);
|
||||
assert.deepEqual(wallets(paid), wallets(f.game));
|
||||
rejected(paid, f.actor, { type: 'revive', amount: 1, elite: 1 });
|
||||
custody(f.game, paid); collectOnce(f, paid, f.tokenCount);
|
||||
});
|
||||
}
|
||||
|
||||
void test('combined ordinary-first Emperor extra-three and early-only Fremen preserve their different ledgers and exact-three boundary', () => {
|
||||
const extra = createNexusSkillsModulesPaymentsFixture({ advanced: true, strongholds: true });
|
||||
const ordinary = applyAction(extra.game, extra.actor, { type: 'revive', amount: 3 });
|
||||
const paid = payNexusSkillsModulesPaymentsFixture(extra, ordinary);
|
||||
const before = nexusSkillsModulesPaymentsPlayer(ordinary, extra.actor), after = nexusSkillsModulesPaymentsPlayer(paid, extra.actor);
|
||||
assert.equal(after.reserves, before.reserves + 3); assert.equal(after.tanks, before.tanks - 3);
|
||||
assert.equal(after.spice, before.spice); assert.equal(after.revived, 3); assert.equal(after.freeForcesRevived, 2);
|
||||
assert.equal(paid.techTokens!.axlotl.spice, ordinary.techTokens!.axlotl.spice);
|
||||
const fremen = createNexusSkillsModulesPaymentsFixture({ kind: 'fremen-revival', advanced: true, strongholds: true });
|
||||
const late = applyAction(fremen.game, fremen.actor, { type: 'revive', amount: 1 });
|
||||
assert.ok(viewGame(late, fremen.actor).nexusFremenRevival!.blocked);
|
||||
rejected(late, fremen.actor, fremen.paymentAction);
|
||||
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
|
||||
const short = createNexusSkillsModulesPaymentsFixture({ kind, advanced: true, strongholds: true, tanks: 2 });
|
||||
rejected(short.game, short.actor, short.paymentAction);
|
||||
assert.equal(nexusSkillsModulesPaymentsPlayer(short.game, short.actor).tanks, 2);
|
||||
custody(short.game, short.game);
|
||||
}
|
||||
});
|
||||
|
||||
for (const ownerFaction of ['guild', 'fremen'] as const) {
|
||||
void test(`original ${ownerFaction} native shipment cannot activate Heighliners from excluded source identity`, () => {
|
||||
const f = createNexusSkillsModulesPaymentsFixture({ kind: 'native-shipment', ownerFaction, advanced: true, strongholds: true });
|
||||
const before = nexusSkillsModulesPaymentsPlayer(f.game, f.actor);
|
||||
const paid = finishNexusSkillsModulesPaymentsFixture(f, payNexusSkillsModulesPaymentsFixture(f));
|
||||
const after = nexusSkillsModulesPaymentsPlayer(paid, f.actor);
|
||||
assert.equal(after.faction, ownerFaction); assert.equal(after.forces[f.destination!], f.amount);
|
||||
assert.equal(after.reserves, before.reserves - f.amount); assert.equal(after.spice, before.spice - f.cost);
|
||||
assert.equal(f.cost, ownerFaction === 'fremen' ? 0 : Math.ceil(f.amount / 2));
|
||||
assert.equal(paid.techTokens!.heighliners.spice, 0);
|
||||
assert.notEqual(paid.techTokens!.heighliners.triggeredTurn, paid.turn);
|
||||
custody(f.game, paid); collectOnce(f, paid, 0);
|
||||
});
|
||||
}
|
||||
|
||||
void test('an actual Guild interception blocks settlement and original phase-end collection; stopping it pays no invoice or Heighliners', () => {
|
||||
const f = createNexusSkillsModulesPaymentsFixture({ kind: 'native-shipment', advanced: true, strongholds: true, nativeGuild: true });
|
||||
const guild = f.game.players.find(p => p.faction === 'guild')!;
|
||||
const counter = holdNexusSkillsModulesPaymentsCard(f.game, guild.id, 'karama', f.staging);
|
||||
const before = structuredClone(f.game);
|
||||
let game = payNexusSkillsModulesPaymentsFixture(f);
|
||||
assert.equal(game.decision?.kind, 'guildShipment'); assert.ok(game.pendingShipment);
|
||||
assert.deepEqual(wallets(game), wallets(before));
|
||||
assert.equal(game.techTokens!.heighliners.spice, 0);
|
||||
for (const player of game.players) rejected(game, player.id, { type: 'ready' });
|
||||
game = applyAction(game, guild.id, { type: 'card', mode: 'special', card: counter.id });
|
||||
game = finishNexusSkillsModulesPaymentsFixture(f, game);
|
||||
assert.deepEqual(wallets(game), wallets(before));
|
||||
assert.equal(nexusSkillsModulesPaymentsPlayer(game, f.actor).reserves, nexusSkillsModulesPaymentsPlayer(before, f.actor).reserves);
|
||||
assert.equal(nexusSkillsModulesPaymentsPlayer(game, f.actor).forces[f.destination!], undefined);
|
||||
assert.ok(game.discard.some(card => card.id === counter.id));
|
||||
assert.equal(game.techTokens!.heighliners.spice, 0);
|
||||
custody(before, game); collectOnce(f, game, 0);
|
||||
});
|
||||
|
||||
void test('actual initialized module setup preserves authenticated IDs, dealt hands and saved skill offers; explicit missing skill is not replaced', () => {
|
||||
const setup = initializeNexusSkillsModulesPaymentsSetup({ advanced: true, strongholds: true,
|
||||
seatIds: ['authenticated-owner', 'authenticated-second', 'authenticated-third'], skill: 'sandmaster' });
|
||||
const before = structuredClone(setup);
|
||||
const accepted = initializeNexusSkillsModulesPaymentsSetup({ initial: setup });
|
||||
assert.deepEqual(accepted, before);
|
||||
const selected = advanceNexusSkillsModulesPayments(accepted, state => state.leaderSkills!.assignments.length === 1);
|
||||
assert.equal(selected.leaderSkills!.assignments.length, 1);
|
||||
const f = createNexusSkillsModulesPaymentsFixture({ initial: setup, skill: 'sandmaster' });
|
||||
assert.equal(f.actor, 'authenticated-owner'); assert.deepEqual(f.initial, before);
|
||||
assert.deepEqual(f.game.players.map(p => p.id), before.players.map(p => p.id));
|
||||
assert.deepEqual(setup, before);
|
||||
assert.ok(f.game.leaderSkills!.assignments.some(a => a.owner === f.actor && a.skill === 'sandmaster'));
|
||||
assert.throws(() => createNexusSkillsModulesPaymentsFixture({ initial: setup, skill: 'spice-banker' }));
|
||||
assert.deepEqual(setup, before);
|
||||
custody(f.game, payNexusSkillsModulesPaymentsFixture(f));
|
||||
});
|
||||
|
||||
void test('earned combined Axlotl pile stays unavailable while an actual original private Truthtrance is unsettled', () => {
|
||||
const f = createNexusSkillsModulesPaymentsFixture({ advanced: true, strongholds: true });
|
||||
let paid = payNexusSkillsModulesPaymentsFixture(f);
|
||||
const card = holdNexusSkillsModulesPaymentsCard(paid, f.opponent, 'truthtrance', f.staging);
|
||||
const before = structuredClone(paid);
|
||||
paid = applyAction(paid, f.opponent, { type: 'card', card: card.id });
|
||||
assert.ok(paid.truthtrance);
|
||||
assert.equal(paid.phase, 4);
|
||||
assert.equal(paid.techTokens!.axlotl.spice, f.tokenCount);
|
||||
assert.deepEqual(wallets(paid), wallets(before));
|
||||
for (const player of paid.players) rejected(paid, player.id, { type: 'ready' });
|
||||
const settled = finishNexusSkillsModulesPaymentsFixture(f, paid);
|
||||
assert.ok(!settled.truthtrance);
|
||||
assert.equal(settled.phase, 4);
|
||||
assert.equal(settled.techTokens!.axlotl.spice, f.tokenCount);
|
||||
assert.deepEqual(wallets(settled), wallets(before));
|
||||
assert.ok(settled.discard.some(c => c.id === card.id));
|
||||
custody(before, settled);
|
||||
collectOnce(f, settled, f.tokenCount);
|
||||
});
|
||||
|
||||
void test('two actual ordinary off-planet shipments in the same combined phase accrue only one original Heighliners pile', () => {
|
||||
const f = createNexusSkillsModulesPaymentsFixture({ kind: 'native-shipment', advanced: true, strongholds: true });
|
||||
let game = advanceNexusSkillsModulesPaymentsToPhase(f.afterClosingDraw, 5);
|
||||
const before = structuredClone(game);
|
||||
const shippers: string[] = [];
|
||||
for (let n = 0; n < 2; n++) {
|
||||
assert.equal(game.phase, 5);
|
||||
const actor = game.active!, player = nexusSkillsModulesPaymentsPlayer(game, actor);
|
||||
assert.notEqual(player.faction, 'guild'); assert.notEqual(player.faction, 'fremen');
|
||||
assert.ok(!shippers.includes(actor)); shippers.push(actor);
|
||||
const target = TERRITORIES.find(t => t.type === 'stronghold' && !t.sectors.includes(game.storm)
|
||||
&& game.players.every(p => Object.keys(p.forces).every(key => !key.startsWith(`${t.id}:`))));
|
||||
assert.ok(target);
|
||||
const sector = target.sectors[0], wallet = player.spice, reserves = player.reserves;
|
||||
game = applyAction(game, actor, { type: 'ship', territory: target.id, sector, amount: 1, smuggler: false });
|
||||
game = finishNexusSkillsModulesPaymentsFixture(f, game);
|
||||
assert.equal(nexusSkillsModulesPaymentsPlayer(game, actor).reserves, reserves - 1);
|
||||
assert.equal(nexusSkillsModulesPaymentsPlayer(game, actor).forces[`${target.id}:${sector}`], 1);
|
||||
assert.equal(nexusSkillsModulesPaymentsPlayer(game, actor).spice, wallet - 1);
|
||||
assert.equal(game.techTokens!.heighliners.spice, f.tokenCount);
|
||||
assert.equal(game.techTokens!.heighliners.triggeredTurn, game.turn);
|
||||
if (n === 0) {
|
||||
game = applyAction(game, actor, { type: 'endMovement' });
|
||||
game = finishNexusSkillsModulesPaymentsFixture(f, game);
|
||||
}
|
||||
}
|
||||
custody(before, game);
|
||||
collectOnce(f, game, f.tokenCount);
|
||||
});
|
||||
|
||||
void test('classic Nexus Skills and selected components retain the original once-per-phase Bureaucrat payment diversion', () => {
|
||||
for (const config of configurations) {
|
||||
const f = createNexusSkillsModulesPaymentsFixture({ ...config, kind: 'native-shipment', skill: 'bureaucrat',
|
||||
seatIds: ['owner', 'payer', 'guild', 'other'] });
|
||||
const payer = f.game.players.find(player => player.id !== f.actor)!;
|
||||
const payee = f.game.players.find(player => player.id !== f.actor && player.id !== payer.id && player.id !== payer.ally)!;
|
||||
const before = structuredClone(f.game);
|
||||
let game = applyAction(f.game, payer.id, { type: 'bribe', target: payee.id, amount: 5 });
|
||||
assert.equal(game.decision?.kind, 'bureaucratPayment');
|
||||
assert.equal(nexusSkillsModulesPaymentsPlayer(game, payer.id).spice, payer.spice - 5);
|
||||
assert.equal(nexusSkillsModulesPaymentsPlayer(game, payee.id).bribes, payee.bribes);
|
||||
game = applyAction(game, f.actor, { type: 'decision', event: game.bureaucratPaymentEvent, redirect: true });
|
||||
assert.equal(nexusSkillsModulesPaymentsPlayer(game, payee.id).bribes, payee.bribes + 3);
|
||||
assert.equal(viewGame(game, f.actor).bureaucrat.usedThisPhase, true);
|
||||
assert.deepEqual(game.techTokens, before.techTokens, 'A bribe is not a Tech industry payment');
|
||||
assert.deepEqual(game.strongholdCards, before.strongholdCards);
|
||||
const again = applyAction(game, payer.id, { type: 'bribe', target: payee.id, amount: 5 });
|
||||
assert.equal(again.decision ?? null, null);
|
||||
assert.equal(nexusSkillsModulesPaymentsPlayer(again, payee.id).bribes, payee.bribes + 8);
|
||||
assert.equal(again.bureaucratPayments!.used.length, 1);
|
||||
nexusSkillsModulesPaymentsInventory(again);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user