Prototype one through three own spice as a separate sealed battle commitment. Reserve funds alongside actual force support, add strength only for the surviving skilled leader, and settle once through existing versioned writes. Apply the documented card/core-rule interpretation: payment survives leader death or loss, except for the sole successful traitor caller. Keep printed disc strength and bounty unchanged. Normal income awaits its deferred-custody ruling; combined modules remain gated. Validation: 54 focused tests; typecheck, lint, 4490 offline tests, production build and 43 HTTP tests passed. Independent rules, privacy, persistence and UI review completed. SQLite tests cover reserved-spice diversion, concurrent settlement, traitor waiver and legacy plans. Browser exercised a three-spice commitment, pending refresh, actual +3 strength and payment, and restored private cards and the next decision. Two genuine four-profile Basic/Advanced samples completed without rejected candidates but did not use Banker spending. Following another reported outage, the server remained healthy. Preserved all 461 opening rooms and original seat/recovery rows (495 rooms after QA/HTTP). SQLite integrity and foreign keys passed. A fresh online backup captured all 495 rooms, and browser refresh restored the QA seat. No restart or reset was needed. Verified source tree: 208bf9324ab994b5e59ed50d78998b4dbf8810b3b6799b1a577ef3b84479335f
283 lines
8.4 KiB
TypeScript
283 lines
8.4 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import type { DatabaseSync } from 'node:sqlite';
|
|
import type { Action } from '../game/engine';
|
|
import type { RoomsClock } from '../db/rooms';
|
|
import { unitStore } from './fixture-nexus-room-store';
|
|
import { spiceBankerGame } from './spice-banker-fixture';
|
|
|
|
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
|
|
|
|
function giveTraitor(
|
|
game: ReturnType<typeof spiceBankerGame>,
|
|
owner: string,
|
|
identity: string,
|
|
) {
|
|
const recipient = game.players.find((player) => player.id === owner)!;
|
|
if (recipient.traitors.includes(identity)) return;
|
|
const replaced = recipient.traitors[0];
|
|
for (const player of game.players) {
|
|
const index = player.traitors.indexOf(identity);
|
|
if (index < 0) continue;
|
|
player.traitors[index] = replaced;
|
|
recipient.traitors[0] = identity;
|
|
return;
|
|
}
|
|
const index = game.traitorReserve!.indexOf(identity);
|
|
assert.ok(index >= 0);
|
|
game.traitorReserve![index] = replaced;
|
|
recipient.traitors[0] = identity;
|
|
}
|
|
|
|
async function fixture(
|
|
options: { advanced?: boolean; traitor?: boolean; spice?: number } = {},
|
|
) {
|
|
const store = unitStore();
|
|
const sqlite: DatabaseSync = store.sqlite;
|
|
const created = await store.rooms.createRoom(
|
|
'Banker',
|
|
'emperor',
|
|
options.advanced ?? false,
|
|
[],
|
|
);
|
|
const joined = await store.rooms.joinRoom(
|
|
created.view.code,
|
|
'Guild',
|
|
'guild',
|
|
);
|
|
const code = created.view.code;
|
|
const tokens = [created.token!, joined.token!];
|
|
const original = await Promise.all(
|
|
tokens.map((token) => store.rooms.authenticate(code, token)),
|
|
);
|
|
for (const [index, seat] of original.entries())
|
|
sqlite
|
|
.prepare('UPDATE seats SET player_id=? WHERE room_code=? AND player_id=?')
|
|
.run(index === 0 ? 'a' : 'd', code, seat.playerId);
|
|
const seats = await Promise.all(
|
|
tokens.map((token) => store.restart().authenticate(code, token)),
|
|
);
|
|
const state = spiceBankerGame(options.advanced ?? false);
|
|
state.code = code;
|
|
state.version = (await store.rooms.readRoom(code)).version;
|
|
state.players[0].spice = options.spice ?? 20;
|
|
if (options.traitor) giveTraitor(state, 'a', 'guild-1');
|
|
sqlite
|
|
.prepare('UPDATE rooms SET state=?,version=? WHERE code=?')
|
|
.run(JSON.stringify(state), state.version, code);
|
|
store.writes.length = 0;
|
|
return { ...store, sqlite, code, seats, state };
|
|
}
|
|
|
|
const bankerPlan = (amount?: number): Action => ({
|
|
type: 'battlePlan',
|
|
dial: 0,
|
|
support: 0,
|
|
leader: 'emperor-0',
|
|
...(amount === undefined ? {} : { bankerSpice: amount }),
|
|
});
|
|
|
|
const guildPlan: Action = {
|
|
type: 'battlePlan',
|
|
dial: 0,
|
|
support: 0,
|
|
leader: 'guild-1',
|
|
};
|
|
|
|
void test('SQLite restart keeps the Banker commitment owner-private and reserves it from other spending', async () => {
|
|
const f = await fixture({ spice: 3 });
|
|
try {
|
|
await f.rooms.act(
|
|
f.code,
|
|
f.seats[0],
|
|
f.state.version,
|
|
bankerPlan(3),
|
|
clock,
|
|
);
|
|
const sealed = await f.restart().readRoom(f.code);
|
|
assert.equal(sealed.battle?.plans.a.bankerSpice, 3);
|
|
assert.equal(sealed.players[0].spice, 3);
|
|
|
|
const owner = await f.restart().readSeatView(f.code, f.seats[0]);
|
|
const opponent = await f.restart().readSeatView(f.code, f.seats[1]);
|
|
assert.equal(owner.battle?.plans.a.bankerSpice, 3);
|
|
assert.deepEqual(owner.battle?.submitted, ['a']);
|
|
assert.deepEqual(opponent.battle?.plans, {});
|
|
assert.deepEqual(opponent.battle?.submitted, ['a']);
|
|
assert.equal('bankerSpice' in (opponent.battle?.plans.a ?? {}), false);
|
|
|
|
await assert.rejects(() =>
|
|
f
|
|
.restart()
|
|
.act(
|
|
f.code,
|
|
f.seats[0],
|
|
sealed.version,
|
|
{ type: 'bribe', target: 'd', amount: 1 },
|
|
clock,
|
|
),
|
|
);
|
|
assert.deepEqual(await f.restart().readRoom(f.code), sealed);
|
|
|
|
await f.restart().act(f.code, f.seats[1], sealed.version, guildPlan, clock);
|
|
let revealed = await f.restart().readRoom(f.code);
|
|
await f
|
|
.restart()
|
|
.act(
|
|
f.code,
|
|
f.seats[0],
|
|
revealed.version,
|
|
{ type: 'traitorCall', call: false },
|
|
clock,
|
|
);
|
|
revealed = await f.restart().readRoom(f.code);
|
|
await f
|
|
.restart()
|
|
.act(
|
|
f.code,
|
|
f.seats[1],
|
|
revealed.version,
|
|
{ type: 'traitorCall', call: false },
|
|
clock,
|
|
);
|
|
const done = await f.restart().readRoom(f.code);
|
|
assert.equal(done.players[0].spice, 0);
|
|
assert.equal(
|
|
done.log.filter(
|
|
(entry) => entry.automatic?.name === 'Spice Banker payment',
|
|
).length,
|
|
1,
|
|
);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('concurrent final Traitor decisions waive one restored Banker payment exactly once', async () => {
|
|
const f = await fixture({ traitor: true });
|
|
try {
|
|
const bounty = f.state.players
|
|
.flatMap((player) => player.leaders)
|
|
.find((leader) => leader.id === 'guild-1')!.strength;
|
|
await f.rooms.act(
|
|
f.code,
|
|
f.seats[0],
|
|
f.state.version,
|
|
bankerPlan(3),
|
|
clock,
|
|
);
|
|
let state = await f.restart().readRoom(f.code);
|
|
await f.restart().act(f.code, f.seats[1], state.version, guildPlan, clock);
|
|
state = await f.restart().readRoom(f.code);
|
|
await f
|
|
.restart()
|
|
.act(
|
|
f.code,
|
|
f.seats[0],
|
|
state.version,
|
|
{ type: 'traitorCall', call: true },
|
|
clock,
|
|
);
|
|
const pending = await f.restart().readRoom(f.code);
|
|
assert.equal(pending.battle?.plans.a.bankerSpice, 3);
|
|
assert.equal(pending.players[0].spice, 20);
|
|
|
|
let arrivals = 0;
|
|
let release!: () => void;
|
|
const barrier = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
f.hooks.beforeWrite = async () => {
|
|
if (++arrivals === 2) release();
|
|
await barrier;
|
|
};
|
|
f.writes.length = 0;
|
|
const finalCall: Action = { type: 'traitorCall', call: false };
|
|
const attempts = await Promise.allSettled([
|
|
f.rooms.act(f.code, f.seats[1], pending.version, finalCall, clock),
|
|
f.restart().act(f.code, f.seats[1], pending.version, finalCall, clock),
|
|
]);
|
|
delete f.hooks.beforeWrite;
|
|
assert.equal(arrivals, 2);
|
|
assert.equal(
|
|
attempts.filter((attempt) => attempt.status === 'fulfilled').length,
|
|
1,
|
|
);
|
|
assert.equal(
|
|
attempts.filter((attempt) => attempt.status === 'rejected').length,
|
|
1,
|
|
);
|
|
assert.deepEqual(
|
|
f.writes.map((write) => write.changes).sort((a, b) => a - b),
|
|
[0, 1],
|
|
);
|
|
|
|
const done = await f.restart().readRoom(f.code);
|
|
assert.equal(done.version, pending.version + 1);
|
|
assert.equal(done.battle, null);
|
|
assert.equal(done.lastBattleContext?.result, 'traitor');
|
|
assert.equal(done.lastBattleContext?.winner, 'a');
|
|
assert.equal(done.players[0].spice, 20 + bounty);
|
|
assert.equal(
|
|
done.log.filter(
|
|
(entry) => entry.automatic?.name === 'Spice Banker payment',
|
|
).length,
|
|
1,
|
|
);
|
|
assert.match(
|
|
done.log.find(
|
|
(entry) => entry.automatic?.name === 'Spice Banker payment',
|
|
)!.text,
|
|
/waived/,
|
|
);
|
|
const writes = f.writes.length;
|
|
await assert.rejects(() =>
|
|
f.restart().act(f.code, f.seats[1], pending.version, finalCall, clock),
|
|
);
|
|
assert.equal(f.writes.length, writes);
|
|
assert.deepEqual(await f.restart().readRoom(f.code), done);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('a restored legacy plan without bankerSpice resolves without reserving or paying it', async () => {
|
|
const f = await fixture({ spice: 4 });
|
|
try {
|
|
await f.rooms.act(f.code, f.seats[0], f.state.version, bankerPlan(), clock);
|
|
const sealed = await f.restart().readRoom(f.code);
|
|
assert.equal('bankerSpice' in sealed.battle!.plans.a, false);
|
|
|
|
await f.restart().act(f.code, f.seats[1], sealed.version, guildPlan, clock);
|
|
let state = await f.restart().readRoom(f.code);
|
|
await f
|
|
.restart()
|
|
.act(
|
|
f.code,
|
|
f.seats[0],
|
|
state.version,
|
|
{ type: 'traitorCall', call: false },
|
|
clock,
|
|
);
|
|
state = await f.restart().readRoom(f.code);
|
|
await f
|
|
.restart()
|
|
.act(
|
|
f.code,
|
|
f.seats[1],
|
|
state.version,
|
|
{ type: 'traitorCall', call: false },
|
|
clock,
|
|
);
|
|
const done = await f.restart().readRoom(f.code);
|
|
assert.equal(done.players[0].spice, 4);
|
|
assert.ok(
|
|
!done.log.some(
|
|
(entry) => entry.automatic?.name === 'Spice Banker payment',
|
|
),
|
|
);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|