Files
Dune/tests/nexus-emperor-purchase-recovery.test.ts

110 lines
5.5 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { unitStore } from './fixture-nexus-room-store';
import { viewGame, type Game } from '../game/engine';
import type { RoomsClock } from '../db/rooms';
import { wonEmperorNexusBankAuction } from './fixture-nexus-emperor-purchase';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
const rows = (store: ReturnType<typeof unitStore>) =>
store.sqlite.prepare('SELECT state,version FROM rooms ORDER BY code').all();
async function fixture(t: test.TestContext, advanced: boolean) {
const store = unitStore();
t.after(() => store.sqlite.close());
const made = await store.rooms.createRoom('Emperor purchase recovery', 'fremen', advanced, []);
const code = made.view.code;
const tokens = [made.token];
for (const faction of ['atreides', 'harkonnen'] as const)
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
const ids = auths.map(auth => auth.playerId) as [string, string, string];
const g = wonEmperorNexusBankAuction(advanced, ids);
g.code = code;
g.version = (await store.rooms.readRoom(code)).version;
store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(g), g.version, code);
store.writes.length = 0;
return { ...store, code, tokens, auths, ids, g };
}
type Fixture = Awaited<ReturnType<typeof fixture>>;
async function restored(f: Fixture, expected: Game) {
const before = rows(f);
for (const [i, token] of f.tokens.entries()) {
const rooms = f.restart();
const auth = await rooms.authenticate(f.code, token);
const view = await rooms.readSeatView(f.code, auth);
assert.deepEqual(view, viewGame(expected, f.ids[i]));
assert.equal('nexusEmperorPurchaseHistory' in view, false);
assert.equal('nexusEmperorPurchaseEvents' in view, false);
if (i !== 1) assert.equal(view.nexusEmperorSecretAlly, null);
for (const rival of view.players.filter(player => player.id !== f.ids[i])) {
assert.equal('hand' in rival, false);
assert.equal('spice' in rival, false);
}
}
await f.restart().continueRoomAutomatic(f.code, clock);
assert.deepEqual(rows(f), before);
}
void test('personally funded purchase survives SQLite restart without losing card or charging bid; stale replay does not commit', async t => {
for (const advanced of [false, true]) {
const f = await fixture(t, advanced);
await restored(f, f.g);
const initial = rows(f);
const seats = f.sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all();
const offer = viewGame(f.g, f.ids[1]).nexusEmperorSecretAlly!;
const action = { type: 'nexusEmperorPurchase', event: offer.event };
for (const [i, malformed] of [[0, action], [1, { ...action, event: 'stale' }], [1, { ...action, price: 0 }]] as const)
await assert.rejects(f.restart().act(f.code, f.auths[i], f.g.version, malformed, clock));
assert.deepEqual(rows(f), initial);
const lot = f.g.auction!.cards[f.g.auction!.index].id;
await f.restart().act(f.code, f.auths[1], f.g.version, action, clock);
const done = await f.restart().readRoom(f.code);
assert.equal(done.version, f.g.version + 1);
assert.equal(done.players[1].spice, f.g.players[1].spice);
assert.equal(done.players[1].hand.filter(card => card.id === lot).length, 1);
assert.equal(done.nexusEmperorPurchaseHistory?.length, 1);
assert.deepEqual(done.nexusEmperorPurchaseEvents, [offer.event]);
assert.equal(done.nexusCards!.cards!.hands[f.ids[1]], null);
assert.equal(done.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
await restored(f, done);
const saved = rows(f);
await assert.rejects(f.restart().act(f.code, f.auths[1], f.g.version, action, clock), /table changed/);
await assert.rejects(f.restart().act(f.code, f.auths[1], done.version, action, clock));
assert.deepEqual(rows(f), saved);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all(), seats);
assert.deepEqual(f.writes.map(write => write.changes), [1]);
}
});
void test('racing Emperor purchase and ordinary auction payment preserve one winner and one physical lot', async t => {
const f = await fixture(t, true);
let arrivals = 0;
let release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; };
const timer = setTimeout(release, 2000);
const event = viewGame(f.g, f.ids[1]).nexusEmperorSecretAlly!.event;
let outcomes: PromiseSettledResult<unknown>[];
try {
outcomes = await Promise.allSettled([
f.restart().act(f.code, f.auths[1], f.g.version, { type: 'nexusEmperorPurchase', event }, clock),
f.restart().act(f.code, f.auths[1], f.g.version, { type: 'decision', karama: false }, clock),
]);
} finally {
clearTimeout(timer);
delete f.hooks.beforeWrite;
}
assert.equal(arrivals, 2);
assert.equal(outcomes.filter(result => result.status === 'fulfilled').length, 1);
const done = await f.restart().readRoom(f.code);
const lot = f.g.auction!.cards[f.g.auction!.index].id;
assert.equal(done.version, f.g.version + 1);
assert.equal(done.players[1].hand.filter(card => card.id === lot).length, 1);
assert.equal(done.nexusEmperorPurchaseHistory?.length ?? 0, done.players[1].spice === f.g.players[1].spice ? 1 : 0);
assert.deepEqual(f.writes.map(write => write.changes).sort((a, b) => a - b), [0, 1]);
await restored(f, done);
});