import test from 'node:test'; import assert from 'node:assert/strict'; import { webcrypto } from 'node:crypto'; import { readFileSync, readdirSync } from 'node:fs'; import { DatabaseSync } from 'node:sqlite'; import { runInNewContext } from 'node:vm'; import ts from 'typescript'; import * as engine from '../game/engine'; import * as bots from '../game/bots'; import * as seatAiDelegation from '../lib/seat-ai-delegation'; import { baseDeck } from '../game/cards'; import { createRicheseNoField, deployRicheseNoField, } from '../game/richese-no-field'; import type * as Rooms from '../db/rooms'; /** Execute the production room module and SQL, with a hook immediately before its CAS. */ function unitStore(runBots = bots.runBots) { const sqlite = new DatabaseSync(':memory:'); const hooks: { beforeWrite?: () => Promise } = {}; const writes: { expected: number; changes: number }[] = []; for (const file of readdirSync(new URL('../drizzle/', import.meta.url)) .filter((f) => f.endsWith('.sql')) .sort()) sqlite.exec( readFileSync(new URL('../drizzle/' + file, import.meta.url), 'utf8'), ); class Statement { values: (string | number | null)[] = []; constructor(readonly sql: string) {} bind(...values: (string | number | null)[]) { this.values = values; return this; } async first() { return sqlite.prepare(this.sql).get(...this.values) ?? null; } async run() { const continuation = this.sql.startsWith('UPDATE rooms SET state'); if (continuation) await hooks.beforeWrite?.(); const changes = Number( sqlite.prepare(this.sql).run(...this.values).changes, ); if (continuation) writes.push({ expected: Number(this.values[3]), changes }); return { meta: { changes } }; } } const database = { prepare: (sql: string) => new Statement(sql), batch: async (statements: Statement[]) => { sqlite.exec('BEGIN'); try { const results = []; for (const statement of statements) results.push(await statement.run()); sqlite.exec('COMMIT'); return results; } catch (error) { sqlite.exec('ROLLBACK'); throw error; } }, }; function loadRooms() { const exports = {}; runInNewContext( ts.transpileModule( readFileSync(new URL('../db/rooms.ts', import.meta.url), 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, }, }, ).outputText, { exports, crypto: webcrypto, TextEncoder, structuredClone, // Keep persisted JSON values in the test realm for strict structural assertions. JSON, require: (name: string) => { if (name === 'cloudflare:workers') return { env: { DB: database } }; if (name === '@/game/engine') return engine; if (name === '@/game/bots') return { ...bots, runBots }; if (name === '@/lib/seat-ai-delegation') return seatAiDelegation; throw new Error('Unexpected module ' + name); }, }, ); return exports as typeof Rooms; } return { rooms: loadRooms(), restart: loadRooms, sqlite, hooks, writes }; } const clock: Rooms.RoomsClock = { now: () => 10000, sleep: async () => {} }; async function fixture(guild = false) { const store = unitStore(); const created = await store.rooms.createRoom('Richese', 'guild', false, []); const code = created.view.code; const joined = await store.rooms.joinRoom(code, 'Ally', 'atreides'); const third = await store.rooms.joinRoom(code, 'Other', 'emperor'); const owner = await store.rooms.authenticate(code, created.token); const ally = await store.rooms.authenticate(code, joined.token!); const other = await store.rooms.authenticate(code, third.token!); const stored = await store.rooms.readRoom(code); const initial = engine.createGame( code, engine.newPlayer(owner.playerId, 'Richese', 'richese'), guild, ['choam'], ); initial.players.push( engine.newPlayer(ally.playerId, 'Ally', 'atreides'), engine.newPlayer(other.playerId, 'Other', guild ? 'guild' : 'emperor'), ); initial.version = stored.version; initial.status = 'playing'; initial.phase = 5; initial.turn = 2; initial.active = ally.playerId; initial.order = initial.players.map((p) => p.id); initial.storm = 18; initial.deck = baseDeck(); initial.discard = []; initial.players.forEach((p) => { p.spice = 10; p.hand = []; p.forces = {}; p.reserves = 20; }); initial.players[0].ally = ally.playerId; initial.players[1].ally = owner.playerId; initial.players[0].noField = createRicheseNoField([ 'concealed-zero', 'concealed-three', 'concealed-five', ]); initial.players[0].noFieldEvent = 'token-generation'; initial.players[0].noField = deployRicheseNoField( initial.players[0].noField, { tokenId: 'concealed-three', controller: owner.playerId, location: { territory: 'imperial_basin', sector: 10 }, }, ); const k = initial.deck.findIndex((c) => c.effect === 'karama'); initial.players[2].hand.push(...initial.deck.splice(k, 1)); function save(g: engine.Game) { store.sqlite .prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?') .run(JSON.stringify(g), g.version, code); } save(initial); store.writes.length = 0; async function action( auth: Rooms.SeatAuth, value: engine.Action, rooms = store.rooms, ) { const state = await rooms.readRoom(code); return rooms.act(code, auth, state.version, value, clock); } const offer: engine.Action = { type: 'offerRicheseNoField', token: 'concealed-five', event: 'token-generation', territory: 'arrakeen', sector: 10, payer: owner.playerId, }; return { ...store, owner, ally, other, code, initial, save, action, offer }; } function barrier() { let release!: () => void; const ready = new Promise((resolve) => { release = resolve; }); let n = 0; return async () => { if (++n === 2) release(); await ready; }; } function oneCommit( results: PromiseSettledResult[], writes: { changes: number }[], ) { assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1); assert.equal(results.filter((r) => r.status === 'rejected').length, 1); assert.deepEqual( writes.map((w) => w.changes).sort((a, b) => a - b), [0, 1], ); } function conservation(g: engine.Game) { for (const p of g.players) assert.equal( p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), 20, ); } void test('restarted authenticated consent is private to the two allies and concurrent accept commits only one pending shipment', async () => { const f = await fixture(); try { await f.action(f.owner, f.offer); const offered = await f.rooms.readRoom(f.code); const restarted = f.restart(); const recipient = await restarted.readSeatView(f.code, f.ally); const unrelated = await restarted.readSeatView(f.code, f.other); assert.equal(recipient.richeseNoField!.allyOffer!.value, 5); assert.equal(recipient.richeseNoField!.allyOffer!.amount, 5); assert.equal(recipient.richeseNoField!.private, null); assert.equal(unrelated.richeseNoField!.allyOffer, null); for (const token of ['concealed-zero', 'concealed-three', 'concealed-five']) assert.equal(JSON.stringify(unrelated).includes(token), false); const choice = { type: 'decision', event: offered.richeseAllyOffer!.event, accept: true, elite: 0, }; f.writes.length = 0; f.hooks.beforeWrite = barrier(); const results = await Promise.allSettled([ restarted.act(f.code, f.ally, offered.version, choice, clock), f.rooms.act(f.code, f.ally, offered.version, choice, clock), ]); f.hooks.beforeWrite = undefined; oneCommit(results, f.writes); const pending = await restarted.readRoom(f.code); assert.equal(pending.version, offered.version + 1); assert.equal(pending.response?.kind, 'richeseNoField'); assert.equal( pending.players[0].noField!.deployed!.tokenId, 'concealed-three', ); assert.equal(pending.players[0].spice, 10); assert.equal(pending.players[1].reserves, 20); await assert.rejects( restarted.act(f.code, f.ally, pending.version, choice, clock), ); assert.deepEqual(await restarted.readRoom(f.code), pending); await restarted.continueRoomAutomatic(f.code, clock); assert.deepEqual(await restarted.readRoom(f.code), pending); conservation(pending); } finally { f.sqlite.close(); } }); void test('duplicate cancellation after restart returns allied shipment custody once and preserves ordinary shipment recovery', async () => { const f = await fixture(); try { await f.action(f.owner, f.offer); let state = await f.rooms.readRoom(f.code); await f.action(f.ally, { type: 'decision', event: state.richeseAllyOffer!.event, accept: true, elite: 0, }); state = await f.rooms.readRoom(f.code); const resumed = f.restart(), cancel = { type: 'card', mode: 'cancel', card: state.players[2].hand[0].id, }; f.writes.length = 0; f.hooks.beforeWrite = barrier(); const results = await Promise.allSettled([ resumed.act(f.code, f.other, state.version, cancel, clock), f.rooms.act(f.code, f.other, state.version, cancel, clock), ]); f.hooks.beforeWrite = undefined; oneCommit(results, f.writes); const stopped = await resumed.readRoom(f.code); assert.equal( stopped.players[0].noField!.deployed!.tokenId, 'concealed-three', ); assert.equal(stopped.players[0].spice, 10); assert.equal(stopped.players[1].shipped, false); assert.equal(stopped.pendingShipment, null); assert.equal(stopped.discard.filter((c) => c.id === cancel.card).length, 1); await f.action( f.ally, { type: 'ship', territory: 'arrakeen', sector: 10, amount: 2 }, resumed, ); const recovered = await resumed.readRoom(f.code); assert.equal(recovered.players[1].forces['arrakeen:10'], 2); assert.equal(recovered.players[1].spice, 8); assert.equal(recovered.players[0].reserves, 20); conservation(recovered); } finally { f.sqlite.close(); } }); void test('stale private consent can decline after reload and stale Guild commitment aborts under CAS without reveal or payment', async () => { const f = await fixture(true); try { await f.action(f.owner, f.offer); let offered = await f.rooms.readRoom(f.code); const event = offered.richeseAllyOffer!.event; offered.players[0].noFieldEvent = 'obsolete-consent'; f.save(offered); let resumed = f.restart(); assert.match( (await resumed.readSeatView(f.code, f.ally)).richeseNoField!.allyOffer! .blocked!, /stale/, ); await f.action(f.ally, { type: 'decision', event, decline: true }, resumed); const declined = await resumed.readRoom(f.code); assert.equal(declined.richeseAllyOffer, null); assert.equal(declined.players[0].spice, 10); assert.equal( declined.players[0].noField!.deployed!.tokenId, 'concealed-three', ); await f.action(f.owner, { ...f.offer, event: 'obsolete-consent' }, resumed); offered = await resumed.readRoom(f.code); await f.action( f.ally, { type: 'decision', event: offered.richeseAllyOffer!.event, accept: true, elite: 0, }, resumed, ); await f.action(f.other, { type: 'passResponse' }, resumed); const pending = await resumed.readRoom(f.code); const foreign = await resumed.readSeatView(f.code, f.other); assert.equal( foreign.decision?.kind === 'guildShipment' ? foreign.decision.amount : null, 1, ); assert.equal(foreign.richeseNoField!.allyOffer, null); pending.players[1].reserves = 2; pending.players[1].tanks = 18; f.save(pending); resumed = f.restart(); const allow = { type: 'decision', allow: true }; f.writes.length = 0; f.hooks.beforeWrite = barrier(); const results = await Promise.allSettled([ resumed.act(f.code, f.other, pending.version, allow, clock), f.rooms.act(f.code, f.other, pending.version, allow, clock), ]); f.hooks.beforeWrite = undefined; oneCommit(results, f.writes); const aborted = await resumed.readRoom(f.code); assert.equal(aborted.version, pending.version + 1); assert.equal(aborted.pendingShipment, null); assert.equal(aborted.players[1].shipped, false); assert.equal(aborted.players[0].spice, 10); assert.equal( aborted.players[0].noField!.deployed!.tokenId, 'concealed-three', ); assert.deepEqual(aborted.players[1].forces, {}); conservation(aborted); await resumed.continueRoomAutomatic(f.code, clock); assert.deepEqual(await resumed.readRoom(f.code), aborted); } finally { f.sqlite.close(); } });