Files
Dune/tests/nexus-world-independent-battles.test.ts
T
Quintine 97c2b67650 Compose classic independent cards with World Nexus rules
Retain independent36, original Homeworlds and Nexus12 through classic fresh setup with optional Skills and phase modules. Resolve own-World Reinforcements fees before computing temporary Sardaukar roles, preserving original Cunning declarations, printed traits and physical counters. Record the provisional ordinary-nonrole-first policy and align human/AI quotes and captions.

Validation: types/lint/build, 128 affected tests across 15 files, bounded rule-source review and finite original-action smoke for Recruits, Harass, local-fee Cunning, all four legal policies and rendered source controls. Verified runtime c219ec587a54b44079539168d93835a5af904c8afb4de58bcec0a3d60c809f0c; documentation-only proof update 8ee21598e7be0c89a08fbad50786c346ebc8ae705492d8b31ea00fae0ffefa3c. Full modes, combinations, assurance and deployed acceptance remain open. No saved-room/provider changes.
2026-10-18 10:20:08 +11:00

155 lines
9.8 KiB
TypeScript

import assert from 'node:assert/strict';
import { test } from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { combatArmy } from '../game/combat-location';
import { casualtyOptions, maxCombatDial, maxCombatSupport } from '../game/combat';
import { homeworldContext } from '../game/homeworld-game';
import { homeworldPopulations } from '../game/homeworld-population';
import type { NativeReserveSelections } from '../game/homeworld-native-reserves';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { reinforcementsBattleForces } from '../game/reinforcements-homeworld';
import { reactionAction } from './fixture-classic-independent-nexus-reactions';
import {
advanceWorldNexusBattle, createWorldNexusEmperorBattle, revealWorldNexusPlans,
worldNexusClean, worldNexusPlayer, type WorldNexusBattleStep,
} from './fixture-nexus-core-world-battles';
const KAITAIN = 'homeworld:emperor', SALUSA = 'homeworld:emperor:salusa';
const localFee: NativeReserveSelections = { [KAITAIN]: { normal: 3, elite: 0 } };
const remoteFee: NativeReserveSelections = { [SALUSA]: { normal: 3, elite: 0 } };
function declareCunning(game: Game): Game {
const offer = viewGame(game, 'emperor').nexusSardaukar!.offer!;
assert.equal(offer.blocked, null);
game = reactionAction(game, 'emperor', { type: 'nexusSardaukar', event: offer.event });
game = advanceWorldNexusBattle(game, worldNexusClean);
const receipt = game.nexusSardaukarHistory!.at(-1)!.receipt;
assert.equal(receipt.normal, 7);
assert.equal(receipt.count, 5, 'The original declaration remains five roles, not a rewritten four-counter declaration');
return game;
}
function cardPlan(step: WorldNexusBattleStep, slot: 'weapon' | 'defense', sources: NativeReserveSelections): WorldNexusBattleStep {
return { actor: step.actor, action: { ...step.action, [slot]: REINFORCEMENTS_CARD,
reinforcementsSources: sources } };
}
function finish(game: Game): Game {
return advanceWorldNexusBattle(game, state => !state.battle && !state.homeworldBattleLoss && worldNexusClean(state));
}
function checkPhysicalDeal(game: Game): void {
assert.equal(game.deck.length + game.discard.length + game.players.reduce((sum, player) => sum + player.hand.length, 0), 36);
assert.equal(game.nexusCards!.cards!.deck.length + game.nexusCards!.cards!.discard.length +
Object.values(game.nexusCards!.cards!.hands).filter(Boolean).length, 12);
}
for (const salusa of ['high', 'low'] as const) for (const slot of ['weapon', 'defense'] as const) {
void test(`original independent ${slot}, own-Kaitain Cunning and ${salusa} Salusa pay the local fee before physical combat`, () => {
const f = createWorldNexusEmperorBattle({ independent: true, salusa, skills: true, tech: true, strongholds: true });
assert.equal(f.lobby.lobbyOptions?.ecazTreachery, true);
assert.ok(f.lobby.players.every(player => !player.hand.length), 'The requested physical card was ordered before any starting deal');
assert.equal(worldNexusPlayer(f.game, 'emperor').hand[0].id, REINFORCEMENTS_CARD);
assert.ok(f.staging.some(note => note.includes('original unused Treachery shuffle')));
checkPhysicalDeal(f.game);
let game = declareCunning(f.game);
const before = worldNexusPlayer(game, 'emperor'), other = worldNexusPlayer(game, 'guild');
const stars = structuredClone(before.elites), salusaBefore = structuredClone(game.homeworlds!.custody!.salusa);
const own = viewGame(game, 'emperor').battle!;
assert.deepEqual([own.ownForces!.normal, own.ownForces!.elite, own.ownForces!.temporaryElite], [7, 0, 5]);
assert.equal(own.nativeBattleStrength, 2);
assert.equal(game.battle!.leaderSkillHidden!.emperor, true);
assert.deepEqual(own.strongholdEffects, { guild: null, emperor: null });
assert.equal(own.reinforcements!.blocked, null);
const projected = reinforcementsBattleForces(own.ownForces!, localFee[KAITAIN]);
// Provisional composition: the two non-role counters pay first, followed
// by one temporary-role counter. The four survivors all retain the role.
assert.deepEqual([projected.normal, projected.elite, projected.temporaryElite], [4, 0, 4]);
assert.equal(projected.eliteFreeSupport, own.ownForces!.eliteFreeSupport);
assert.equal(!!projected.eliteFreeSupport, salusa === 'high');
assert.equal(maxCombatDial(projected), 8);
assert.equal(maxCombatSupport(projected), salusa === 'high' ? 0 : 4);
assert.deepEqual(casualtyOptions(projected, 4, salusa === 'high' ? 0 : 2), [
{ normal: 2, elite: 0, paidNormal: salusa === 'high' ? 0 : 2, paidElite: 0 },
]);
const ownPlan = cardPlan(f.plans[0], slot, localFee);
assert.throws(() => applyAction(game, 'emperor', { ...ownPlan.action, dial: 9 }),
'The three fee counters cannot also fund the physical dial');
if (salusa === 'high') assert.throws(() => applyAction(game, 'emperor', { ...ownPlan.action, support: 1 }),
'There are no remaining ordinary non-role counters to pay support');
game = revealWorldNexusPlans(game, [ownPlan, f.plans[1]]);
assert.equal(viewGame(game, 'emperor').battle!.nativeBattleStrength, 2, 'The printed high face belongs to the original battle');
game = finish(game);
assert.equal(game.lastBattleContext!.winner, 'emperor');
assert.equal(game.lastBattleContext!.result, 'normal');
assert.deepEqual(combatArmy(game, 'emperor', KAITAIN), { normal: 2, elite: 0 });
assert.deepEqual(combatArmy(game, 'guild', KAITAIN), { normal: 0, elite: 0 });
const after = worldNexusPlayer(game, 'emperor');
assert.equal(after.reserves, before.reserves - 5);
assert.equal(after.tanks, before.tanks + 5, 'Three native fee counters and two real dial casualties, with no virtual bonus casualties');
assert.equal(after.battleLosses, before.battleLosses + 5);
assert.equal(worldNexusPlayer(game, 'guild').tanks, other.tanks + 3);
assert.equal(after.spice, before.spice - (salusa === 'high' ? 0 : 2));
assert.deepEqual(after.elites, stars, 'Temporary roles never become or kill actual stars');
assert.deepEqual(game.homeworlds!.custody!.salusa, salusaBefore);
const home = homeworldPopulations(homeworldContext(game), game.homeworlds!.custody!).find(home => home.location === KAITAIN)!;
assert.deepEqual([home.population, home.side], [2, 'low']);
const record = game.nexusSardaukarHistory!.at(-1)!;
assert.equal(record.receipt.count, 5);
assert.equal(record.receipt.normal, 7);
assert.deepEqual(record.casualties!.reserveFee, { normal: 3, elite: 0 });
assert.deepEqual([record.casualties!.forces.normal, record.casualties!.forces.elite,
record.casualties!.forces.temporaryElite], [4, 0, 4]);
assert.equal(record.casualties!.outcome, 'complete');
assert.equal(game.discard.filter(card => card.id === REINFORCEMENTS_CARD).length, 1);
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
checkPhysicalDeal(game);
});
}
void test('an original Salusa-only fee leaves all five Kaitain Cunning roles available', () => {
const f = createWorldNexusEmperorBattle({ independent: true, salusa: 'high' });
let game = declareCunning(f.game);
const before = worldNexusPlayer(game, 'emperor'), stars = structuredClone(before.elites);
const salusaBefore = structuredClone(game.homeworlds!.custody!.salusa!);
const own = viewGame(game, 'emperor').battle!.ownForces!;
const projected = reinforcementsBattleForces(own, remoteFee[KAITAIN]);
assert.deepEqual([projected.normal, projected.elite, projected.temporaryElite], [7, 0, 5]);
assert.equal(maxCombatDial(projected), 12);
assert.equal(maxCombatSupport(projected), 2);
game = revealWorldNexusPlans(game, [cardPlan(f.plans[0], 'weapon', remoteFee), f.plans[1]]);
game = finish(game);
assert.equal(game.lastBattleContext!.winner, 'emperor');
assert.deepEqual(combatArmy(game, 'emperor', KAITAIN), { normal: 5, elite: 0 });
assert.deepEqual(game.homeworlds!.custody!.salusa, { normal: salusaBefore.normal - 3, elite: salusaBefore.elite });
assert.equal(worldNexusPlayer(game, 'emperor').tanks, before.tanks + 5);
assert.deepEqual(worldNexusPlayer(game, 'emperor').elites, stars);
const record = game.nexusSardaukarHistory!.at(-1)!;
assert.equal(record.casualties!.forces.temporaryElite, 5);
assert.equal(record.casualties!.reserveFee, undefined, 'An off-World fee never rewrites the original temporary-role army');
assert.equal(record.casualties!.outcome, 'complete');
checkPhysicalDeal(game);
});
for (const salusa of ['high', 'low'] as const) {
void test(`all four minimal legal policies emit positive source-bearing Reinforcements plans with ${salusa} Salusa and original Cunning`, () => {
const f = createWorldNexusEmperorBattle({ independent: true, salusa });
const game = declareCunning(f.game);
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = viewGame(game, 'emperor');
view.players.find(player => player.id === 'emperor')!.bot = difficulty;
const action = botActions(view).find(action => action.type === 'battlePlan' && Number(action.dial) > 0 &&
(action.weapon === REINFORCEMENTS_CARD || action.defense === REINFORCEMENTS_CARD));
assert.ok(action, `${difficulty} must retain a positive physical card path`);
const sources = action.reinforcementsSources as NativeReserveSelections | undefined;
assert.ok(sources && Object.keys(sources).length, `${difficulty} must declare actual native sources`);
const pool = reinforcementsBattleForces(view.battle!.ownForces!, sources[KAITAIN]);
assert.ok(casualtyOptions(pool, Number(action.dial), Number(action.support ?? 0)).length,
`${difficulty} must fund the physical dial after its own fee`);
const sealed = applyAction(game, 'emperor', action);
assert.ok(sealed.battle!.plans.emperor);
}
});
}