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

220 lines
15 KiB
TypeScript

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, HARASS_WITHDRAW_CARD,
nativeWorldBattleFinished, nativeWorldBattlePlayer as player, nativeWorldBattlePool as pool,
REINFORCEMENTS_CARD, revealNativeWorldBattle, type NativeWorldBattleStep,
} from './fixture-native-world-battle';
import {
createNativeWorldPhaseBattleFixture, createNativeWorldPhaseForeignBattleFixture,
createNativeWorldPhaseOwnBattleFixture, finishNativeWorldPhaseBattle, revealNativeWorldPhaseBattle,
settleNativeWorldPhaseBattle,
} from './fixture-native-world-phase-battle';
for (const scenario of [
{ advanced: true, tech: true, support: 0 },
{ advanced: true, tech: true, support: 1 },
{ advanced: true, tech: true, support: 3 },
{ advanced: true, tech: false, support: 3 },
{ advanced: false, tech: true, support: 0 },
] as const) {
void test(`native Ix ${scenario.advanced ? 'Advanced earned Arrakeen' : 'Basic'} / Tech ${scenario.tech}: actual support ${scenario.support}, native fee, typed Harass`, () => {
const f = createNativeWorldPhaseBattleFixture(scenario);
const ownShipment = f.shipments[0];
assert.equal(ownShipment.step.actor, f.owner);
assert.equal(player(ownShipment.before, f.owner).spice - player(ownShipment.after, f.owner).spice, 6);
assert.deepEqual(combatArmy(ownShipment.after, f.owner, 'arrakeen'), { normal: 3, elite: 3 });
assert.deepEqual(pool(f.game, f.owner, 'homeworld:ixians'), { normal: 7, elite: 1 });
assert.deepEqual(combatArmy(f.game, f.owner, 'arrakeen'), { normal: 3, elite: 3 });
if (scenario.advanced) {
assert.equal(f.beforeFirstMentat.strongholdCards!.owners.arrakeen, null);
assert.deepEqual(combatArmy(f.beforeFirstMentat, f.owner, 'arrakeen'), { normal: 3, elite: 3 });
assert.equal(f.afterFirstMentat.strongholdCards!.owners.arrakeen, f.owner);
assert.equal(f.afterFirstMentat.strongholdCards!.claimedTurn, 1);
assert.equal(viewGame(f.game, f.owner).battle!.strongholdEffects[f.owner], 'arrakeen');
} else assert.equal(viewGame(f.game, f.owner).strongholdCards, null);
assert.deepEqual(f.game.battle!.plans, {}, 'Both original controls start at the unsealed plan boundary');
const before = structuredClone(f.game);
assert.throws(() => applyAction(f.game, f.owner, { ...f.planActions[0].action, dial: 9.5 }), RuleError,
'R adds score, not physical Suboids/Cyborgs available to dial');
if (scenario.advanced)
assert.throws(() => applyAction(f.game, f.owner, { ...f.planActions[0].action, support: 4 }), RuleError,
'Only three actual Cyborgs can be supported; neither R +2 nor bank2 adds counters');
assert.throws(() => applyAction(f.game, f.owner, { ...f.planActions[0].action,
reinforcementsSources: { 'homeworld:guild': { normal: 3, elite: 0 } } }), RuleError);
assert.deepEqual(f.game, before);
const revealed = revealNativeWorldPhaseBattle(f);
assert.equal(player(revealed, f.owner).reserves, 8, 'Sealing/reveal never spends a forecast fee');
assert.equal(player(revealed, f.owner).spice, player(f.game, f.owner).spice,
'Holding Arrakeen never credits two personal spice at the plan boundary');
const settled = settleNativeWorldPhaseBattle(revealed, f);
assert.equal(player(settled, f.owner).spice,
player(f.game, f.owner).spice - Math.max(0, scenario.support - 2));
const done = finishNativeWorldPhaseBattle(settled, f);
assert.equal(done.turn, 2); assert.equal(done.phase, 7);
assert.equal(done.lastBattleContext!.winner, f.owner);
assert.equal(done.lastBattleContext!.result, 'normal');
assert.equal(player(done, f.owner).tanks, 6, 'Exactly native fee3N and the three physically committed Cyborgs');
assert.equal(player(done, f.owner).elites!.tanks, 3, 'No Cyborg is silently used instead of available normal fee counters');
assert.equal(player(done, f.owner).battleLosses, 6);
assert.deepEqual(pool(done, f.owner, 'homeworld:ixians'), { normal: 7, elite: 1 },
'Ix7N1E pays original3N, then all three undialed Suboids return to native Ix');
assert.equal(player(done, f.owner).reserves, 8);
assert.deepEqual(combatArmy(done, f.owner, 'arrakeen'), { normal: 0, elite: 0 });
assert.equal(player(done, f.owner).forces['hidden_mobile_stronghold:0'], 6);
assert.equal(player(done, f.owner).elites!.forces['hidden_mobile_stronghold:0'], 3);
assert.equal(player(done, f.opponent).tanks, 3);
for (const card of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD])
assert.equal(done.discard.filter(face => face.id === card).length, 1);
if (scenario.tech) {
assert.equal(f.game.techTokens!.heighliners.spice, 0, 'Original phase-end shipment income has already settled');
assert.ok(done.techTokens, 'Original winner reward/cleanup preserves the actual Tech module');
} else assert.equal(done.techTokens, undefined);
assertNativeWorldBattleInventory(done, f.afterSetup);
});
}
for (const advanced of [false, true]) {
void test(`${advanced ? 'Advanced Tech+Strongholds' : 'Basic Tech'} foreign World: exact original mixed fee cannot borrow future Harass returns`, () => {
const f = createNativeWorldPhaseForeignBattleFixture({ advanced });
assert.ok(f.game.techTokens);
assert.equal(viewGame(f.game, f.owner).battle!.strongholdEffects[f.owner], null,
'A Stronghold Card never grants its Arrakis-local benefit on a foreign World');
assert.deepEqual(pool(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);
const done = advanceNativeWorldBattle(revealNativeWorldBattle(f), nativeWorldBattleFinished, [], { returns: f.returns });
assert.equal(done.lastBattleContext!.winner, f.opponent);
assert.deepEqual(pool(done, f.owner, 'homeworld:ixians'), { normal: 7, elite: 2 });
assert.equal(player(done, f.owner).tanks, 5);
assert.equal(player(done, f.owner).elites!.tanks, 2,
'The future seven normal returns cannot repay the original two-Cyborg fee');
assert.deepEqual(combatArmy(done, f.owner, f.location), { normal: 0, elite: 0 });
assertNativeWorldBattleInventory(done, f.afterSetup);
});
void test(`${advanced ? 'Advanced Tech+Strongholds' : 'Basic Tech'} own Ix: Harass stays forbidden and local native fee is disjoint from dial`, () => {
const f = createNativeWorldPhaseOwnBattleFixture(advanced), before = structuredClone(f.game);
assert.deepEqual(pool(f.game, f.owner, f.location), { normal: 1, elite: 4 });
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,
'After local1N2E fee, only two real Cyborgs remain eligible for the dial');
assert.deepEqual(f.game, before);
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, !advanced);
const done = advanceNativeWorldBattle(revealed, nativeWorldBattleFinished);
assert.equal(done.lastBattleContext!.winner, f.owner);
assert.equal(player(done, f.owner).tanks, advanced ? 5 : 4);
assert.equal(player(done, f.owner).elites!.tanks, advanced ? 4 : 3);
assert.ok(player(done, f.owner).hand.some(card => card.id === HARASS_WITHDRAW_CARD));
assertNativeWorldBattleInventory(done, f.afterSetup);
});
}
void test('Advanced original phase modules: native board-only Cyborg substitution resumes real Face Dance without recovering reserve fees', () => {
const f = createNativeWorldPhaseForeignBattleFixture({ typedAftercare: true });
const steps: NativeWorldBattleStep[] = [];
let game = advanceNativeWorldBattle(revealNativeWorldBattle(f), g => g.decision?.kind === 'ixSubstitution', steps,
{ casualties: { normal: 0, elite: 1 } });
assert.equal(player(game, f.owner).tanks, 4);
assert.equal(player(game, f.owner).elites!.tanks, 3);
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, 'Two native fee Cyborgs are not board casualty origins');
assert.deepEqual(game, before);
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(player(game, f.owner).elites!.tanks, 2);
assert.equal(game.decision?.player, f.opponent);
assert.equal(game.decision?.kind === 'faceDance' && game.decision.territory, f.location);
assert.equal(viewGame(game, f.opponent).decision?.kind, 'faceDance');
game = applyAction(game, f.opponent, { type: 'decision', reveal: true, sources: {} });
game = advanceNativeWorldBattle(game, nativeWorldBattleFinished, steps);
assert.deepEqual(combatArmy(game, f.owner, f.location), { normal: 0, elite: 0 });
assert.deepEqual(pool(game, f.owner, 'homeworld:ixians'), { normal: 8, elite: 2 });
assert.equal(player(game, f.owner).tanks, 4); assert.equal(player(game, f.owner).elites!.tanks, 2);
assert.equal(player(game, f.owner).leaders.find(l => l.id === f.planActions[0].action.leader)!.dead, true);
assert.ok(steps.some(step => step.action.type === 'decision' && step.action.choice !== undefined),
'The genuine native typed casualty decision precedes the separate substitution and Face Dance queues');
assertNativeWorldBattleInventory(game, f.afterSetup);
});
void test('all14 earned Arrakeen funds actual Cyborg support while selected Suk rescues board losses before original Face Dance', () => {
const f = createNativeWorldPhaseBattleFixture({ skills: true, skilled: true, harass: false, support: 3,
training: { ixians: 'suk-graduate', tleilaxu: 'killer-medic', guild: 'sandmaster' } });
assert.equal(f.beforeFirstMentat.strongholdCards!.owners.arrakeen, null);
assert.equal(f.afterFirstMentat.strongholdCards!.owners.arrakeen, f.owner);
assert.equal(viewGame(f.game, f.owner).battle!.strongholdEffects[f.owner], 'arrakeen');
let game = advanceNativeWorldBattle(revealNativeWorldPhaseBattle(f), g => g.decision?.kind === 'sukRescue', [],
{ casualties: { normal: 0, elite: 3 } });
assert.ok(game.decision?.kind === 'sukRescue');
assert.deepEqual(game.pendingSukRescue!.pool, [{ key: f.location, normal: 3, elite: 3 }]);
assert.deepEqual(game.pendingSukRescue!.losses, { normal: 0, elite: 3, paidNormal: 0, paidElite: 3 });
assert.equal(player(game, f.owner).spice, player(f.game, f.owner).spice - 1,
'Only the real earned Arrakeen benefit covers two of the three selected support');
assert.equal(player(game, f.owner).tanks, 3);
assert.equal(player(game, f.owner).elites!.tanks, 0);
assert.ok(game.decision.options.every(o => o.normal === 0), 'The three original native fee normals are not rescuable');
const choice = game.decision.options.findIndex(o => o.elite === 3 && o.kept?.key === f.location && o.kept.kind === 'elite');
assert.ok(choice >= 0);
game = applyAction(game, f.owner, { type: 'decision', event: game.decision.event, choice });
game = settleNativeWorldPhaseBattle(game, f);
assert.equal(game.decision?.kind, 'faceDance', 'The actual native spectator continuation resumes after Suk and card disposal');
assert.deepEqual(pool(game, f.owner, 'homeworld:ixians'), { normal: 4, elite: 3 });
assert.deepEqual(combatArmy(game, f.owner, 'arrakeen'), { normal: 3, elite: 1 });
assert.equal(game.lastBattleContext!.sukRescue!.physical!.source, 'arrakis');
assert.deepEqual(game.lastBattleContext!.sukRescue!.physical!.destinations,
{ 'homeworld:ixians': { normal: 0, elite: 2 } });
const done = finishNativeWorldPhaseBattle(game, f);
assert.equal(done.lastBattleContext!.winner, f.owner);
assert.equal(done.lastBattleContext!.sukRescue!.completed, true);
assert.equal(player(done, f.owner).tanks, 3);
assert.equal(player(done, f.owner).elites!.tanks, 0);
assert.equal(player(done, f.owner).battleLosses, 6);
assert.equal(done.discard.filter(c => c.id === REINFORCEMENTS_CARD).length, 1);
assert.equal(done.strongholdCards!.owners.arrakeen, f.owner);
assert.ok(done.techTokens);
assertNativeWorldBattleInventory(done, f.afterSetup);
});
void test('Basic all14 visitor Suk keeps the only committed Cyborg before native Face Dance returns it and kills the trained disc', () => {
const f = createNativeWorldPhaseForeignBattleFixture({ advanced: false, skills: true, skilled: true, typedAftercare: true,
training: { ixians: 'suk-graduate', tleilaxu: 'killer-medic', guild: 'sandmaster' } });
assert.equal(viewGame(f.game, f.owner).battle!.strongholdEffects[f.owner], null);
let game = advanceNativeWorldBattle(revealNativeWorldBattle(f), g => g.decision?.kind === 'sukRescue', [],
{ casualties: { normal: 0, elite: 1 } });
assert.ok(game.decision?.kind === 'sukRescue');
assert.deepEqual(game.pendingSukRescue!.losses, { normal: 0, elite: 1, paidNormal: 0, paidElite: 0 });
assert.equal(player(game, f.owner).tanks, 3);
assert.equal(player(game, f.owner).elites!.tanks, 2);
const choice = game.decision.options.findIndex(o => o.elite === 1 && o.kept?.key === f.location && o.kept.kind === 'elite');
assert.ok(choice >= 0);
game = applyAction(game, f.owner, { type: 'decision', event: game.decision.event, choice });
game = advanceNativeWorldBattle(game, g => g.decision?.kind === 'faceDance');
assert.equal(game.pendingIxSubstitution, undefined, 'The single board Cyborg was saved, while the two fee Cyborgs cannot supply substitution');
assert.deepEqual(combatArmy(game, f.owner, f.location), { normal: 9, elite: 1 });
assert.equal(game.decision!.player, f.opponent);
assert.equal(game.discard.filter(c => c.id === REINFORCEMENTS_CARD).length, 1);
game = applyAction(game, f.opponent, { type: 'decision', reveal: true, sources: {} });
game = advanceNativeWorldBattle(game, nativeWorldBattleFinished);
assert.deepEqual(combatArmy(game, f.owner, f.location), { normal: 0, elite: 0 });
assert.deepEqual(pool(game, f.owner, 'homeworld:ixians'), { normal: 9, elite: 2 });
assert.equal(player(game, f.owner).tanks, 3);
assert.equal(player(game, f.owner).elites!.tanks, 2);
assert.equal(player(game, f.owner).leaders.find(l => l.id === f.planActions[0].action.leader)!.dead, true);
assert.ok(!game.leaderSkills!.assignments.some(a => a.leader === f.planActions[0].action.leader));
assert.equal(game.leaderSkills!.deck.filter(skill => skill === 'suk-graduate').length, 1,
'Original native Face Dance death returns the singleton trained card instead of retaining a dead assignment');
assertNativeWorldBattleInventory(game, f.afterSetup);
});