import test from 'node:test'; import assert from 'node:assert/strict'; import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game, } from '../game/engine'; import { quoteSpiceCollection } from '../game/board-resolution-quote'; import { createJacurutuMixedDialFixture, jacurutuBattleReceipt, jacurutuCurrentDecision, } from './fixture-jacurutu-mixed-dial'; const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)); const boardCount = (game: Game, id: string) => Object.values(game.players.find(player => player.id === id)!.forces) .reduce((sum, count) => sum + count, 0); const settlementLogs = (game: Game) => game.log.filter(entry => entry.automatic?.name === 'Jacurutu Sietch income'); /** The genuine fixture advanced to its single pending physical dial choice. */ function pausedDialChoice(options?: { dial?: number; support?: number }) { const fixture = createJacurutuMixedDialFixture(options); const game = applyAction(reload(fixture.game), fixture.finalVote.actor, fixture.finalVote.action); return { fixture, game }; } void test('the genuine Advanced battle is reached through real blows, shipments and free entry', () => { const fixture = createJacurutuMixedDialFixture(); const token = fixture.game.discoveries!.tokens.find(candidate => candidate.id === fixture.token)!; assert.equal(token.face, 'jacurutu-sietch'); assert.equal(token.status, 'placed'); assert.equal(token.revealedTurn, 1); assert.equal(`${token.territory}:${token.sector}`, fixture.parent); assert.equal(fixture.parent, 'gara_kulon:8'); const emperor = fixture.game.players.find(player => player.id === fixture.defender)!; assert.equal(emperor.faction, 'emperor'); assert.equal(fixture.game.battle!.territory, fixture.territory); assert.equal(emperor.forces['jacurutu-sietch:0'], 2); assert.equal(emperor.elites!.forces['jacurutu-sietch:0'], 1); for (const player of fixture.game.players) { assert.equal(player.reserves + player.tanks + boardCount(fixture.game, player.id), 20); assert.ok(player.reserves >= (player.elites?.reserves ?? 0)); assert.ok(player.tanks >= (player.elites?.tanks ?? 0)); } assert.equal(jacurutuBattleReceipt(fixture.game), null); assert.equal(jacurutuCurrentDecision(fixture.game), null); }); void test('the defeated owner alone sees both real physical readings of the mixed dial', () => { const { fixture, game } = pausedDialChoice(); const decision = jacurutuCurrentDecision(game); assert.ok(decision); assert.equal(decision.player, fixture.defender); const receipt = jacurutuBattleReceipt(game)!; assert.equal(receipt.event, decision.event); assert.equal(receipt.owner, fixture.defender); assert.equal(receipt.winner, fixture.attacker); assert.deepEqual(receipt.choices.map(choice => choice.amount), [1, 0]); assert.deepEqual(receipt.choices.map(choice => choice.dialed), [ { normal: 0, elite: 1, paidNormal: 0, paidElite: 1 }, { normal: 1, elite: 1, paidNormal: 1, paidElite: 0 }, ]); assert.deepEqual(receipt.selectedDial ?? null, null); assert.equal(viewGame(game, fixture.attacker).jacurutuDial, null); const ownerView = viewGame(game, fixture.defender).jacurutuDial!; assert.deepEqual(ownerView.choices.map(choice => choice.amount), [1, 0]); // The gate pauses before any settlement: no payment, casualty or reward yet. for (const player of game.players) { assert.equal(player.spice, fixture.game.players.find(candidate => candidate.id === player.id)!.spice); assert.equal(player.tanks, fixture.game.players.find(candidate => candidate.id === player.id)!.tanks); } assert.equal(game.players.find(player => player.id === fixture.defender)!.forces['jacurutu-sietch:0'], 2); assert.equal(settlementLogs(game).length, 0); }); void test('either real reading settles the same original battle once and differs only by the Bank reward', () => { const { fixture, game } = pausedDialChoice(); const before = reload(game); const decision = jacurutuCurrentDecision(game)!; const spiceBefore = Object.fromEntries(game.players.map(player => [player.id, player.spice])); const first = applyAction(reload(game), fixture.defender, { type: 'decision', event: decision.event, allocation: 0, }); assert.deepEqual(game, before, 'A valid choice must not mutate the caller’s paused battle.'); const second = applyAction(reload(game), fixture.defender, { type: 'decision', event: decision.event, allocation: 1, }); const spiceOf = (settled: Game, id: string) => settled.players.find(player => player.id === id)!.spice; // The original battle completion also opens ordinary Collection; its city // income remains independent of Jacurutu's reward and both support payments. const collection = Object.fromEntries(quoteSpiceCollection(game).receipts.map(receipt => [receipt.player,receipt.strongholds+receipt.collected])); assert.equal(first.phase,7); assert.equal(spiceOf(first, fixture.defender), spiceBefore[fixture.defender] - 1 + collection[fixture.defender]); assert.equal(spiceOf(second, fixture.defender), spiceBefore[fixture.defender] - 1 + collection[fixture.defender]); assert.equal(spiceOf(first, fixture.attacker), spiceBefore[fixture.attacker] - 2 + 1 + collection[fixture.attacker]); assert.equal(spiceOf(second, fixture.attacker), spiceBefore[fixture.attacker] - 2 + collection[fixture.attacker]); assert.equal(spiceOf(first, fixture.observer), spiceBefore[fixture.observer] + collection[fixture.observer]); // Everything except the Bank reward is the identical single continuation. for (const [index, player] of first.players.entries()) assert.deepEqual({ ...player, spice: 0 }, { ...second.players[index], spice: 0 }); assert.equal(settlementLogs(first).length, 1); assert.equal(settlementLogs(second).length, 0); assert.equal(first.log.filter(entry => entry.automatic?.name === 'Battle').length, 1); assert.equal(first.log.filter(entry => entry.automatic?.name === 'Jacurutu physical dial').length, 1); assert.equal(second.log.filter(entry => entry.automatic?.name === 'Jacurutu physical dial').length, 1); const emperor = first.players.find(player => player.id === fixture.defender)!; assert.equal(emperor.tanks, fixture.defenderTanks.normal + fixture.defenderTanks.elite); assert.equal(emperor.elites!.tanks, fixture.defenderTanks.elite); assert.equal(emperor.forces['jacurutu-sietch:0'], undefined); const atreides = first.players.find(player => player.id === fixture.attacker)!; assert.equal(atreides.tanks, 2, 'The winning Advanced commitment settles its own losses once.'); assert.equal(atreides.forces['jacurutu-sietch:0'], 1); for (const player of first.players) assert.equal(player.reserves + player.tanks + boardCount(first, player.id), 20); assert.equal(first.battle, null); assert.equal(jacurutuCurrentDecision(first), null); assert.equal(jacurutuBattleReceipt(first), null); assert.equal(first.lastBattleContext?.winner, fixture.attacker); const stable = reload(first); assert.deepEqual(normalizeAutomaticGame(reload(stable)), stable); assert.throws(() => applyAction(reload(first), fixture.defender, { type: 'decision', event: decision.event, allocation: 0, }), /not available|pending decision/); }); void test('a unique reading of the same real battle pays automatically without a choice', () => { const fixture = createJacurutuMixedDialFixture({ dial: 1, support: 0 }); assert.equal(fixture.game.battle!.plans[fixture.defender]!.dial, fixture.dial); assert.equal(fixture.game.battle!.plans[fixture.defender]!.support, fixture.support); const spiceBefore = Object.fromEntries(fixture.game.players.map(player => [player.id, player.spice])); const done = applyAction(reload(fixture.game), fixture.finalVote.actor, fixture.finalVote.action); assert.equal(jacurutuCurrentDecision(done), null); assert.equal(jacurutuBattleReceipt(done), null); assert.equal(done.battle, null); const collection = Object.fromEntries(quoteSpiceCollection(fixture.game).receipts.map(receipt => [receipt.player,receipt.strongholds+receipt.collected])); assert.equal(done.phase,7); assert.equal(done.players.find(player => player.id === fixture.attacker)!.spice, spiceBefore[fixture.attacker] - 2 + 1 + collection[fixture.attacker]); assert.equal(done.players.find(player => player.id === fixture.defender)!.spice, spiceBefore[fixture.defender] + collection[fixture.defender]); assert.equal(settlementLogs(done).length, 1); const emperor = done.players.find(player => player.id === fixture.defender)!; assert.equal(emperor.tanks, 2); assert.equal(emperor.elites!.tanks, 1); assert.equal(emperor.forces['jacurutu-sietch:0'], undefined); for (const player of done.players) assert.equal(player.reserves + player.tanks + boardCount(done, player.id), 20); const stable = reload(done); assert.deepEqual(normalizeAutomaticGame(reload(stable)), stable); }); void test('a wrong actor, stale event or stale index never changes the paused battle', () => { const { fixture, game } = pausedDialChoice(); const before = reload(game); const decision = jacurutuCurrentDecision(game)!; assert.throws(() => applyAction(reload(game), fixture.attacker, { type: 'decision', event: decision.event, allocation: 0, }), /pending decision/); assert.throws(() => applyAction(reload(game), fixture.observer, { type: 'decision', event: decision.event, allocation: 0, }), /pending decision/); for (const action of [ { type: 'decision', event: 'stale-event', allocation: 0 }, { type: 'decision', event: decision.event, allocation: 2 }, { type: 'decision', event: decision.event, allocation: -1 }, { type: 'decision', event: decision.event, allocation: 0.5 }, { type: 'decision', event: decision.event }, { type: 'decision', event: decision.event, allocation: 0, dial: 2 }, ] as Action[]) assert.throws(() => applyAction(reload(game), fixture.defender, action), /Jacurutu dial/); assert.deepEqual(game, before); const receipt = jacurutuBattleReceipt(game)!; assert.deepEqual(receipt.selectedDial ?? null, null); assert.deepEqual(viewGame(game, fixture.defender).jacurutuDial!.choices.map(choice => choice.amount), [1, 0]); });