Files
Dune/tests/native-world-battle.test.ts

205 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, RuleError, viewGame } from '../game/engine';
import { combatArmy } from '../game/combat-location';
import {
advanceNativeWorldBattle, assertNativeWorldBattleInventory, createNativeWorldBattleFixture,
HARASS_WITHDRAW_CARD, nativeWorldBattleFinished, nativeWorldBattlePlayer, nativeWorldBattlePool,
REINFORCEMENTS_CARD, revealNativeWorldBattle, type NativeWorldBattleStep,
} from './fixture-native-world-battle';
for (const advanced of [false, true]) {
const rules = advanced ? 'Advanced' : 'Basic';
void test(`${rules}: nine paid Suboids retain original 1N4E reserves; Reinforcements pays 1N2E before Harass returns`, () => {
const f = createNativeWorldBattleFixture({ advanced });
const shipment = f.shipments[0], original = nativeWorldBattlePlayer(shipment.before, f.owner);
const shipped = nativeWorldBattlePlayer(shipment.after, f.owner);
assert.equal(shipment.cost, 9);
assert.equal(shipped.spice, original.spice - 9);
assert.deepEqual(combatArmy(shipment.after, f.owner, f.location), { normal: 9, elite: 0 });
assert.deepEqual(nativeWorldBattlePool(f.game, f.owner, 'homeworld:ixians'), { normal: 1, elite: 4 });
assert.deepEqual(f.sources, { 'homeworld:ixians': { normal: 1, elite: 2 } });
const before = structuredClone(f.game);
assert.throws(() => applyAction(f.game, f.owner, { ...f.planActions[0].action,
reinforcementsSources: { 'homeworld:ixians': { normal: 3, elite: 0 } } }), RuleError);
assert.deepEqual(f.game, before, 'Seven future returned Suboids cannot replace the two original Cyborg fees');
assert.throws(() => applyAction(f.game, f.owner, { ...f.planActions[0].action,
reinforcementsSources: { [f.location]: { normal: 1, elite: 2 } } }));
assert.deepEqual(f.game, before, 'Foreign visitor stock is not a native fee source');
const revealed = revealNativeWorldBattle(f);
assert.deepEqual(viewGame(revealed, f.owner).battle!.traitorVoters, ['tleilaxu']);
assert.throws(() => applyAction(revealed, f.owner, { type: 'traitorCall', call: false }));
const steps: NativeWorldBattleStep[] = [];
const done = advanceNativeWorldBattle(revealed, nativeWorldBattleFinished, steps, { returns: f.returns });
const p = nativeWorldBattlePlayer(done, f.owner);
assert.equal(done.lastBattleContext?.winner, 'tleilaxu');
assert.equal(p.reserves, 9, 'Original five minus fee three plus seven returned normals');
assert.equal(p.elites!.reserves, 2, 'Returned normals never undo the original two-Cyborg payment');
assert.equal(p.tanks, 5, 'Fee three plus only the two committed Suboids');
assert.equal(p.elites!.tanks, 2);
assert.deepEqual(nativeWorldBattlePool(done, f.owner, 'homeworld:ixians'), { normal: 7, elite: 2 });
assert.deepEqual(combatArmy(done, f.owner, f.location), { normal: 0, elite: 0 });
assert.ok(!done.pendingIxSubstitution);
for (const id of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD])
assert.equal(done.discard.filter(c => c.id === id).length, 1);
assert.deepEqual(p.forces, nativeWorldBattlePlayer(f.afterSetup, f.owner).forces, 'Original HMS garrison stays physical');
assertNativeWorldBattleInventory(done, f.afterSetup);
});
void test(`${rules}: own Ix removes local fees from the physical dial, retains Cyborg/Suboid traits and forbids Harass`, () => {
const f = createNativeWorldBattleFixture({ advanced, route: 'ix-native', faceDancer: true });
assert.deepEqual(nativeWorldBattlePool(f.game, f.owner, f.location), { normal: 1, elite: 4 });
const before = structuredClone(f.game);
assert.throws(() => applyAction(f.game, f.owner, { ...f.planActions[0].action, defense: HARASS_WITHDRAW_CARD }),
RuleError);
assert.throws(() => applyAction(f.game, f.owner, { ...f.planActions[0].action, dial: 4.5 }),
RuleError);
assert.deepEqual(f.game, before);
const revealed = revealNativeWorldBattle(f), view = viewGame(revealed, f.owner).battle!;
assert.ok(view.ownForces);
assert.equal(view.ownForces.normal, 0);
assert.equal(view.ownForces.elite, 2, 'Two local Cyborg fee counters cannot also be dialed');
assert.equal(view.ownForces.normalFixedHalf, true);
assert.equal(view.ownForces.eliteStrength, 2);
assert.equal(view.ownForces.freeSupport, !advanced);
assert.deepEqual(view.traitorVoters, ['ixians']);
assert.throws(() => applyAction(revealed, 'guild', { type: 'traitorCall', call: false }));
const done = advanceNativeWorldBattle(revealed, nativeWorldBattleFinished);
const p = nativeWorldBattlePlayer(done, f.owner);
assert.equal(done.lastBattleContext?.winner, f.owner);
assert.equal(p.tanks, advanced ? 5 : 4);
assert.equal(p.elites!.tanks, advanced ? 4 : 3, 'Fee two plus only this dial’s actual Cyborg casualties');
assert.deepEqual(combatArmy(done, f.owner, f.location), { normal: 0, elite: advanced ? 0 : 1 });
assert.deepEqual(combatArmy(done, f.owner, 'homeworld:tleilaxu'), { normal: 9, elite: 0 },
'The separate real exported garrison is not a casualty or fee origin for this battle');
assert.ok(nativeWorldBattlePlayer(done, 'tleilaxu').faceDancers!.some(d =>
d.leader === f.planActions[0].action.leader && !d.revealed),
'A matching spectator Face Dancer cannot act on another faction’s World');
assertNativeWorldBattleInventory(done, f.afterSetup);
});
void test(`${rules}: paid typed invasion separates two fee Cyborgs from one battle Cyborg and resumes native Face Dance`, () => {
const f = createNativeWorldBattleFixture({ advanced, exportNormal: 9, exportElite: 1,
ownerDial: advanced ? 1 : 2, harass: false, outcome: 'win', faceDancer: true });
assert.equal(f.shipments[0].cost, 10);
assert.equal(nativeWorldBattlePlayer(f.shipments[0].after, f.owner).spice, 0);
assert.deepEqual(combatArmy(f.game, f.owner, f.location), { normal: 9, elite: 1 });
const steps: NativeWorldBattleStep[] = [];
let game = advanceNativeWorldBattle(revealNativeWorldBattle(f), g => g.decision?.kind === 'ixSubstitution', steps,
{ casualties: { normal: 0, elite: 1 } });
const ix = nativeWorldBattlePlayer(game, f.owner);
assert.equal(ix.tanks, 4);
assert.equal(ix.elites!.tanks, 3, 'Two reserve-fee Cyborgs and exactly one actual board casualty');
assert.deepEqual(combatArmy(game, f.owner, f.location), { normal: 9, elite: 0 });
const before = structuredClone(game);
assert.throws(() => applyAction(game, f.owner, { type: 'decision', sources: { [f.location]: 3 },
recover: { [f.location]: 3 } }), RuleError);
assert.deepEqual(game, before, 'The fee cannot become two extra board-loss origins');
game = applyAction(game, f.owner, { type: 'decision', sources: { [f.location]: 1 }, recover: { [f.location]: 1 } });
game = advanceNativeWorldBattle(game, g => g.decision?.kind === 'faceDance', steps);
assert.deepEqual(combatArmy(game, f.owner, f.location), { normal: 8, elite: 1 });
assert.equal(nativeWorldBattlePlayer(game, f.owner).elites!.tanks, 2, 'Only the battle Cyborg was recovered');
assert.equal(nativeWorldBattlePlayer(game, f.owner).tanks, 4, 'One surviving Suboid replaces that Cyborg in Tanks');
assert.equal(game.decision?.player, 'tleilaxu');
assert.equal(game.decision?.kind === 'faceDance' && game.decision.territory, 'homeworld:tleilaxu');
const offer = viewGame(game, 'tleilaxu').decision;
assert.ok(offer?.kind === 'faceDance');
assert.equal(offer.blocked, undefined);
const dancer = nativeWorldBattlePlayer(game, 'tleilaxu').faceDancers!.find(d => d.leader === f.planActions[0].action.leader)!;
assert.ok(dancer && !dancer.revealed);
assertNativeWorldBattleInventory(game, f.afterSetup);
game = applyAction(game, 'tleilaxu', { type: 'decision', reveal: true, sources: {} });
game = advanceNativeWorldBattle(game, nativeWorldBattleFinished, steps);
assert.deepEqual(combatArmy(game, f.owner, f.location), { normal: 0, elite: 0 });
assert.deepEqual(nativeWorldBattlePool(game, f.owner, 'homeworld:ixians'), { normal: 8, elite: 2 },
'Eight Suboids and the one recovered Cyborg return to native Ix, joining its one remaining native Cyborg');
const returned = nativeWorldBattlePlayer(game, f.owner);
assert.equal(returned.reserves, 10);
assert.equal(returned.elites!.reserves, 2);
assert.equal(returned.tanks, 4);
assert.equal(returned.elites!.tanks, 2);
assert.equal(returned.leaders.find(l => l.id === f.planActions[0].action.leader)!.dead, true);
assert.ok(nativeWorldBattlePlayer(game, 'tleilaxu').faceDancers!.find(d => d.leader === dancer.leader)!.revealed);
assertNativeWorldBattleInventory(game, f.afterSetup);
});
}
void test('Advanced: native CHOAM Reinforcements preserves the original Auditor response, payment and real inspection', () => {
const f = createNativeWorldBattleFixture({ advanced: true, route: 'choam-native' });
let game = advanceNativeWorldBattle(revealNativeWorldBattle(f), g => g.decision?.kind === 'choamAudit');
assert.equal(game.decision?.player, 'choam');
assert.equal(game.lastBattleContext?.winner, 'choam');
assert.deepEqual(combatArmy(game, 'choam', f.location), { normal: 17, elite: 0 });
assert.equal(nativeWorldBattlePlayer(game, 'choam').tanks, 3);
const event = game.pendingAuditor!.event;
const held = structuredClone(nativeWorldBattlePlayer(game, 'guild').hand);
assert.equal(held.length, 1, 'One actual unused original card makes inspection deterministic without a replacement RNG');
game = applyAction(game, 'choam', { type: 'decision', event, audit: true });
game = advanceNativeWorldBattle(game, g => g.decision?.kind === 'choamAuditPayment');
assert.equal(game.decision?.player, 'guild');
const before = structuredClone(game);
const count = viewGame(game, 'choam').auditor!.count;
assert.equal(count, 1);
const paid = applyAction(game, 'guild', { type: 'decision', event, pay: true, count });
assert.equal(nativeWorldBattlePlayer(paid, 'guild').spice, nativeWorldBattlePlayer(before, 'guild').spice - 1);
assert.equal(nativeWorldBattlePlayer(paid, 'choam').spice, nativeWorldBattlePlayer(before, 'choam').spice + 1);
assert.ok(!paid.auditorInsight, 'Payment cancels the inspection');
assertNativeWorldBattleInventory(paid, f.afterSetup);
game = applyAction(game, 'guild', { type: 'decision', event, pay: false });
assert.deepEqual(game.auditorInsight?.cards.map(c => c.id), held.map(c => c.id));
assert.equal(game.auditorInsight?.target, 'guild');
assert.deepEqual(nativeWorldBattlePlayer(game, 'guild').hand.map(c => c.id), held.map(c => c.id),
'Inspection transfers no physical card');
assert.equal(nativeWorldBattlePlayer(game, 'guild').spice, nativeWorldBattlePlayer(before, 'guild').spice);
assert.equal(nativeWorldBattlePlayer(game, 'choam').spice, nativeWorldBattlePlayer(before, 'choam').spice);
game = advanceNativeWorldBattle(game, nativeWorldBattleFinished);
assertNativeWorldBattleInventory(game, f.afterSetup);
});
void test('all14 native Ix Suk saves only the post-fee committed Cyborgs, never the reserve fee or exported garrison', () => {
const f = createNativeWorldBattleFixture({ advanced: true, route: 'ix-native', skills: true, skilled: true,
tech: true, strongholds: true,
training: { ixians: 'suk-graduate', tleilaxu: 'killer-medic', guild: 'sandmaster' } });
const original = structuredClone(f.game);
assert.deepEqual(f.sources, { 'homeworld:ixians': { normal: 1, elite: 2 } });
assert.equal(viewGame(f.game, f.owner).battle!.strongholdEffects[f.owner], null,
'Original unused Strongholds never funds support on its owner’s World');
assert.throws(() => applyAction(f.game, f.owner, { ...f.planActions[0].action, dial: 4.5 }), RuleError);
assert.throws(() => applyAction(f.game, f.owner, { ...f.planActions[0].action, defense: HARASS_WITHDRAW_CARD }), RuleError);
assert.deepEqual(f.game, original);
const revealed = revealNativeWorldBattle(f), forces = viewGame(revealed, f.owner).battle!.ownForces!;
assert.equal(forces.normal, 0); assert.equal(forces.elite, 2);
assert.equal(forces.normalFixedHalf, true); assert.equal(forces.eliteStrength, 2);
assert.equal(forces.freeSupport, false);
assert.deepEqual(viewGame(revealed, f.owner).battle!.traitorVoters, [f.owner]);
let game = advanceNativeWorldBattle(revealed, g => g.decision?.kind === 'sukRescue');
assert.ok(game.decision?.kind === 'sukRescue');
assert.deepEqual(game.pendingSukRescue!.pool, [{ key: f.location, normal: 0, elite: 2 }]);
assert.deepEqual(game.pendingSukRescue!.losses, { normal: 0, elite: 2, paidNormal: 0, paidElite: 0 });
assert.equal(nativeWorldBattlePlayer(game, f.owner).tanks, 3);
assert.equal(nativeWorldBattlePlayer(game, f.owner).elites!.tanks, 2);
assert.ok(game.decision.options.every(o => o.normal === 0 && o.elite <= 2),
'The normal fee and two fee Cyborgs are not Suk casualties');
const choice = game.decision.options.findIndex(o => o.elite === 2 && o.kept?.kind === 'elite');
assert.ok(choice >= 0);
const event = game.decision.event;
game = applyAction(game, f.owner, { type: 'decision', event, choice });
game = advanceNativeWorldBattle(game, nativeWorldBattleFinished);
assert.equal(game.lastBattleContext!.winner, f.owner);
assert.deepEqual(game.lastBattleContext!.sukRescue!.physical, {
player: f.owner, territory: f.location, source: 'native-homeworld', saved: { normal: 0, elite: 2 },
kept: { key: f.location, kind: 'elite' }, destinations: {}, tanks: { normal: 0, elite: 0 },
});
assert.deepEqual(nativeWorldBattlePool(game, f.owner, f.location), { normal: 0, elite: 2 });
assert.deepEqual(combatArmy(game, f.owner, 'homeworld:tleilaxu'), { normal: 9, elite: 0 });
assert.equal(nativeWorldBattlePlayer(game, f.owner).tanks, 3);
assert.equal(nativeWorldBattlePlayer(game, f.owner).elites!.tanks, 2);
assert.equal(nativeWorldBattlePlayer(game, f.owner).battleLosses, 5);
assert.equal(game.discard.filter(c => c.id === REINFORCEMENTS_CARD).length, 1);
assert.ok(nativeWorldBattlePlayer(game, f.owner).hand.some(c => c.id === HARASS_WITHDRAW_CARD));
assertNativeWorldBattleInventory(game, f.afterSetup);
});