151 lines
10 KiB
TypeScript
151 lines
10 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);
|
|
});
|