Files
Dune/tests/spice-banker-income-recovery.test.ts

795 lines
45 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, SQLOutputValue>;
interface Fixture extends SpiceBankerIncomeFixture {
sqlite: DatabaseSync;
restart(): typeof Rooms;
hooks: { beforeWrite?: () => Promise<void> };
tokens: string[];
auths: SeatAuth[];
code: string;
unrelatedCode: string;
unrelatedToken: string;
protectedRows: Record<string, SqlRow[]>;
stock: string[];
save(game: Game): void;
}
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
function records(sqlite: DatabaseSync, excludedRoom?: string) {
const result: Record<string, SqlRow[]> = {};
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<Fixture> {
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<void>(resolve => { release = resolve; });
f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; };
let results: PromiseSettledResult<unknown>[];
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<void>(resolve => { release = resolve; });
f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; };
let results: PromiseSettledResult<unknown>[];
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<void>(resolve => { release = resolve; });
f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; };
let results: PromiseSettledResult<unknown>[];
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);
});