220 lines
15 KiB
TypeScript
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);
|
|
});
|