import test from 'node:test'; import assert from 'node:assert/strict'; import { jacurutuDialSelection, quoteJacurutuBattleIncome, type JacurutuDialOffer, } from '../game/discovery-battle'; import { applyAction, normalizeAutomaticGame, type Game } from '../game/engine'; import { JACURUTU_SIETCH } from '../game/discoveries'; import { discoveryBattleBeforeFinalVote } from './fixture-discovery-battle'; const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)); void test('the source-bound contract quotes only opposing undialed counters actually sent to the Tanks', () => { const ordinary = { normal: 5, elite: 0, eliteStrength: 1 as const, freeSupport: true, }; assert.deepEqual( quoteJacurutuBattleIncome({ territory: JACURUTU_SIETCH, jacurutuRevealed: true, winner: 'winner', opponent: 'loser', opponentForces: ordinary, opponentDial: 2, opponentSupport: 0, sentToTanks: { normal: 5, elite: 0 }, }), { kind: 'income', winner: 'winner', opponent: 'loser', amount: 3, sentToTanks: { normal: 5, elite: 0 }, dialed: [{ normal: 2, elite: 0, paidNormal: 0, paidElite: 0 }], }, ); const withdrawn = quoteJacurutuBattleIncome({ territory: JACURUTU_SIETCH, jacurutuRevealed: true, winner: 'winner', opponent: 'loser', opponentForces: ordinary, opponentDial: 2, opponentSupport: 0, sentToTanks: { normal: 2, elite: 0 }, }); assert.equal(withdrawn?.kind, 'income'); assert.equal(withdrawn?.kind === 'income' ? withdrawn.amount : -1, 0); }); void test('mixed Advanced allocations remain explicit when they imply different physical payouts', () => { const quote = quoteJacurutuBattleIncome({ territory: JACURUTU_SIETCH, jacurutuRevealed: true, winner: 'winner', opponent: 'emperor', opponentForces: { normal: 5, elite: 1, eliteStrength: 2, freeSupport: false, }, opponentDial: 3, opponentSupport: 1, sentToTanks: { normal: 5, elite: 1 }, }); assert.equal(quote?.kind, 'allocationRequired'); if (quote?.kind !== 'allocationRequired') return; assert.deepEqual( [...new Set(quote.choices.map((choice) => choice.amount))].sort( (left, right) => left - right, ), [1, 2, 3], ); }); void test('no winner, another territory, or a hidden Jacurutu never creates income', () => { const partial = { opponent: null, opponentForces: null, opponentDial: null, opponentSupport: null, sentToTanks: null, }; assert.equal( quoteJacurutuBattleIncome({ ...partial, territory: JACURUTU_SIETCH, jacurutuRevealed: true, winner: null, }), null, ); assert.equal( quoteJacurutuBattleIncome({ ...partial, territory: 'arrakeen', jacurutuRevealed: true, winner: 'winner', }), null, ); assert.equal( quoteJacurutuBattleIncome({ ...partial, territory: JACURUTU_SIETCH, jacurutuRevealed: false, winner: 'winner', }), null, ); }); void test('real Basic and Advanced battles pay once from the losing plan and add a typed chronicle event', () => { for (const advanced of [false, true]) { const { game: pending, attacker, defender, } = discoveryBattleBeforeFinalVote(advanced); const winnerBefore = pending.players.find( (player) => player.id === attacker, )!.spice; const loser = pending.players.find((player) => player.id === defender)!; loser.tanks = 4; loser.reserves = 11; const before = structuredClone(pending); const done = applyAction(reload(pending), defender, { type: 'traitorCall', call: false, }); assert.deepEqual(pending, before); assert.equal( done.players.find((player) => player.id === attacker)!.spice, winnerBefore - (advanced ? 1 : 0) + 3, ); assert.equal( done.players.find((player) => player.id === defender)!.tanks, 9, ); assert.equal( done.log.filter( (entry) => entry.automatic?.name === 'Jacurutu Sietch income', ).length, 1, ); const stable = reload(done); assert.deepEqual(normalizeAutomaticGame(reload(stable)), stable); } }); void test('winner casualties and pre-existing Tanks do not increase Jacurutu income', () => { const { game: pending, attacker, defender, } = discoveryBattleBeforeFinalVote(false); const winner = pending.players.find((player) => player.id === attacker)!; winner.tanks = 3; winner.reserves = 12; const beforeSpice = winner.spice; const done = applyAction(pending, defender, { type: 'traitorCall', call: false, }); const settled = done.players.find((player) => player.id === attacker)!; assert.equal(settled.tanks, 4); assert.equal(settled.spice, beforeSpice + 3); }); void test('a real traitor winner still receives only the opposing undialed Tanks income', () => { const { game: pending, attacker, defender, } = discoveryBattleBeforeFinalVote(false, ['a', 'g', 'f'], true); const beforeSpice = pending.players.find( (player) => player.id === attacker, )!.spice; const done = applyAction(pending, defender, { type: 'traitorCall', call: false, }); assert.equal(done.lastBattleContext?.result, 'traitor'); assert.equal( done.players.find((player) => player.id === attacker)!.spice, beforeSpice + 1 + 3, ); assert.equal( done.log.filter( (entry) => entry.automatic?.name === 'Jacurutu Sietch income', ).length, 1, ); }); const mixedDialInput = { territory: JACURUTU_SIETCH, jacurutuRevealed: true, winner: 'winner', opponent: 'emperor', opponentForces: { normal: 1, elite: 1, eliteStrength: 2 as const, freeSupport: false, }, opponentDial: 2, opponentSupport: 1, sentToTanks: { normal: 1, elite: 1 }, }; void test('the defeated mixed dial offers both physical readings and the index resolver returns them', () => { const pending = quoteJacurutuBattleIncome(mixedDialInput); assert.equal(pending?.kind, 'allocationRequired'); if (pending?.kind !== 'allocationRequired') return; assert.deepEqual( pending.choices.map((choice) => choice.amount), [1, 0], ); assert.deepEqual( pending.choices.map((choice) => choice.dialed), [ { normal: 0, elite: 1, paidNormal: 0, paidElite: 1 }, { normal: 1, elite: 1, paidNormal: 1, paidElite: 0 }, ], ); assert.deepEqual(pending.sentToTanks, { normal: 1, elite: 1 }); const offer: JacurutuDialOffer = { event: 'event', owner: 'emperor', winner: 'winner', choices: pending.choices, }; assert.deepEqual(jacurutuDialSelection(offer, 0), { normal: 0, elite: 1, paidNormal: 0, paidElite: 1, }); assert.deepEqual(jacurutuDialSelection(offer, 1), { normal: 1, elite: 1, paidNormal: 1, paidElite: 0, }); for (const allocation of [2, -1, 0.5, Number.NaN]) assert.equal(jacurutuDialSelection(offer, allocation), null); for (const [allocation, amount] of [ [0, 1], [1, 0], ] as const) assert.deepEqual( quoteJacurutuBattleIncome({ ...mixedDialInput, selectedDial: pending.choices[allocation].dialed, }), { kind: 'income', winner: 'winner', opponent: 'emperor', amount, sentToTanks: { normal: 1, elite: 1 }, dialed: [pending.choices[allocation].dialed], }, ); }); void test('a selected reading must be a legal allocation of the revealed dial and never exceed the Tanks', () => { // The same counters with the other paid distribution describe a different // committed strength and are not a reading of this dial and support. assert.throws( () => quoteJacurutuBattleIncome({ ...mixedDialInput, selectedDial: { normal: 1, elite: 1, paidNormal: 0, paidElite: 1 }, }), /legal physical allocation/, ); // The revealed plan and support cannot have dialed only the ordinary counter. assert.throws( () => quoteJacurutuBattleIncome({ ...mixedDialInput, selectedDial: { normal: 1, elite: 0, paidNormal: 1, paidElite: 0 }, }), /legal physical allocation/, ); // A real reading of the plan that the settled Tanks losses cannot contain. assert.throws( () => quoteJacurutuBattleIncome({ ...mixedDialInput, sentToTanks: { normal: 0, elite: 1 }, selectedDial: { normal: 1, elite: 1, paidNormal: 1, paidElite: 0 }, }), /exceeds/, ); // Withdrawal leaves the committed Sardaukar reading and a zero reward. assert.deepEqual( quoteJacurutuBattleIncome({ ...mixedDialInput, sentToTanks: { normal: 0, elite: 1 }, selectedDial: { normal: 0, elite: 1, paidNormal: 0, paidElite: 1 }, }), { kind: 'income', winner: 'winner', opponent: 'emperor', amount: 0, sentToTanks: { normal: 0, elite: 1 }, dialed: [{ normal: 0, elite: 1, paidNormal: 0, paidElite: 1 }], }, ); });