import test from 'node:test'; import assert from 'node:assert/strict'; import { unitStore } from './fixture-nexus-room-store'; import { discoveryFixture, enterDiscoveryCollection, putDiscovery, } from './fixture-discovery'; import { applyAction, viewGame, type Game } from '../game/engine'; import { revealDiscoveryToken } from '../game/discoveries'; import { discoveryStashSignature } from '../game/discovery-actions'; import { startPrototypeRoom } from '../tools/prototype-room'; import type { RoomsClock } from '../db/rooms'; const clock: RoomsClock = { now: () => 10000, sleep: async () => {} }; void test('SQLite Discovery prototype preserves seats and competing stash reveals pay once; saved overflow discard resumes exactly once', async () => { const store = unitStore(); try { const made = await store.rooms.createRoom( 'Discovery SQL', 'atreides', false, [], ), code = made.view.code; const tokens = [made.token]; for (const faction of ['guild', 'fremen'] 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]; let lobby = await store.rooms.readRoom(code); for (const p of lobby.players) lobby = applyAction(lobby, p.id, { type: 'ready' }); store.sqlite .prepare('UPDATE rooms SET state = ? WHERE code = ?') .run(JSON.stringify(lobby), code); const seats = store.sqlite .prepare('SELECT * FROM seats ORDER BY player_id') .all(); const begun = startPrototypeRoom( store.sqlite, code, lobby.version, 'discovery', ); assert.equal(begun.status, 'setup'); assert.equal(begun.version, lobby.version + 1); assert.throws(() => startPrototypeRoom(store.sqlite, code, begun.version, 'discovery'), ); assert.deepEqual( store.sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all(), seats, ); let g = discoveryFixture(false, ids); g.code = code; g.version = begun.version; const stash = enterDiscoveryCollection(g, 'spice-stash'); g = applyAction(g, ids[0], { type: 'discovery', token: stash.id, reveal: false, }); store.sqlite .prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?') .run(JSON.stringify(g), g.version, code); let arrivals = 0, release!: () => void; const gate = new Promise((resolve) => { release = resolve; }); store.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; }; const timer = setTimeout(release, 2000); const action = { type: 'discovery', token: stash.id, reveal: true }; try { const results = await Promise.allSettled( [0, 1].map(() => store.restart().act(code, auths[0], g.version, action, clock), ), ); assert.equal(arrivals, 2); assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1); } finally { clearTimeout(timer); delete store.hooks.beforeWrite; } const done = await store.restart().readRoom(code); assert.equal(done.players[0].spice, g.players[0].spice + 7); assert.equal(done.version, g.version + 1); await assert.rejects( store.restart().act(code, auths[0], done.version, action, clock), ); for (const [i, token] of tokens.entries()) { const rooms = store.restart(), auth = await rooms.authenticate(code, token); assert.deepEqual( await rooms.readSeatView(code, auth), viewGame(done, ids[i]), ); } g = done; const cardToken = enterDiscoveryCollection(g, 'treachery-card-stash'); while (g.players[0].hand.length < 4) g.players[0].hand.push(g.deck.shift()!); g = applyAction(g, ids[0], { type: 'discovery', token: cardToken.id, reveal: false, }); g = applyAction(g, ids[0], { type: 'discovery', token: cardToken.id, reveal: true, }); store.sqlite .prepare('UPDATE rooms SET state = ? WHERE code = ?') .run(JSON.stringify(g), code); const restored = await store.restart().readRoom(code); assert.equal(restored.players[0].hand.length, 5); assert.equal(restored.decision?.kind, 'discoveryDiscard'); const discard = restored.players[0].hand.at(-1)!; await store .restart() .act( code, auths[0], restored.version, { type: 'decision', event: restored.discoveryStash!.event, card: discard.id, }, clock, ); const finished = await store.restart().readRoom(code); assert.equal(finished.discoveryStash!.stage, 'complete'); assert.equal(finished.players[0].hand.length, 4); const paused = JSON.parse(JSON.stringify(finished)) as Game, record = paused.discoveryStash!; record.stage = 'discard'; record.signature = discoveryStashSignature(record); const sequence = paused.treacheryDiscardSequence!; paused.resolvedTreacheryDiscardSequence = sequence - 1; paused.pendingTreacheryDiscard = { sequence, batch: { event: `discard:${paused.turn}:${paused.phase}:${sequence}`, turn: paused.turn, phase: paused.phase, cause: 'discovery:stash', entries: [{ card: discard, discardedBy: ids[0], publicFace: true }], }, continuation: { kind: 'discoveryStash', event: record.event, owner: ids[0], card: discard.id, }, }; store.sqlite .prepare('UPDATE rooms SET state = ? WHERE code = ?') .run(JSON.stringify(paused), code); await store.restart().continueRoomAutomatic(code, clock); const resumed = await store.restart().readRoom(code); assert.equal(resumed.discoveryStash!.stage, 'complete'); assert.deepEqual(resumed.players, finished.players); assert.deepEqual(resumed.deck, finished.deck); assert.deepEqual(resumed.discard, finished.discard); const settled = JSON.stringify(resumed); await store.restart().continueRoomAutomatic(code, clock); assert.equal(JSON.stringify(await store.restart().readRoom(code)), settled); assert.deepEqual( store.sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all(), seats, ); } finally { store.sqlite.close(); } }); void test('competing saved Collection commits transfer Orgiz spice exactly once', async () => { const store = unitStore(); try { const made = await store.rooms.createRoom('Orgiz SQL', 'atreides', false, []); const code = made.view.code; const tokens = [made.token]; for (const faction of ['guild', 'fremen'] 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 = discoveryFixture(false, ids); g.code = code; g.version = (await store.rooms.readRoom(code)).version; const token = putDiscovery(g, 'orgiz-processing-station'); g.discoveries = revealDiscoveryToken(g.discoveries!, token.id, g.turn).state; Object.assign(g, { phase: 5, active: ids[2], movementRemaining: [ids[2]], phaseOpening: null, response: null, decision: null, storm: 18, stormPending: null, spice: { 'hagga_basin:12': 4 }, }); for (const player of g.players) Object.assign(player, { forces: {}, reserves: 20, shipped: true, moved: 1, }); g.players[0].forces = { 'orgiz-processing-station:0': 1 }; g.players[0].reserves = 19; g.players[1].forces = { 'hagga_basin:12': 1 }; g.players[1].reserves = 19; const beforeOwner = g.players[0].spice, beforeCollector = g.players[1].spice; store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?') .run(JSON.stringify(g), g.version, code); let arrivals = 0, release!: () => void; const gate = new Promise(resolve => { release = resolve; }); store.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; }; const timer = setTimeout(release, 2000); try { const results = await Promise.allSettled([0, 1].map(() => store.restart().act(code, auths[2], g.version, { type: 'endMovement' }, clock))); assert.equal(arrivals, 2); assert.equal(results.filter(result => result.status === 'fulfilled').length, 1); } finally { clearTimeout(timer); delete store.hooks.beforeWrite; } const done = await store.restart().readRoom(code); assert.equal(done.version, g.version + 1); assert.equal(done.players[0].spice, beforeOwner + 1); assert.equal(done.players[1].spice, beforeCollector + 1); assert.equal(done.spice['hagga_basin:12'], 2); assert.equal(done.log.filter(entry => entry.automatic?.name === 'Orgiz Processing Station').length, 1); const settled = JSON.stringify(done); await store.restart().continueRoomAutomatic(code, clock); assert.equal(JSON.stringify(await store.restart().readRoom(code)), settled); } finally { store.sqlite.close(); } });