import assert from 'node:assert/strict'; import test from 'node:test'; import type { DatabaseSync, SQLOutputValue } from 'node:sqlite'; import { applyAction, viewGame, type Action, type Game } from '../game/engine'; import { createLeaderSkills } from '../game/leader-skills'; import type { RoomsClock, SeatAuth } from '../db/rooms'; import type * as Rooms from '../db/rooms'; import { startPrototypeRoom } from '../tools/prototype-room'; import { unitStore } from './fixture-nexus-room-store'; import { createSpiceBankerIncomeFixture, createSpiceBankerIncomeReassignmentFixture, nextSpiceBankerIncomeNativeStep, quoteSpiceBankerIncomeBattleEconomics, stageSpiceBankerIncomeTrainerBattle, withSpiceBankerIncomeSetupRandomness, type SpiceBankerIncomeFixture, type SpiceBankerIncomeFixtureOptions as Options, } from './fixture-spice-banker-income'; type SqlRow = Record; interface Fixture extends SpiceBankerIncomeFixture { sqlite: DatabaseSync; restart(): typeof Rooms; hooks: { beforeWrite?: () => Promise }; tokens: string[]; auths: SeatAuth[]; code: string; unrelatedCode: string; unrelatedToken: string; protectedRows: Record; stock: string[]; save(game: Game): void; } const clock: RoomsClock = { now: () => 10000, sleep: async () => {} }; function records(sqlite: DatabaseSync, excludedRoom?: string) { const result: Record = {}; for (const row of sqlite.prepare("SELECT name FROM sqlite_schema WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all()) { assert.equal(typeof row.name, 'string'); const table = String(row.name).replaceAll('"', '""'); result[String(row.name)] = sqlite.prepare(`SELECT * FROM "${table}"`).all() .filter(value => !(row.name === 'rooms' && value.code === excludedRoom)); } return result; } function player(game: Game, id: string) { const value = game.players.find(seat => seat.id === id); assert.ok(value); return value; } function physical(game: Game) { const cards = [...game.deck, ...game.discard, ...game.players.flatMap(seat => seat.hand), ...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])]; const ids = cards.map(card => card.id).sort(); assert.equal(new Set(ids).size, ids.length, 'one physical custodian per original Treachery Card'); return ids; } async function authenticate(f: Fixture, id: string) { const index = f.auths.findIndex(auth => auth.playerId === id); assert.ok(index >= 0); const auth = await f.restart().authenticate(f.code, f.tokens[index]); assert.equal(auth.playerId, id); return auth; } async function act(f: Fixture, game: Game, actor: string, action: Action) { await f.restart().act(f.code, await authenticate(f, actor), game.version, action, clock); if (action.type === 'advanceBots') await f.restart().continueRoomAutomatic(f.code, clock); return f.restart().readRoom(f.code); } async function fixture(t: test.TestContext, options: Options = {}): Promise { const store = unitStore(); t.after(() => store.sqlite.close()); const bankerFaction = options.bankerFaction ?? 'atreides'; const made = await store.rooms.createRoom('Original Banker faction', bankerFaction, options.advanced ?? true, []); const code = made.view.code; const tokens = [made.token]; const factions = [bankerFaction === 'harkonnen' ? 'atreides' : 'harkonnen', 'beneGesserit'] as const; for (const faction of [...factions, ...(options.guild ? ['guild'] as const : []), ...(options.emperor || options.kind === 'emperor-extra-revival' ? ['emperor'] as const : [])]) { const joined = await store.rooms.joinRoom(code, faction, faction); assert.ok(joined.token); tokens.push(joined.token); } const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token))); let lobby = await store.rooms.readRoom(code); for (const auth of auths) { await store.rooms.act(code, auth, lobby.version, { type: 'ready' }, clock); lobby = await store.rooms.readRoom(code); } // BG keeps the entry's actual native setup undealt. The fixture consumes that // original setup; it cannot reset already dealt cards or replace an offer. withSpiceBankerIncomeSetupRandomness(() => startPrototypeRoom(store.sqlite, code, lobby.version, 'leader-skills')); const setup = await store.rooms.readRoom(code); const native = createSpiceBankerIncomeFixture({ ...options, initial: setup }); assert.equal(native.beforePayment.code, code); assert.equal(native.beforePayment.host, lobby.host); assert.deepEqual(native.beforePayment.playerPositions, lobby.playerPositions); assert.deepEqual(native.beforePayment.players.map(seat => [seat.id, seat.name, seat.faction]), lobby.players.map(seat => [seat.id, seat.name, seat.faction])); const save = (game: Game) => { assert.equal(store.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=?') .run(JSON.stringify(game), game.version, code).changes, 1); }; save(native.beforePayment); const unrelated = await store.rooms.createRoom('Unrelated private lobby', 'guild', false, []); return { ...store, ...native, code, tokens, auths, save, unrelatedCode: unrelated.view.code, unrelatedToken: unrelated.token, protectedRows: records(store.sqlite, code), stock: physical(native.beforePayment) }; } async function restored(f: Fixture, game: Game) { const before = records(f.sqlite); for (const auth of f.auths) { const view = await f.restart().readSeatView(f.code, await authenticate(f, auth.playerId)); assert.deepEqual(view, viewGame(game, auth.playerId)); assert.deepEqual(Object.keys(view.spiceBankerIncome!).sort(), ['deferred', 'usedThisPhase']); assert.equal(Object.hasOwn(view, 'deck'), false); assert.equal(Object.hasOwn(view, 'spiceBankerIncomePayment'), false); const projected = JSON.stringify(view); const income = JSON.stringify(view.spiceBankerIncome); for (const receipt of game.spiceBankerIncome!.sources) { assert.equal(projected.includes(receipt.source.event), false, 'native payment event stays private in the entire view'); assert.equal(projected.includes(receipt.signature), false, 'receipt consistency data stays private in the entire view'); assert.equal(income.includes(JSON.stringify(receipt.source.bankLegs)), false, 'actual payer legs stay server-only'); } const pendingPayment = game.response?.spiceBankerIncomePayment; if (pendingPayment) { assert.equal(view.response!.spiceBankerIncomePayment, undefined); assert.equal(projected.includes('"spiceBankerIncomePayment":'), false, 'serialized response has no private invoice field'); assert.equal(projected.includes(pendingPayment.event), false, 'pending native invoice event stays private'); assert.equal(projected.includes(pendingPayment.signature), false, 'pending invoice signature stays private'); assert.equal(projected.includes(JSON.stringify(pendingPayment.legs)), false, 'pending own and donor legs stay private'); } for (const seat of view.players) if (seat.id !== auth.playerId) assert.equal(seat.hand, undefined); } assert.deepEqual(await f.restart().readRoom(f.code), game); assert.deepEqual(records(f.sqlite), before, 'authenticated projections never award, collect or repair'); assert.deepEqual(records(f.sqlite, f.code), f.protectedRows); assert.deepEqual(physical(game), f.stock); } async function rejected(f: Fixture, game: Game, auth: SeatAuth, action: Action, version = game.version) { const before = records(f.sqlite); await assert.rejects(f.restart().act(f.code, auth, version, action, clock)); assert.deepEqual(records(f.sqlite), before, 'rejected action changes no currency, card, credential or room'); } async function settlePayment(f: Fixture, before = f.beforePayment) { let game = await act(f, before, f.actor, f.paymentAction); for (let steps = 0; (game.response || game.decision?.kind === 'guildShipment') && steps < 30; steps++) { const step = nextSpiceBankerIncomeNativeStep(game); assert.ok(step); game = await act(f, game, step.actor, step.action); } assert.equal(game.response, null); return game; } async function finalCollectionStep(f: Fixture, initial: Game) { let game = initial; for (let steps = 0; steps < 500; steps++) { assert.notEqual(game.phase, 8, 'the original final Collection action must still be pending'); const step = nextSpiceBankerIncomeNativeStep(game); assert.ok(step, `native continuation is available in phase ${game.phase}`); if (game.phase === 7 && applyAction(game, step.actor, step.action).phase === 8) return { game, step }; game = await act(f, game, step.actor, step.action); } assert.fail('native Collection completion did not become available'); } for (const advanced of [false, true]) { void test(`SQLite ${advanced ? 'Advanced' : 'Basic'} original auction pays once, holds unavailable income through restart and collects at actual native Mentat`, async t => { const f = await fixture(t, { advanced }); const before = f.beforePayment; assert.ok(before.auction?.bidder); const payer = before.auction.bidder; const card = before.auction.cards[before.auction.index]; const payerSpice = player(before, payer).spice; const ownerSpice = player(before, f.owner).spice; const paid = await settlePayment(f); assert.equal(player(paid, payer).spice, payerSpice - 4); assert.equal(player(paid, payer).hand.filter(held => held.id === card.id).length, 1); assert.equal(player(paid, f.owner).spice, ownerSpice); assert.deepEqual(viewGame(paid, f.owner).spiceBankerIncome!.deferred, [{ owner: f.owner, amount: 1 }]); assert.equal(paid.spiceBankerIncome!.sources.filter(source => source.grant).length, 1); await restored(f, paid); await rejected(f, paid, await authenticate(f, f.actor), f.paymentAction, before.version); const final = await finalCollectionStep(f, paid); await restored(f, final.game); const funds = player(final.game, f.owner).spice; const done = await act(f, final.game, final.step.actor, final.step.action); assert.equal(done.phase, 8); assert.equal(player(done, f.owner).spice, funds + 1); assert.deepEqual(viewGame(done, f.owner).spiceBankerIncome!.deferred, []); assert.equal(done.spiceBankerIncome!.collections.length, 1); assert.deepEqual(done.spiceBankerIncome!.collections[0].credits, [{ owner: f.owner, amount: 1 }]); await Promise.all([f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]); assert.deepEqual(await f.restart().readRoom(f.code), done); await rejected(f, done, await authenticate(f, final.step.actor), final.step.action, final.game.version); await restored(f, done); }); } for (const options of [{ amount: 3 }, { ownerIsPayer: true }] satisfies Options[]) { void test(`SQLite original auction ${options.amount === 3 ? 'below four' : 'by Banker owner'} cannot produce normal income`, async t => { const f = await fixture(t, options); const before = f.beforePayment; assert.ok(before.auction?.bidder); const payer = before.auction.bidder; const amount = options.amount ?? 4; const done = await settlePayment(f); assert.equal(player(done, payer).spice, player(before, payer).spice - amount); assert.deepEqual(viewGame(done, f.owner).spiceBankerIncome!.deferred, []); assert.equal(done.spiceBankerIncome!.sources.filter(source => source.grant).length, 0); await restored(f, done); }); } void test('SQLite duplicate original payment race admits one debit, one physical card and one deferred grant', async t => { const f = await fixture(t); const before = f.beforePayment; assert.ok(before.auction?.bidder); const payer = before.auction.bidder; const card = before.auction.cards[before.auction.index]; const auth = await authenticate(f, f.actor); let arrivals = 0; let release!: () => void; const gate = new Promise(resolve => { release = resolve; }); f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; }; let results: PromiseSettledResult[]; try { results = await Promise.allSettled([f.restart(), f.restart()].map(rooms => rooms.act(f.code, auth, before.version, f.paymentAction, clock).finally(release))); } finally { delete f.hooks.beforeWrite; } assert.equal(results.filter(result => result.status === 'fulfilled').length, 1); assert.equal(results.filter(result => result.status === 'rejected').length, 1); const done = await f.restart().readRoom(f.code); assert.equal(done.version, before.version + 1); assert.equal(player(done, payer).spice, player(before, payer).spice - 4); assert.equal(player(done, payer).hand.filter(held => held.id === card.id).length, 1); assert.equal(done.spiceBankerIncome!.sources.filter(source => source.grant).length, 1); await restored(f, done); }); void test('SQLite lost original payment CAS cannot leak a speculative grant or retire the original card', async t => { const f = await fixture(t); const before = f.beforePayment; const competing = structuredClone(before); competing.version++; f.hooks.beforeWrite = async () => { delete f.hooks.beforeWrite; f.save(competing); }; await assert.rejects(f.restart().act(f.code, await authenticate(f, f.actor), before.version, f.paymentAction, clock)); assert.deepEqual(await f.restart().readRoom(f.code), competing); assert.deepEqual(viewGame(competing, f.owner).spiceBankerIncome!.deferred, []); await restored(f, competing); const done = await settlePayment(f, competing); assert.equal(done.spiceBankerIncome!.sources.filter(source => source.grant).length, 1); await restored(f, done); }); void test('SQLite racing final Collection readiness and automatic recovery credit the original owner atomically once', async t => { const f = await fixture(t); const paid = await settlePayment(f); const { game, step } = await finalCollectionStep(f, paid); const funds = player(game, f.owner).spice; const auth = await authenticate(f, step.actor); let arrivals = 0; let release!: () => void; const gate = new Promise(resolve => { release = resolve; }); f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; }; let results: PromiseSettledResult[]; try { results = await Promise.allSettled([ f.restart().act(f.code, auth, game.version, step.action, clock).finally(release), f.restart().act(f.code, auth, game.version, step.action, clock).finally(release), f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock), ]); } finally { delete f.hooks.beforeWrite; } assert.equal(results.slice(0, 2).filter(result => result.status === 'fulfilled').length, 1); const done = await f.restart().readRoom(f.code); assert.equal(done.phase, 8); assert.equal(done.version, game.version + 1); assert.equal(player(done, f.owner).spice, funds + 1); assert.equal(done.spiceBankerIncome!.collections.length, 1); assert.deepEqual(viewGame(done, f.owner).spiceBankerIncome!.deferred, []); await Promise.all([f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]); assert.deepEqual(await f.restart().readRoom(f.code), done); await restored(f, done); }); void test('SQLite foreign credentials, wrong native actor, stale version and forged automatic-income actions cannot mutate payment state', async t => { const f = await fixture(t); const before = records(f.sqlite); const foreign = await f.restart().authenticate(f.unrelatedCode, f.unrelatedToken); await assert.rejects(f.restart().authenticate(f.code, f.unrelatedToken)); await assert.rejects(f.restart().readSeatView(f.code, foreign)); await assert.rejects(f.restart().readSeatView(f.code, { playerId: f.actor, tokenHash: foreign.tokenHash })); assert.deepEqual(records(f.sqlite), before); for (const auth of [foreign, { playerId: f.actor, tokenHash: foreign.tokenHash }, await authenticate(f, f.auths.find(auth => auth.playerId !== f.actor)!.playerId)]) await rejected(f, f.beforePayment, auth, f.paymentAction); const auth = await authenticate(f, f.actor); await rejected(f, f.beforePayment, auth, f.paymentAction, f.beforePayment.version - 1); for (const action of [{ type: 'spiceBankerIncome', amount: 1 }, { type: 'collectSpiceBankerIncome' }, { ...f.paymentAction, actor: f.owner }, { ...f.paymentAction, bankerIncome: 1 }, { ...f.paymentAction, paymentSource: 'bank' }]) await rejected(f, f.beforePayment, auth, action); f.sqlite.prepare('UPDATE seats SET revoked=1 WHERE room_code=? AND player_id=?').run(f.code, f.actor); f.protectedRows = records(f.sqlite, f.code); await assert.rejects(f.restart().readSeatView(f.code, auth)); await rejected(f, f.beforePayment, auth, f.paymentAction); }); void test('SQLite corrupt earned source, recipient, amount, stamps and collection history fail closed without read or recovery repair', async t => { const f = await fixture(t); const paid = await settlePayment(f); const changes: ((game: Game) => void)[] = [ game => { game.spiceBankerIncome!.sources[0].source.bankLegs[0].amount++; }, game => { game.spiceBankerIncome!.sources[0].source.event += ':forged'; }, game => { game.spiceBankerIncome!.sources[0].grant!.owner = f.payer; }, game => { game.spiceBankerIncome!.sources[0].grant!.stamp += ':reset'; }, game => { game.spiceBankerIncome!.sources.push(structuredClone(game.spiceBankerIncome!.sources[0])); }, game => { game.spiceBankerIncome!.head += ':forged'; }, game => { game.spiceBankerIncome!.signature += ':forged'; }, ]; const final = await finalCollectionStep(f, paid); const collected = await act(f, final.game, final.step.actor, final.step.action); const corruptCollection = structuredClone(collected); corruptCollection.spiceBankerIncome!.collections[0].credits[0].amount++; for (const corrupt of [...changes.map(change => { const game = structuredClone(paid); change(game); return game; }), corruptCollection]) { f.save(corrupt); const before = records(f.sqlite); const snapshot = structuredClone(corrupt); for (const auth of f.auths) await assert.rejects(f.restart().readSeatView(f.code, await authenticate(f, auth.playerId))); await rejected(f, corrupt, await authenticate(f, f.actor), { type: 'ready' }); await Promise.allSettled([f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]); assert.deepEqual(records(f.sqlite), before); assert.deepEqual(corrupt, snapshot); } }); void test('SQLite front-shield income cannot fund a native bid before Mentat collection', async t => { const f = await fixture(t); let game = await settlePayment(f); for (let steps = 0; game.phase === 3 && game.auction?.active !== f.owner && steps < 30; steps++) { const step = nextSpiceBankerIncomeNativeStep(game); assert.ok(step); game = await act(f, game, step.actor, step.action); } assert.equal(game.phase, 3); assert.equal(game.auction?.active, f.owner); assert.deepEqual(viewGame(game, f.owner).spiceBankerIncome!.deferred, [{ owner: f.owner, amount: 1 }]); assert.equal(player(game, f.owner).hand.some(card => card.effect === 'karama'), false, 'this is an ordinary funded bid, not an authorized free Karama overbid'); await rejected(f, game, await authenticate(f, f.owner), { type: 'bid', amount: player(game, f.owner).spice + 1 }); await restored(f, game); }); for (const guild of [false, true]) { void test(`SQLite original shipment to ${guild ? 'native Guild' : 'Bank'} preserves its actual recipient and credits only the Bank case`, async t => { const f = await fixture(t, { kind: 'shipment', guild }); const before = f.beforePayment; const payer = player(before, f.payer); const shipped = Number(f.paymentAction.amount ?? before.pendingShipment?.amount); assert.ok(Number.isSafeInteger(shipped) && shipped > 0); const recipient = before.players.find(seat => seat.faction === 'guild'); const done = await settlePayment(f); assert.equal(player(done, f.payer).spice, payer.spice - f.amount); assert.equal(player(done, f.payer).reserves, payer.reserves - shipped); assert.equal(player(done, f.payer).shipped, true); if (f.actor !== f.payer) assert.equal(player(done, f.actor).reserves, player(before, f.actor).reserves, 'the native executor does not ship the economic payer’s reserves'); if (recipient) assert.equal(player(done, recipient.id).spice, recipient.spice + f.amount); assert.deepEqual(viewGame(done, f.owner).spiceBankerIncome!.deferred, guild ? [] : [{ owner: f.owner, amount: 1 }]); await restored(f, done); }); } void test('SQLite genuine free force revival has zero bank payment and never manufactures income', async t => { const f = await fixture(t, { kind: 'force-revival' }); const before = f.beforePayment; const funds = player(before, f.actor).spice; const tanks = player(before, f.actor).tanks; const reserves = player(before, f.actor).reserves; const done = await act(f, before, f.actor, { type: 'revive', amount: 1 }); assert.equal(player(done, f.actor).spice, funds); assert.equal(player(done, f.actor).tanks, tanks - 1); assert.equal(player(done, f.actor).reserves, reserves + 1); assert.deepEqual(viewGame(done, f.owner).spiceBankerIncome!.deferred, []); assert.equal(done.spiceBankerIncome!.sources.filter(source => source.grant).length, 0); await restored(f, done); }); void test('SQLite pending native Guild income keeps its funded own and donor invoice private and rejects invoice corruption without repair', async t => { const f = await fixture(t, { kind: 'shipment', guild: true, amount: 5, allySplit: 1, stageIncomeCounter: true }); const before = f.beforePayment; assert.ok(player(before, f.owner).hand.some(card => card.effect === 'karama'), 'an actual held counter keeps the original Guild income window eligible'); const donor = player(before, f.payer).ally; const guild = before.players.find(seat => seat.faction === 'guild'); assert.ok(donor && guild); let pending = await act(f, before, f.actor, f.paymentAction); for (let steps = 0; (pending.decision?.kind === 'guildShipment' || pending.response?.kind === 'guildRate') && steps < 30; steps++) { assert.equal(pending.phase, before.phase, 'only the original shipment’s native windows may continue'); const step = nextSpiceBankerIncomeNativeStep(pending); assert.ok(step); pending = await act(f, pending, step.actor, step.action); } assert.equal(pending.response?.kind, 'guildIncome'); const invoice = pending.response!.spiceBankerIncomePayment; assert.ok(invoice); assert.deepEqual(invoice.legs, [ { payer: f.payer, amount: 4, recipient: 'player' }, { payer: donor, amount: 1, recipient: 'player' }, ]); assert.equal(player(pending, f.payer).spice, player(before, f.payer).spice - 4); assert.equal(player(pending, donor).spice, player(before, donor).spice, 'native donor escrow was paid before declaration'); assert.equal(player(pending, f.payer).reserves, player(before, f.payer).reserves - 5); assert.equal(player(pending, guild.id).spice, guild.spice, 'original recipient is still awaiting its response'); assert.deepEqual(viewGame(pending, f.owner).spiceBankerIncome!.deferred, []); await restored(f, pending); assert.throws(() => viewGame(pending, 'spectator')); const privateValues: readonly string[] = [invoice.event, invoice.signature, JSON.stringify(invoice.legs)]; for (const seat of pending.players) { const publicView = viewGame(pending, seat.id); assert.equal(publicView.response!.spiceBankerIncomePayment, undefined); assert.equal(JSON.stringify(publicView).includes('"spiceBankerIncomePayment":'), false); for (const privateValue of privateValues) assert.equal(JSON.stringify(publicView).includes(privateValue), false); } const changes: ((game: Game) => void)[] = [ game => { delete game.response!.spiceBankerIncomePayment; }, game => { game.response!.spiceBankerIncomePayment!.event += ':forged'; }, game => { game.response!.spiceBankerIncomePayment!.signature += ':forged'; }, game => { game.response!.spiceBankerIncomePayment!.legs[0].amount++; }, game => { game.response!.spiceBankerIncomePayment!.legs[1].payer = f.payer; }, game => { game.response!.spiceBankerIncomePayment!.legs[0].recipient = 'bank'; }, game => { game.response!.spiceBankerIncomePayment!.kind = 'auction'; }, ]; const continuation = nextSpiceBankerIncomeNativeStep(pending); assert.ok(continuation); for (const change of changes) { const corrupt = structuredClone(pending); change(corrupt); f.save(corrupt); const rows = records(f.sqlite); for (const auth of f.auths) await assert.rejects(f.restart().readSeatView(f.code, await authenticate(f, auth.playerId))); await rejected(f, corrupt, await authenticate(f, continuation.actor), continuation.action); await Promise.allSettled([f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]); assert.deepEqual(records(f.sqlite), rows, 'corrupt pending invoices cannot be repaired by refresh or automatic recovery'); } f.save(pending); let paid = pending; for (let steps = 0; paid.response && steps < 30; steps++) { const step = nextSpiceBankerIncomeNativeStep(paid); assert.ok(step); paid = await act(f, paid, step.actor, step.action); } assert.equal(paid.response, null); assert.equal(player(paid, guild.id).spice, guild.spice + 5); assert.equal(player(paid, f.payer).spice, player(pending, f.payer).spice); assert.deepEqual(player(paid, f.payer).forces, player(pending, f.payer).forces); assert.deepEqual(viewGame(paid, f.owner).spiceBankerIncome!.deferred, []); await restored(f, paid); }); void test('SQLite competing original shipment declarations preserve only the winning tariff and delivered group', async t => { const f = await fixture(t, { kind: 'shipment' }); const before = f.beforePayment; const auth = await authenticate(f, f.actor); const actions = [f.paymentAction, { ...f.paymentAction, amount: 5 }]; let arrivals = 0; let release!: () => void; const gate = new Promise(resolve => { release = resolve; }); f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; }; let results: PromiseSettledResult[]; try { results = await Promise.allSettled(actions.map(action => f.restart().act(f.code, auth, before.version, action, clock).finally(release))); } finally { delete f.hooks.beforeWrite; } assert.equal(results.filter(result => result.status === 'fulfilled').length, 1); const winner = results.findIndex(result => result.status === 'fulfilled'); const amount = winner === 0 ? 4 : 5; const done = await f.restart().readRoom(f.code); assert.equal(player(done, f.payer).spice, player(before, f.payer).spice - amount); assert.equal(player(done, f.payer).reserves, player(before, f.payer).reserves - amount); assert.equal(done.spiceBankerIncome!.sources.filter(source => source.grant).length, 1); assert.deepEqual(viewGame(done, f.owner).spiceBankerIncome!.deferred, [{ owner: f.owner, amount: 1 }]); await restored(f, done); }); for (const fate of ['death', 'capture'] as const) { void test(`SQLite original earned currency survives actual native trainer ${fate} and collects for the original faction under the local policy`, async t => { const f = await fixture(t, { advanced: true }); const paid = await settlePayment(f); const earned = structuredClone(paid.spiceBankerIncome); // The fixture stages conserved battle pieces, then executes the genuine // visibility, plans, survival and capture choices; it never edits income. const changed = stageSpiceBankerIncomeTrainerBattle(paid, { [fate]: true }); const trainer = player(changed, f.owner).leaders.find(leader => leader.id === f.leader); assert.ok(trainer); if (fate === 'death') assert.equal(trainer.dead, true); else assert.equal(trainer.capturedBy, changed.players.find(seat => seat.faction === 'harkonnen')!.id); assert.deepEqual(changed.spiceBankerIncome, earned); assert.deepEqual(viewGame(changed, f.owner).spiceBankerIncome!.deferred, [{ owner: f.owner, amount: 1 }]); f.save(changed); await restored(f, changed); const { game, step } = await finalCollectionStep(f, changed); const funds = game.players.map(seat => [seat.id, seat.spice] as const); const done = await act(f, game, step.actor, step.action); for (const [id, spice] of funds) assert.equal(player(done, id).spice, spice + Number(id === f.owner)); assert.deepEqual(done.spiceBankerIncome!.collections[0].credits, [{ owner: f.owner, amount: 1 }]); await Promise.all([f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]); assert.deepEqual(await f.restart().readRoom(f.code), done); await restored(f, done); }); } void test('SQLite two distinct native auction purchases retain both original costs but award only once in their phase', async t => { const f = await fixture(t); const firstPurchase = f.beforePayment.auction!.cards[f.beforePayment.auction!.index].id; const firstBonus = f.beforePayment.deck[0].id; const firstHand = player(f.beforePayment, f.payer).hand.map(card => card.id); assert.equal(player(f.beforePayment, f.payer).faction, 'harkonnen'); const first = await settlePayment(f); assert.equal(player(first, f.payer).spice, player(f.beforePayment, f.payer).spice - 4); assert.notEqual(firstPurchase, firstBonus); assert.deepEqual(player(first, f.payer).hand.map(card => card.id).sort(), [...firstHand, firstPurchase, firstBonus].sort()); assert.deepEqual(first.deck.map(card => card.id), f.beforePayment.deck.slice(1).map(card => card.id)); let game = first; for (let steps = 0; game.auction?.active !== f.payer && steps < 30; steps++) { const step = nextSpiceBankerIncomeNativeStep(game); assert.ok(step); game = await act(f, game, step.actor, step.action); } assert.equal(game.phase, 3); assert.equal(game.auction?.active, f.payer); const funds = player(game, f.payer).spice; const hand = player(game, f.payer).hand.map(card => card.id); const acquired = game.auction!.cards[game.auction!.index].id; const bonus = game.deck[0].id; const deck = game.deck.map(card => card.id); assert.equal(hand.includes(acquired), false); assert.equal(hand.includes(bonus), false); assert.notEqual(acquired, firstPurchase, 'the second purchase is a distinct original auction lot'); assert.notEqual(acquired, bonus); game = await act(f, game, f.payer, { type: 'bid', amount: 4 }); for (let steps = 0; (!player(game, f.payer).hand.some(card => card.id === acquired) || game.response?.kind === 'harkonnenBonus') && steps < 30; steps++) { if (game.decision?.kind === 'auctionPayment') game = await act(f, game, f.payer, { type: 'decision', karama: false }); else { const step = nextSpiceBankerIncomeNativeStep(game); assert.ok(step); game = await act(f, game, step.actor, step.action); } } assert.equal(player(game, f.payer).spice, funds - 4); assert.deepEqual(player(game, f.payer).hand.map(card => card.id).sort(), [...hand, acquired, bonus].sort()); assert.deepEqual(game.deck.map(card => card.id), deck.slice(1), 'the actual native bonus leaves its sole deck custodian once'); assert.equal(game.spiceBankerIncome!.sources.length, first.spiceBankerIncome!.sources.length + 1); assert.equal(game.spiceBankerIncome!.sources.filter(source => source.grant).length, 1); assert.notEqual(game.spiceBankerIncome!.sources[0].source.event, game.spiceBankerIncome!.sources[1].source.event); assert.deepEqual(game.spiceBankerIncome!.sources.map(receipt => receipt.source.bankLegs), [[{ payer: f.payer, amount: 4 }], [{ payer: f.payer, amount: 4 }]]); assert.deepEqual(viewGame(game, f.owner).spiceBankerIncome!.deferred, [{ owner: f.owner, amount: 1 }]); await restored(f, game); }); void test('SQLite one funded auction split between actual payers never pools three and one into a qualifying payment', async t => { const f = await fixture(t, { amount: 4, allySplit: 1 }); const before = f.beforePayment; const donor = player(before, f.payer).ally; assert.ok(donor); const done = await settlePayment(f); assert.equal(player(done, f.payer).spice, player(before, f.payer).spice - 3); assert.equal(player(done, donor).spice, player(before, donor).spice, 'already committed donor escrow is not charged twice'); const source = done.spiceBankerIncome!.sources.at(-1); assert.ok(source); assert.deepEqual(source.source.bankLegs, [{ payer: f.payer, amount: 3 }, { payer: donor, amount: 1 }]); assert.equal(source.grant, null); assert.deepEqual(viewGame(done, f.owner).spiceBankerIncome!.deferred, []); await restored(f, done); }); void test('SQLite the actual returned Banker card can be legally revived and assigned to a different faction without transferring original earned currency', async t => { const f = await fixture(t, { advanced: true }); const paid = await settlePayment(f); const originalSource = structuredClone(paid.spiceBankerIncome!.sources[0]); // Parent-approved entropy is scoped to the genuine native mutual-death return // shuffle. The helper then follows real Mentat and next-turn Revival; it never // reorders a started deck, fabricates an offer or edits an income receipt. const lifecycle = createSpiceBankerIncomeReassignmentFixture(paid); let game = lifecycle.beforeRevival; assert.notEqual(lifecycle.actor, lifecycle.originalOwner); assert.equal(game.turn, paid.turn + 1); assert.equal(game.phase, 4); assert.deepEqual(game.spiceBankerIncome!.sources[0], originalSource); assert.deepEqual(game.spiceBankerIncome!.collections[0].credits, [{ owner: lifecycle.originalOwner, amount: 1 }]); assert.deepEqual(viewGame(game, lifecycle.originalOwner).spiceBankerIncome!.deferred, []); assert.equal(game.leaderSkills!.assignments.some(assignment => assignment.skill === 'spice-banker'), false); f.save(game); await restored(f, game); const funds = player(game, lifecycle.actor).spice; const originalFunds = player(game, lifecycle.originalOwner).spice; const trainer = player(game, lifecycle.actor).leaders.find(leader => leader.id === lifecycle.leader); assert.ok(trainer?.dead); const price = trainer.strength; game = await act(f, game, lifecycle.actor, lifecycle.revivalAction); for (let steps = 0; game.response && steps < 30; steps++) { const step = nextSpiceBankerIncomeNativeStep(game); assert.ok(step); game = await act(f, game, step.actor, step.action); } assert.equal(game.decision?.kind, 'leaderSkillRevival'); assert.equal(player(game, lifecycle.actor).spice, funds - price); assert.equal(player(game, lifecycle.originalOwner).spice, originalFunds); assert.equal(player(game, lifecycle.actor).leaders.find(leader => leader.id === lifecycle.leader)!.dead, false); assert.equal(game.spiceBankerIncome!.sources.at(-1)!.grant, null, 'future skill assignment cannot award for the earlier revival payment'); await restored(f, game); const event = game.decision!.event; game = await act(f, game, lifecycle.actor, { type: 'leaderSkill', event, mode: 'draw' }); assert.ok(game.leaderSkills!.offers[lifecycle.actor].cards.includes('spice-banker')); await restored(f, game); const beforeSelection = game; const select: Action = { type: 'leaderSkill', event, skill: 'spice-banker', leader: lifecycle.leader }; game = await act(f, game, lifecycle.actor, select); assert.deepEqual(game.leaderSkills!.assignments.find(assignment => assignment.skill === 'spice-banker'), { owner: lifecycle.actor, leader: lifecycle.leader, skill: 'spice-banker' }); const skills = [...game.leaderSkills!.deck, ...Object.values(game.leaderSkills!.offers).flatMap(offer => offer.cards), ...game.leaderSkills!.assignments.map(assignment => assignment.skill)]; assert.deepEqual(skills.sort(), createLeaderSkills(() => 0.5).deck.sort(), 'all fourteen physical skills remain in one custodian'); assert.deepEqual(game.spiceBankerIncome!.sources[0], originalSource); assert.deepEqual(game.spiceBankerIncome!.collections[0].credits, [{ owner: lifecycle.originalOwner, amount: 1 }]); assert.equal(player(game, lifecycle.actor).spice, funds - price); assert.equal(player(game, lifecycle.originalOwner).spice, originalFunds); assert.deepEqual(viewGame(game, lifecycle.actor).spiceBankerIncome!.deferred, []); await rejected(f, game, await authenticate(f, lifecycle.actor), select, beforeSelection.version); await rejected(f, game, await authenticate(f, lifecycle.actor), select); await Promise.all([f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]); assert.deepEqual(await f.restart().readRoom(f.code), game); await restored(f, game); }); for (const kind of ['force-revival', 'leader-revival', 'kh-revival', 'emperor-extra-revival'] as const) { void test(`SQLite actual paid ${kind} preserves native payer, revived beneficiary and allowance while retaining only qualifying bank income`, async t => { const f = await fixture(t, { kind, advanced: true, ...(kind === 'force-revival' ? { payerFaction: 'emperor', emperor: true } : {}), ...(kind === 'kh-revival' ? { bankerFaction: 'harkonnen', payerFaction: 'atreides' } : {}) }); const before = f.beforePayment; const payer = player(before, f.payer); const beneficiary = kind === 'emperor-extra-revival' ? player(before, payer.ally!) : payer; const trainer = player(before, f.owner).leaders.find(leader => leader.id === f.leader); assert.ok(trainer && !trainer.dead && !trainer.capturedBy); assert.notEqual(f.owner, payer.id, 'these are another real payer’s bank payment'); const paid = await settlePayment(f); const revived = player(paid, beneficiary.id); assert.equal(player(paid, payer.id).spice, payer.spice - f.amount); assert.equal(player(paid, f.owner).spice, player(before, f.owner).spice); if (kind === 'force-revival') { assert.equal(payer.faction, 'emperor', 'native free-one revival leaves two paid forces'); assert.equal(f.amount, 4); assert.equal(revived.reserves, beneficiary.reserves + 3); assert.equal(revived.tanks, beneficiary.tanks - 3); assert.equal(revived.revived, beneficiary.revived + 3); assert.equal(revived.freeForcesRevived, (beneficiary.freeForcesRevived ?? 0) + 1); } else if (kind === 'leader-revival') { const target = beneficiary.leaders.find(leader => leader.id === f.paymentAction.leader); assert.ok(target?.dead); assert.equal(revived.leaders.find(leader => leader.id === target.id)!.dead, false); assert.equal(revived.leaderRevived, true); assert.equal(paid.decision?.kind, 'leaderSkillRevival'); } else if (kind === 'kh-revival') { assert.equal(f.amount, 2, 'KH is its actual native two-spice price, never a fabricated four'); assert.equal(beneficiary.kwisatz?.dead, true); assert.equal(revived.kwisatz?.dead, false); assert.equal(revived.kwisatz?.revivalCycle, Math.max(revived.revivalCycle, beneficiary.kwisatz!.revivalCycle ?? 1) + 1); assert.equal(revived.leaderRevived, true); } else { assert.notEqual(payer.id, beneficiary.id); assert.equal(payer.faction, 'emperor'); assert.equal(revived.spice, beneficiary.spice, 'the revived ally does not pay the Emperor’s debit'); assert.equal(revived.reserves, beneficiary.reserves + 2); assert.equal(revived.tanks, beneficiary.tanks - 2); assert.equal(revived.revived, beneficiary.revived, 'extra revivals do not consume the normal allowance'); assert.equal(revived.freeForcesRevived, beneficiary.freeForcesRevived); assert.equal(paid.emperorExtra[beneficiary.id], (before.emperorExtra[beneficiary.id] ?? 0) + 2); } const source = paid.spiceBankerIncome!.sources.at(-1); assert.ok(source); assert.equal(paid.spiceBankerIncome!.sources.length, before.spiceBankerIncome!.sources.length + 1); assert.equal(source.source.kind, kind); assert.equal(source.source.turn, before.turn); assert.equal(source.source.phase, 4); assert.deepEqual(source.source.bankLegs, [{ payer: payer.id, amount: f.amount }]); assert.equal(source.source.trainer?.resolved, true, 'the source authority denotes committed native payment'); assert.deepEqual(viewGame(paid, f.owner).spiceBankerIncome!.deferred, kind === 'kh-revival' ? [] : [{ owner: f.owner, amount: 1 }]); await rejected(f, paid, await authenticate(f, f.actor), f.paymentAction, before.version); await restored(f, paid); }); } for (const options of [ { trainerSelected: true, amount: 4, expected: 1 }, { trainerSelected: true, trainerDies: true, amount: 4, expected: 0 }, { trainerHidden: true, amount: 4, expected: 0 }, { amount: 3, expected: 0 }, ]) { void test(`SQLite resolved native support preserves payment and obeys trainer survival, concealment and threshold ${JSON.stringify(options)}`, async t => { const { expected, ...native } = options; const f = await fixture(t, { kind: 'battle-support', advanced: true, ...native }); const before = f.beforePayment; assert.ok(before.battle?.revealed); assert.equal(before.battle.plans[f.payer].support, options.amount); assert.deepEqual(viewGame(before, f.owner).spiceBankerIncome!.deferred, []); const paid = await settlePayment(f); const trainer = player(paid, f.owner).leaders.find(leader => leader.id === f.leader); assert.ok(trainer); assert.equal(trainer.dead, 'trainerDies' in options && options.trainerDies); assert.equal(paid.lastBattleContext?.event, before.battle.event); assert.equal(paid.lastBattleContext?.result, 'normal'); const economics = quoteSpiceBankerIncomeBattleEconomics(before, paid); const support = economics.resolution.payments.find(payment => payment.player === f.payer); assert.ok(support); assert.equal(support.ownPayment, options.amount, 'the original native support debit is not netted against income'); const bounty = economics.resolution.bounty; assert.equal(bounty?.amount ?? 0, trainer.dead ? trainer.strength : 0); if (bounty) assert.equal(bounty.player, f.payer, 'the winner earns the real killed-leader bounty'); for (const id of [f.payer, f.owner]) { const debit = economics.resolution.payments.find(payment => payment.player === id)!.ownPayment; const collection = economics.nativeCollection.find(receipt => receipt.player === id); const nativeIncome = (bounty?.player === id ? bounty.amount : 0) + (collection ? collection.strongholds + collection.collected : 0); assert.equal(player(paid, id).spice, player(before, id).spice - debit + nativeIncome, 'original support, native bounty and actual Collection remain separate from deferred Banker income'); } const source = paid.spiceBankerIncome!.sources.at(-1); assert.ok(source); assert.equal(source.source.kind, 'battle-support'); assert.deepEqual(source.source.bankLegs, [{ payer: f.payer, amount: options.amount }]); if (trainer.dead) assert.equal(source.source.trainer, null); else { assert.equal(source.source.trainer?.resolved, true); assert.equal(source.source.trainer?.selected, 'trainerSelected' in options && options.trainerSelected); assert.equal(source.source.trainer?.faceUp, !('trainerSelected' in options || 'trainerHidden' in options)); } assert.equal(source.grant?.amount ?? 0, expected); assert.deepEqual(viewGame(paid, f.owner).spiceBankerIncome!.deferred, expected ? [{ owner: f.owner, amount: 1 }] : []); await rejected(f, paid, await authenticate(f, f.actor), f.paymentAction, before.version); await restored(f, paid); }); } void test('SQLite Emperor self-purchase actually pays the Bank, retains its original card and auction counter, and collects deferred income at native Mentat once', async t => { const f = await fixture(t, { emperor: true, payerFaction: 'emperor' }); const before = f.beforePayment; const emperor = player(before, f.payer); assert.equal(emperor.faction, 'emperor'); assert.notEqual(emperor.id, f.owner); assert.ok(before.auction); const card = before.auction.cards[before.auction.index]; const paid = await settlePayment(f); assert.equal(player(paid, emperor.id).spice, emperor.spice - 4, 'self purchase is a real bank debit, not self-recipient income'); assert.equal(player(paid, emperor.id).hand.filter(held => held.id === card.id).length, 1); assert.equal(paid.auction?.index, before.auction.index + 1); assert.equal(player(paid, f.owner).spice, player(before, f.owner).spice); const receipt = paid.spiceBankerIncome!.sources.at(-1); assert.ok(receipt); assert.deepEqual(receipt.source.bankLegs, [{ payer: emperor.id, amount: 4 }]); assert.deepEqual(viewGame(paid, f.owner).spiceBankerIncome!.deferred, [{ owner: f.owner, amount: 1 }]); await restored(f, paid); const final = await finalCollectionStep(f, paid); const funds = player(final.game, f.owner).spice; const collected = await act(f, final.game, final.step.actor, final.step.action); assert.equal(collected.phase, 8); assert.equal(player(collected, f.owner).spice, funds + 1); assert.deepEqual(collected.spiceBankerIncome!.collections.at(-1)!.credits, [{ owner: f.owner, amount: 1 }]); assert.deepEqual(viewGame(collected, f.owner).spiceBankerIncome!.deferred, []); await Promise.all([f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]); assert.deepEqual(await f.restart().readRoom(f.code), collected); await rejected(f, collected, await authenticate(f, final.step.actor), final.step.action, final.game.version); await restored(f, collected); });