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); });