import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, viewGame, type Game } from '../game/engine'; import { REINFORCEMENTS_CARD } from '../game/reinforcements'; import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw'; import { harassHomeworldArrakisPool as pool } from './fixture-harass-homeworld-arrakis'; import { assertReinforcementsHomeworldArrakisInventory, createReinforcementsHomeworldArrakisFixture, finishReinforcementsHomeworldArrakisBattle, reinforcementsHomeworldArrakisClean, reinforcementsHomeworldArrakisPlayer as player, revealReinforcementsHomeworldArrakisBattle, sealReinforcementsHomeworldArrakisPlan, type ReinforcementsHomeworldArrakisFixture, } from './fixture-reinforcements-homeworld-arrakis'; function assertDisposed(game: Game, fixture: ReinforcementsHomeworldArrakisFixture, harass: boolean): void { for (const id of harass ? [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD] : [REINFORCEMENTS_CARD]) { assert.equal(game.discard.filter(c => c.id === id).length, 1, 'Used physical utility cards discard even for the winner'); assert.ok(!player(game, fixture.owner).hand.some(c => c.id === id)); } assert.equal(game.battle, null); assert.ok(reinforcementsHomeworldArrakisClean(game)); assertReinforcementsHomeworldArrakisInventory(game); } for (const advanced of [false, true]) { for (const slot of ['weapon', 'defense'] as const) { for (const outcome of ['winner', 'loser'] as const) { void test(`${advanced ? 'Advanced' : 'Basic'} Arrakis ${slot} ${outcome}: native fee is not dial, support or an army`, () => { const f = createReinforcementsHomeworldArrakisFixture({ advanced, slot, reserves: 'normal', outcome, harass: false }); const before = player(f.game, f.owner); const revealed = revealReinforcementsHomeworldArrakisBattle(f); const done = finishReinforcementsHomeworldArrakisBattle(revealed, { losses: outcome === 'winner' ? { normal: 1, elite: 1 } : { normal: 3, elite: 0 } }); const own = player(done, f.owner), other = player(done, f.opponent); assert.equal(done.lastBattleContext!.result, 'normal'); assert.equal(done.lastBattleContext!.winner, outcome === 'winner' ? f.owner : f.opponent); assert.equal(own.reserves, 10, 'Thirteen original native reserves pay exactly three normal counters'); assert.equal(own.elites!.reserves, 1); assert.equal(own.tanks, outcome === 'winner' ? 5 : 10, 'Fee3 plus the original two committed counters, or the original losing army7'); assert.equal(own.elites!.tanks, outcome === 'winner' ? 1 : 4); assert.equal(own.battleLosses, own.tanks); assert.equal(own.forces[f.location] ?? 0, outcome === 'winner' ? 5 : 0); assert.equal(own.elites!.forces[f.location] ?? 0, outcome === 'winner' ? 3 : 0); const collection = advanced && done.phase >= 7 && outcome === 'winner' ? 1 : 0; assert.equal(own.spice, before.spice - (advanced ? 1 : 0) + collection, 'Only the original sealed support is charged; later stronghold Collection is separate'); assert.equal(other.reserves, 13, 'The enemy native reserve pool never pays this fee'); assert.equal(other.tanks, outcome === 'winner' ? 7 : 3); assert.equal(other.forces[f.location] ?? 0, outcome === 'winner' ? 0 : 4); assert.deepEqual(pool(done, f.owner, 'homeworld:emperor'), advanced ? { normal: 9, elite: 0 } : { normal: 9, elite: 1 }); if (advanced) assert.deepEqual(pool(done, f.owner, 'homeworld:emperor:salusa'), { normal: 0, elite: 1 }); assertDisposed(done, f, false); if (outcome === 'winner') { // The same physical armies, leaders and dial lose without precisely // the utility's +2: Bashar2 + dial2 =4, against Staban Tuek5. const plain = structuredClone(f.planActions[0]); plain.action.weapon = null; plain.action.defense = null; delete plain.action.reinforcementsSources; let control = sealReinforcementsHomeworldArrakisPlan(structuredClone(f.game), plain); control = sealReinforcementsHomeworldArrakisPlan(control, f.planActions[1]); control = finishReinforcementsHomeworldArrakisBattle(control); assert.equal(control.lastBattleContext!.winner, f.opponent); assert.equal(player(control, f.owner).reserves, 13); assert.equal(player(control, f.owner).tanks, 7); assertReinforcementsHomeworldArrakisInventory(control); } }); } for (const reserves of ['mixed', 'elite'] as const) { void test(`${advanced ? 'Advanced' : 'Basic'} Arrakis ${slot}: real Fremen ${reserves} fee follows native normal-first custody`, () => { const f = createReinforcementsHomeworldArrakisFixture({ advanced, slot, reserves, outcome: 'winner', harass: false }); const done = finishReinforcementsHomeworldArrakisBattle(revealReinforcementsHomeworldArrakisBattle(f), { losses: { normal: 2, elite: 0 } }); const own = player(done, f.owner); assert.equal(done.lastBattleContext!.winner, f.owner); assert.equal(own.reserves, reserves === 'mixed' ? 1 : 0); assert.equal(own.elites!.reserves, reserves === 'mixed' ? 1 : 0); assert.equal(own.tanks, 5, 'Three native fee counters plus two original on-board normal casualties'); assert.equal(own.elites!.tanks, reserves === 'mixed' ? 2 : 3); assert.equal(own.battleLosses, 5); assert.equal(own.forces[f.location], reserves === 'mixed' ? 14 : 15); assert.equal(own.elites!.forces[f.location] ?? 0, 0); assert.deepEqual(pool(done, f.owner, 'homeworld:fremen'), reserves === 'mixed' ? { normal: 0, elite: 1 } : { normal: 0, elite: 0 }); assert.equal(player(done, f.opponent).reserves, player(f.game, f.opponent).reserves); assert.equal(player(done, f.opponent).tanks, 2); assertDisposed(done, f, false); }); } void test(`${advanced ? 'Advanced' : 'Basic'} ${slot}: same-plan Harass returns cannot replace the original real Fedaykin fee`, () => { const f = createReinforcementsHomeworldArrakisFixture({ advanced, slot, reserves: 'elite', outcome: 'winner', harass: true }); const revealed = revealReinforcementsHomeworldArrakisBattle(f); const returns = { [f.location]: { normal: 15, elite: 0 } }; const done = finishReinforcementsHomeworldArrakisBattle(revealed, { returns }); const own = player(done, f.owner); assert.equal(done.lastBattleContext!.winner, f.owner); assert.equal(own.reserves, 15, 'Three real native elites pay before fifteen real normals return'); assert.equal(own.elites!.reserves, 0); assert.equal(own.tanks, 5); assert.equal(own.elites!.tanks, 3, 'Newly returned normal reserves cannot replace the sealed elite payment'); assert.equal(own.battleLosses, 5); assert.equal(own.forces[f.location] ?? 0, 0); assert.deepEqual(pool(done, f.owner, 'homeworld:fremen'), { normal: 15, elite: 0 }); assertDisposed(done, f, true); }); } void test(`${advanced ? 'Advanced' : 'Basic'} same-plan Harass preserves the mixed native fee before returning normals`, () => { const f = createReinforcementsHomeworldArrakisFixture({ advanced, slot: 'defense', reserves: 'mixed', outcome: 'winner', harass: true }); const done = finishReinforcementsHomeworldArrakisBattle(revealReinforcementsHomeworldArrakisBattle(f), { returns: { [f.location]: { normal: 14, elite: 0 } } }); const own = player(done, f.owner); assert.equal(own.reserves, 15); assert.equal(own.elites!.reserves, 1); assert.equal(own.tanks, 5); assert.equal(own.elites!.tanks, 2); assert.deepEqual(pool(done, f.owner, 'homeworld:fremen'), { normal: 14, elite: 1 }); assertDisposed(done, f, true); }); void test(`${advanced ? 'Advanced' : 'Basic'} future Harass returns cannot fund two native reserves, and sources cannot bypass normal-first`, () => { const short = createReinforcementsHomeworldArrakisFixture({ advanced, slot: 'weapon', reserves: 'shortage', outcome: 'winner', harass: true }); assert.equal(player(short.game, short.owner).reserves, 2); assert.equal(player(short.game, short.owner).elites!.reserves, 2); assert.equal(player(short.game, short.owner).forces[short.location], 18); assert.equal(player(short.game, short.owner).elites!.forces[short.location], 1, 'A real free shipment moved the eighth physical counter, one original Fedaykin'); const before = structuredClone(short.game); assert.throws(() => applyAction(short.game, short.owner, short.planActions[0].action)); assert.deepEqual(short.game, before, 'Underfunded physical utility commitment is atomic'); const preview = viewGame(short.game, short.owner).battle!.reinforcements; assert.ok(preview?.blocked, 'A large possible later Harass return does not fund the current fee'); assertReinforcementsHomeworldArrakisInventory(short.game); const mixed = createReinforcementsHomeworldArrakisFixture({ advanced, slot: 'defense', reserves: 'mixed', outcome: 'winner', harass: false }); const mixedBefore = JSON.stringify(mixed.game); const wrong = structuredClone(mixed.planActions[0]); wrong.action.reinforcementsSources = { 'homeworld:fremen': { normal: 0, elite: 3 } }; assert.throws(() => applyAction(mixed.game, mixed.owner, wrong.action), 'Three elites exist, but the one native normal must pay first'); const foreign = structuredClone(mixed.planActions[0]); foreign.action.reinforcementsSources = { 'homeworld:guild': { normal: 1, elite: 2 } }; assert.throws(() => applyAction(mixed.game, mixed.owner, foreign.action), 'An opposing World cannot fund the selected native fee'); assert.equal(JSON.stringify(mixed.game), mixedBefore); assertReinforcementsHomeworldArrakisInventory(mixed.game); }); for (const resolution of ['owner', 'opponent', 'both', 'explosion'] as const) { void test(`${advanced ? 'Advanced' : 'Basic'} Arrakis ${resolution}: revealed native fee and mandatory disposal survive exceptional outcomes`, () => { const f = createReinforcementsHomeworldArrakisFixture({ advanced, slot: 'weapon', reserves: 'normal', outcome: 'winner', harass: true, traitor: resolution === 'explosion' ? undefined : resolution, explosion: resolution === 'explosion' }); const revealed = revealReinforcementsHomeworldArrakisBattle(f); const callers = resolution === 'both' ? [f.owner, f.opponent] : resolution === 'owner' ? [f.owner] : resolution === 'opponent' ? [f.opponent] : []; const done = finishReinforcementsHomeworldArrakisBattle(revealed, { callers, returns: { [f.location]: { normal: 2, elite: 3 } } }); const canceled = resolution === 'opponent' || resolution === 'both'; const own = player(done, f.owner); assert.equal(done.lastBattleContext!.result, resolution === 'both' ? 'mutualTraitors' : resolution === 'explosion' ? 'explosion' : 'traitor'); assert.equal(done.lastBattleContext!.winner, resolution === 'owner' ? f.owner : resolution === 'opponent' ? f.opponent : null); assert.equal(own.reserves, canceled ? 10 : 15, 'Every revealed outcome pays the original native fee; only opposing Traitor cancels Harass returns'); assert.equal(own.elites!.reserves, canceled ? 1 : 4); assert.equal(own.tanks, canceled ? 10 : resolution === 'owner' ? 3 : 5); assert.equal(own.elites!.tanks, canceled ? 4 : resolution === 'owner' ? 0 : 1); assert.equal(own.battleLosses, own.tanks); assert.equal(own.forces[f.location] ?? 0, resolution === 'owner' ? 2 : 0, 'Only the own-Traitor winner retains its original committed physical complement'); assert.equal(own.elites!.forces[f.location] ?? 0, resolution === 'owner' ? 1 : 0); assert.deepEqual(pool(done, f.owner, 'homeworld:emperor'), advanced ? { normal: canceled ? 9 : 11, elite: 0 } : { normal: canceled ? 9 : 11, elite: canceled ? 1 : 4 }); if (advanced) assert.deepEqual(pool(done, f.owner, 'homeworld:emperor:salusa'), { normal: 0, elite: canceled ? 1 : 4 }); assertDisposed(done, f, true); }); } }