import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine'; import { quoteSpiceCollection } from '../game/board-resolution-quote'; import { advanceSkillsTechPaymentsToPhase, createSkillsTechPaymentsFixture, nextSkillsTechPaymentsNativeStep, paySkillsTechPaymentsFixture, skillsTechPaymentsEmptyDestination, type SkillsTechPaymentsFixture, } from './fixture-skills-tech-payments'; function wallets(game: Game): Record { return Object.fromEntries(game.players.map(player => [player.id, player.spice])); } function assertPhysicalPayment(before: Game, after: Game): void { const cards = (game: Game) => [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)] .map(card => card.id).sort(); assert.equal(cards(before).length, 33); assert.equal(new Set(cards(after)).size, 33); assert.deepEqual(cards(after), cards(before), 'Payment must not create, lose or redeal a physical card.'); assert.deepEqual(after.leaderSkills, before.leaderSkills, 'Normal income does not move a skill card or training disc.'); for (const player of after.players) assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, amount) => sum + amount, 0), 20); } function deferred(game: Game, owner: string): { owner: string; amount: number }[] { return viewGame(game, owner).spiceBankerIncome!.deferred; } /** Native phase-end payout may also auto-skip empty Battle and collect the board. * Account for that independent original quote; only Banker waits for Mentat. */ function assertSeparateCollections(fixture: SkillsTechPaymentsFixture, paid: Game, bankerAmount: number, techAmount: number): Game { const before = wallets(paid); const sources = structuredClone(paid.spiceBankerIncome!.sources); const phaseEnd = advanceSkillsTechPaymentsToPhase(paid, paid.phase + 1); const expected = phaseEnd.phase === 7 ? Object.fromEntries(quoteSpiceCollection(paid).receipts.map(receipt => [receipt.player, receipt.balance])) : { ...before }; expected[fixture.tokenOwner] += techAmount; assert.deepEqual(wallets(phaseEnd), expected, 'Native Collection plus only the original token owner’s phase-end pile.'); assert.equal(phaseEnd.techTokens![fixture.token].spice, 0); assert.equal(phaseEnd.techTokens![fixture.token].triggeredTurn, techAmount ? paid.turn : undefined); assert.deepEqual(deferred(phaseEnd, fixture.owner), bankerAmount ? [{ owner: fixture.owner, amount: bankerAmount }] : []); assert.deepEqual(phaseEnd.spiceBankerIncome!.sources, sources, 'A Tech credit is not a qualifying Banker payment.'); const collection = advanceSkillsTechPaymentsToPhase(phaseEnd, 7); const collectionWallets = wallets(collection); // Includes original stronghold/desert Collection, not invented funding. assert.deepEqual(deferred(collection, fixture.owner), bankerAmount ? [{ owner: fixture.owner, amount: bankerAmount }] : []); const mentat = advanceSkillsTechPaymentsToPhase(collection, 8); const expectedMentat = { ...collectionWallets }; expectedMentat[fixture.owner] += bankerAmount; assert.deepEqual(wallets(mentat), expectedMentat); assert.deepEqual(deferred(mentat, fixture.owner), []); assert.deepEqual(mentat.spiceBankerIncome!.sources, sources, 'Banker collection must not recursively earn Banker income.'); assert.equal(mentat.techTokens![fixture.token].spice, 0, 'Mentat income does not retrigger technology.'); const next = nextSkillsTechPaymentsNativeStep(mentat); assert.ok(next); const continued = applyAction(mentat, next.actor, next.action); assert.equal(continued.phase, 8); assert.deepEqual(wallets(continued), expectedMentat, 'Further real Mentat readiness cannot credit either pile twice.'); assert.deepEqual(continued.spiceBankerIncome!.collections, mentat.spiceBankerIncome!.collections); assertPhysicalPayment(fixture.beforePayment, continued); return continued; } for (const advanced of [false, true]) { const band = advanced ? 'Advanced' : 'Basic'; void test(`${band} Banker + Axlotl: actual four-spice revival delivers forces, keeps both earning stores unspendable, and collects each once at its own boundary`, () => { const fixture = createSkillsTechPaymentsFixture({ advanced }); const before = fixture.beforePayment; const payer = before.players.find(player => player.id === fixture.payer)!; assert.equal(fixture.cost, 4); assert.equal(before.techTokens!.axlotl.spice, 0); const paid = paySkillsTechPaymentsFixture(fixture); const revived = paid.players.find(player => player.id === payer.id)!; assert.equal(revived.reserves, payer.reserves + 3); assert.equal(revived.tanks, payer.tanks - 3); assert.equal(revived.revived, payer.revived + 3); assert.equal(revived.freeForcesRevived, 1); const expected = wallets(before); expected[payer.id] -= 4; assert.deepEqual(wallets(paid), expected, 'Neither accrued pile is available money during Revival.'); assert.deepEqual(deferred(paid, fixture.owner), [{ owner: fixture.owner, amount: 1 }]); assert.equal(paid.techTokens!.axlotl.spice, 1); assert.equal(paid.techTokens!.axlotl.triggeredTurn, paid.turn); assert.deepEqual(paid.spiceBankerIncome!.sources.filter(row => row.grant).map(row => row.source.bankLegs), [[{ payer: fixture.payer, amount: 4 }]]); // A second real free revival is another eligible Axlotl action, not another // physical token award. Stage only one conserved Harkonnen reserve casualty. const smuggler = paid.players.find(player => player.id === fixture.smugglerOwner)!; smuggler.reserves -= 1; smuggler.tanks += 1; const repeat = applyAction(paid, smuggler.id, { type: 'revive', amount: 1 }); assert.equal(repeat.players.find(player => player.id === smuggler.id)!.reserves, smuggler.reserves + 1); assert.equal(repeat.players.find(player => player.id === smuggler.id)!.tanks, smuggler.tanks - 1); assert.deepEqual(wallets(repeat), expected); assert.equal(repeat.techTokens!.axlotl.spice, 1); assert.deepEqual(deferred(repeat, fixture.owner), [{ owner: fixture.owner, amount: 1 }]); assertSeparateCollections(fixture, repeat, 1, 1); }); for (const amount of [1, 2]) void test(`${band} Axlotl free-return trigger is independent of an actual ${amount === 1 ? 'zero' : 'two-spice nonqualifying'} Banker debit`, () => { const fixture = createSkillsTechPaymentsFixture({ advanced, amount }); const payer = fixture.beforePayment.players.find(player => player.id === fixture.payer)!; assert.equal(fixture.cost, amount === 1 ? 0 : 2); const paid = paySkillsTechPaymentsFixture(fixture); const revived = paid.players.find(player => player.id === payer.id)!; assert.equal(revived.spice, payer.spice - fixture.cost); assert.equal(revived.reserves, payer.reserves + amount); assert.equal(revived.tanks, payer.tanks - amount); assert.equal(paid.techTokens!.axlotl.spice, 1); assert.deepEqual(deferred(paid, fixture.owner), []); assert.equal(paid.spiceBankerIncome!.sources.some(row => row.grant), false); assertSeparateCollections(fixture, paid, 0, 1); }); void test(`${band} Smuggler + Banker + Heighliners: five physical counters cost four, two real bank invoices earn only one of each deferred award`, () => { const fixture = createSkillsTechPaymentsFixture({ advanced, kind: 'shipment' }); const before = fixture.beforePayment; const payer = before.players.find(player => player.id === fixture.payer)!; assert.equal(fixture.cost, 4); let paid = paySkillsTechPaymentsFixture(fixture); const shipped = paid.players.find(player => player.id === payer.id)!; assert.equal(shipped.reserves, payer.reserves - 5); assert.equal(shipped.forces[fixture.destination!], 5); assert.equal(shipped.spice, payer.spice - 4); const expected = wallets(before); expected[payer.id] -= 4; assert.deepEqual(wallets(paid), expected); assert.equal(paid.techTokens!.heighliners.spice, 1); assert.deepEqual(deferred(paid, fixture.owner), [{ owner: fixture.owner, amount: 1 }]); // Continue to Emperor using original endMovement; no phase or turn is staged. const emperor = paid.players.find(player => player.faction === 'emperor')!; for (let i = 0; paid.active !== emperor.id && i < 20; i++) { const next = nextSkillsTechPaymentsNativeStep(paid); assert.ok(next); paid = applyAction(paid, next.actor, next.action); } assert.equal(paid.phase, 5); assert.equal(paid.active, emperor.id); const target = skillsTechPaymentsEmptyDestination(paid); const secondBefore = paid.players.find(player => player.id === emperor.id)!; paid = applyAction(paid, emperor.id, { type: 'ship', ...target, amount: 4 }); assert.equal(paid.players.find(player => player.id === emperor.id)!.spice, secondBefore.spice - 4); assert.equal(paid.players.find(player => player.id === emperor.id)!.reserves, secondBefore.reserves - 4); assert.equal(paid.players.find(player => player.id === emperor.id)!.forces[`${target.territory}:${target.sector}`], 4); assert.equal(paid.techTokens!.heighliners.spice, 1); assert.equal(paid.spiceBankerIncome!.sources.filter(row => row.grant).length, 1); assert.deepEqual(deferred(paid, fixture.owner), [{ owner: fixture.owner, amount: 1 }]); assertSeparateCollections(fixture, paid, 1, 1); }); for (const smuggler of [true, false]) void test(`${band} Smuggler ${smuggler ? 'free companion drops' : 'decline retains'} the original four-counter invoice across Banker's four-spice threshold`, () => { const fixture = createSkillsTechPaymentsFixture({ advanced, kind: 'shipment', amount: 4, smuggler }); const before = fixture.beforePayment.players.find(player => player.id === fixture.payer)!; const paid = paySkillsTechPaymentsFixture(fixture); const after = paid.players.find(player => player.id === fixture.payer)!; assert.equal(after.reserves, before.reserves - 4); assert.equal(after.forces[fixture.destination!], 4); assert.equal(after.spice, before.spice - (smuggler ? 3 : 4)); assert.equal(paid.techTokens!.heighliners.spice, 1); assert.deepEqual(deferred(paid, fixture.owner), smuggler ? [] : [{ owner: fixture.owner, amount: 1 }]); assertSeparateCollections(fixture, paid, smuggler ? 0 : 1, 1); }); void test(`${band} actual Smuggler fee goes to Guild, while Heighliners accrues separately and Banker gains nothing`, () => { const fixture = createSkillsTechPaymentsFixture({ advanced, kind: 'shipment', withGuild: true }); const before = wallets(fixture.beforePayment); const guild = fixture.beforePayment.players.find(player => player.faction === 'guild')!; const paid = paySkillsTechPaymentsFixture(fixture); const expected = { ...before }; expected[fixture.payer] -= 4; expected[guild.id] += 4; assert.deepEqual(wallets(paid), expected, 'The original Guild fee excludes both deferred earnings.'); assert.equal(paid.players.find(player => player.id === fixture.payer)!.forces[fixture.destination!], 5); assert.equal(paid.techTokens!.heighliners.spice, 1); assert.deepEqual(deferred(paid, fixture.owner), []); assertSeparateCollections(fixture, paid, 0, 1); }); void test(`${band} Guild's actual own-bank shipment earns Banker but is excluded from the original Heighliners trigger`, () => { const fixture = createSkillsTechPaymentsFixture({ advanced, kind: 'guild-shipment' }); const before = fixture.beforePayment.players.find(player => player.id === fixture.payer)!; const paid = paySkillsTechPaymentsFixture(fixture); const after = paid.players.find(player => player.id === fixture.payer)!; assert.equal(after.spice, before.spice - 4); assert.equal(after.reserves, before.reserves - 8); assert.equal(after.forces[fixture.destination!], 8); assert.equal(paid.techTokens!.heighliners.spice, 0); assert.equal(paid.techTokens!.heighliners.triggeredTurn, undefined); assert.deepEqual(deferred(paid, fixture.owner), [{ owner: fixture.owner, amount: 1 }]); assertSeparateCollections(fixture, paid, 1, 0); }); } void test('fresh authenticated payment fixture retains original faction identities through setup, real Smuggler invoice and Mentat', () => { const initial = createGame('AUTHTECH', newPlayer('human-owner', 'Human owner', 'emperor'), true); joinGame(initial, newPlayer('human-companion', 'Human companion', 'harkonnen')); joinGame(initial, newPlayer('human-banker', 'Human banker', 'atreides')); const identities = initial.players.map(player => ({ id: player.id, name: player.name, faction: player.faction })); const fixture = createSkillsTechPaymentsFixture({ initial, kind: 'shipment' }); assert.equal(fixture.payer, 'human-companion'); assert.equal(fixture.owner, 'human-banker'); const paid = paySkillsTechPaymentsFixture(fixture); assert.equal(paid.players.find(player => player.id === fixture.payer)!.spice, fixture.beforePayment.players.find(player => player.id === fixture.payer)!.spice - 4); const mentat = assertSeparateCollections(fixture, paid, 1, 1); assert.deepEqual(mentat.players.map(player => ({ id: player.id, name: player.name, faction: player.faction })), identities); });