Files
Dune/tests/selected-independent-nexus-battles.test.ts
Quintine 8422cf1f2d Compose selected E1/E2 independent cards with Nexus rules
Retain selected decks and original optional modules through normal setup, distinguish native Ixian Betrayal from absent-faction replacement, route explicit CHOAM fuel before printed effects, and preserve native No-Field invoices and pair continuation. Connect canonical native cancellation stock and existing human/minimal AI consumers.

Validation: 194 affected tests across 19 files, types/lint/build, bounded native invoice review and seven finite original-action/rendered-control programmes. Runtime ec5ad6c18996d8d958ec0d01132ef3e3ea6d23d36188902eca9a63d4db2efbf8; documentation-only proof overlay a5892004937434429916496497c07a3838a324b4a5d64d4a0eb804ab5212d5bd. Other envelopes, full combinations, comprehensive assurance and deployed acceptance remain open. No saved-room/provider changes.
2026-10-18 10:46:21 +11:00

161 lines
11 KiB
TypeScript

import assert from 'node:assert/strict';
import { test } from 'node:test';
import { applyAction, viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import { MOBILE_LOCATION } from '../game/board';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
import {
activateSelectedIndependentSuboids, actSelectedIndependentNexusBattle,
advanceSelectedIndependentNexusBattle, createSelectedIndependentNexusBattleFixture,
revealSelectedIndependentNexusPlans, selectedIndependentNexusClean,
selectedIndependentNexusPlayer as player,
} from './fixture-selected-independent-nexus-battles';
for (const advanced of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} original selected Ix Cunning pays its mixed physical fee before Harass returns eight Suboids`, () => {
const f = createSelectedIndependentNexusBattleFixture({ advanced, program: 'mixed-return', expansions: ['ix'] });
assert.deepEqual(f.fee, { normal: 1, elite: 2 });
assert.equal(f.ownShipment.action.amount, 9);
assert.equal(f.ownShipment.action.elite, 0);
assert.equal(player(f.beforeShipment, 'ixians').spice - player(f.afterShipment, 'ixians').spice, 9);
assert.equal(f.purchases.length, 2);
assert.ok(f.purchases.every(purchase => purchase.step.action.type === 'bid' && purchase.step.action.amount === 1));
if (!advanced) assert.ok(f.actions.some(step => step.actor === 'ixians' && step.action.type === 'charity'),
'After spending the original ten spice, Basic funds the second bid through its actual eligible charity claim');
assert.ok(player(f.purchases[1].before, 'ixians').spice >= 1);
assert.equal(f.afterNexusDraw.nexusCards!.cards!.hands.ixians, 'ixians');
assert.equal(player(f.afterNexusDraw, 'guild').ally, 'emperor');
assert.equal(player(f.afterNexusDraw, 'ixians').ally, null);
assert.ok(player(f.game, 'ixians').hand.some(card => card.id === REINFORCEMENTS_CARD));
assert.ok(player(f.game, 'ixians').hand.some(card => card.id === HARASS_WITHDRAW_CARD));
const before = player(f.game, 'ixians');
let game = activateSelectedIndependentSuboids(f.game, f.actions);
const own = viewGame(game, 'ixians').battle!.ownForces!;
assert.deepEqual([own.normal, own.elite, own.normalFixedHalf, own.normalFreeSupport], [9, 0, false, true]);
assert.deepEqual(viewGame(game, 'ixians').battle!.reinforcements!.normal, 1);
assert.deepEqual(viewGame(game, 'ixians').battle!.reinforcements!.elite, 2);
game = revealSelectedIndependentNexusPlans(game, f.plans, f.actions);
assert.equal(game.decision, null,
'One ordinary pool has a unique undialed return and needs no invented allocation prompt');
game = advanceSelectedIndependentNexusBattle(game, state => !state.battle && selectedIndependentNexusClean(state), f.actions);
const after = player(game, 'ixians');
assert.equal(game.lastBattleContext!.winner, 'ixians');
assert.equal(game.lastBattleContext!.result, 'normal');
assert.equal(after.forces[f.location] ?? 0, 0);
assert.equal(after.reserves, before.reserves - 3 + 8);
assert.equal(after.elites!.reserves, before.elites!.reserves - 2,
'Returned ordinary Suboids cannot replace the originally quoted two Cyborg fee counters');
assert.equal(after.tanks, before.tanks + 4, 'The original fee3 and one committed physical Suboid pay once');
assert.equal(after.elites!.tanks, before.elites!.tanks + 2);
assert.equal(after.forces[MOBILE_LOCATION], before.forces[MOBILE_LOCATION]);
assert.equal(after.elites!.forces[MOBILE_LOCATION], before.elites!.forces[MOBILE_LOCATION]);
assert.ok(!game.pendingIxSubstitution,
'Reserve-fee Cyborgs are not actual battlefield Cyborg casualties available for substitution');
assert.equal(player(game, 'guild').tanks, player(f.game, 'guild').tanks + 3);
for (const card of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD])
assert.equal(game.discard.filter(physical => physical.id === card).length, 1);
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'ixians').length, 1);
assert.equal(viewGame(game, 'ixians').nexusSuboids!.active, true);
});
}
for (const modules of [false, true]) {
void test(`original selected ${modules ? 'union E1/E2 Worlds/all14/phase' : 'E1'} Cunning preserves actual Cyborg losses and equal residual Suboid substitution`, () => {
const f = createSelectedIndependentNexusBattleFixture({ advanced: true, program: 'substitution',
expansions: modules ? ['ix', 'choam'] : ['ix'], homeworlds: modules, skills: modules,
tech: modules, strongholds: modules });
assert.deepEqual(f.fee, { normal: 3, elite: 0 });
assert.equal(f.ownShipment.action.amount, 6);
assert.equal(f.ownShipment.action.elite, 3);
if (modules) {
assert.ok(f.plans[0].action.reinforcementsSources);
assert.equal(f.afterFirstMentat.strongholdCards!.owners.arrakeen, 'ixians');
}
const original = player(f.game, 'ixians');
let game = activateSelectedIndependentSuboids(f.game, f.actions);
game = revealSelectedIndependentNexusPlans(game, f.plans, f.actions);
game = advanceSelectedIndependentNexusBattle(game, state => !state.response && !state.phaseOpening &&
(state.decision?.kind === 'battleLosses' || state.decision?.kind === 'ixSubstitution'), f.actions);
if (game.decision?.kind === 'battleLosses') {
const choice = game.decision.options.findIndex(loss => loss.normal === 1 && loss.elite === 1); assert.ok(choice >= 0);
game = actSelectedIndependentNexusBattle(game, { actor: 'ixians', action: { type: 'decision', choice } }, f.actions);
}
game = advanceSelectedIndependentNexusBattle(game, state => !state.response && !state.phaseOpening &&
state.decision?.kind === 'ixSubstitution', f.actions);
assert.equal(player(game, 'ixians').forces[f.location], 4);
assert.equal(player(game, 'ixians').elites!.forces[f.location], 2);
assert.equal(player(game, 'ixians').tanks, original.tanks + 5);
assert.equal(player(game, 'ixians').elites!.tanks, original.elites!.tanks + 1);
assert.deepEqual(game.pendingIxSubstitution!.losses, { [f.location]: 1 });
assert.throws(() => applyAction(game, 'ixians', { type: 'decision',
sources: { [f.location]: 2 }, recover: { [f.location]: 2 } }));
game = actSelectedIndependentNexusBattle(game, { actor: 'ixians', action: { type: 'decision',
sources: { [f.location]: 1 }, recover: { [f.location]: 1 } } }, f.actions);
game = advanceSelectedIndependentNexusBattle(game, state => !state.battle && selectedIndependentNexusClean(state), f.actions);
const after = player(game, 'ixians');
assert.equal(game.lastBattleContext!.winner, 'ixians');
assert.equal(after.forces[f.location], 4);
assert.equal(after.elites!.forces[f.location], 3);
assert.equal(after.tanks, original.tanks + 5);
assert.equal(after.elites!.tanks, original.elites!.tanks);
assert.equal(after.reserves, original.reserves - 3);
assert.equal(after.elites!.reserves, original.elites!.reserves);
assert.equal(game.discard.filter(card => card.id === REINFORCEMENTS_CARD).length, 1);
assert.equal(after.forces[MOBILE_LOCATION], original.forces[MOBILE_LOCATION]);
assert.equal(after.elites!.forces[MOBILE_LOCATION], original.elites!.forces[MOBILE_LOCATION]);
});
}
void test('original selected independent Cunning completes actual reserve fees and residual substitution before a genuinely dealt Face Dance', () => {
const f = createSelectedIndependentNexusBattleFixture({ advanced: true, program: 'faceDance', expansions: ['ix', 'choam'] });
assert.ok(player(f.afterSetup, 'tleilaxu').faceDancers!.some(dancer => dancer.leader === f.leader && !dancer.revealed));
let game = revealSelectedIndependentNexusPlans(activateSelectedIndependentSuboids(f.game, f.actions), f.plans, f.actions);
game = advanceSelectedIndependentNexusBattle(game, state => !state.response && !state.phaseOpening &&
(state.decision?.kind === 'battleLosses' || state.decision?.kind === 'ixSubstitution'), f.actions);
if (game.decision?.kind === 'battleLosses') {
const choice = game.decision.options.findIndex(loss => loss.normal === 1 && loss.elite === 1); assert.ok(choice >= 0);
game = actSelectedIndependentNexusBattle(game, { actor: 'ixians', action: { type: 'decision', choice } }, f.actions);
}
game = advanceSelectedIndependentNexusBattle(game, state => !state.response && !state.phaseOpening &&
state.decision?.kind === 'ixSubstitution', f.actions);
game = actSelectedIndependentNexusBattle(game, { actor: 'ixians', action: { type: 'decision',
sources: { [f.location]: 1 }, recover: { [f.location]: 1 } } }, f.actions);
game = advanceSelectedIndependentNexusBattle(game, state => !state.response && !state.phaseOpening &&
state.decision?.kind === 'faceDance', f.actions);
assert.equal(game.decision!.kind, 'faceDance');
const before = player(game, 'ixians'), native = player(game, 'tleilaxu');
assert.equal(before.forces[f.location], 4);
assert.equal(before.elites!.forces[f.location], 3);
assert.equal(game.discard.filter(card => card.id === REINFORCEMENTS_CARD).length, 1);
game = actSelectedIndependentNexusBattle(game, { actor: 'tleilaxu', action: {
type: 'decision', reveal: true, sources: { reserves: 4 }, sector: f.sector,
} }, f.actions);
game = advanceSelectedIndependentNexusBattle(game, state => !state.battle && selectedIndependentNexusClean(state), f.actions);
const after = player(game, 'ixians');
assert.equal(after.forces[f.location] ?? 0, 0);
assert.equal(after.elites!.forces[f.location] ?? 0, 0);
assert.equal(after.reserves, before.reserves + 4);
assert.equal(after.elites!.reserves, before.elites!.reserves + 3);
assert.equal(after.tanks, before.tanks);
assert.equal(after.elites!.tanks, before.elites!.tanks);
assert.equal(after.leaders.find(leader => leader.id === f.leader)!.dead, true);
assert.equal(player(game, 'tleilaxu').reserves, native.reserves - 4);
assert.equal(player(game, 'tleilaxu').forces[f.location], 4);
assert.equal(player(game, 'tleilaxu').faceDancers!.find(dancer => dancer.leader === f.leader)!.revealed, true);
});
void test('all four minimal policies retain a positive native Reinforcements path after genuine selected-family Suboid Cunning', () => {
const f = createSelectedIndependentNexusBattleFixture({ advanced: true, program: 'substitution', expansions: ['ix', 'choam'], homeworlds: true });
const game = activateSelectedIndependentSuboids(f.game, f.actions);
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = viewGame(game, 'ixians');
view.players.find(player => player.id === 'ixians')!.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 expose a positive original card plan`);
assert.ok(action.reinforcementsSources, `${difficulty} must retain the original native source quote`);
assert.ok(applyAction(game, 'ixians', action).battle!.plans.ixians);
}
});