import test from 'node:test'; import assert from 'node:assert/strict'; import { unitStore } from './fixture-nexus-room-store'; import { normalizeAutomaticGame, viewGame, type Game, type Action, } from '../game/engine'; import type { RoomsClock } from '../db/rooms'; import { nexusRicheseFixture, nexusRicheseRequest, nexusRicheseInventory, type NexusRicheseFixtureOptions, } from './fixture-nexus-richese'; const clock: RoomsClock = { now: () => 10000, sleep: async () => {} }; async function fixture(options: NexusRicheseFixtureOptions = {}) { const store = unitStore(); const richese = options.ownerFaction === 'richese'; const made = await store.rooms.createRoom( 'Richese SQL', options.ownerFaction ?? 'atreides', options.advanced ?? true, richese ? ['choam'] : [], ); const code = made.view.code, tokens = [made.token]; for (const faction of ['guild', richese ? 'choam' : '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((a) => a.playerId) as [string, string, string]; const f = nexusRicheseFixture({ guild: true, advanced: true, karama: true, ...options, seatIds: ids, }); f.g.code = code; f.g.version = (await store.rooms.readRoom(code)).version; const save = (g: Game) => store.sqlite .prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?') .run(JSON.stringify(g), g.version, code); save(f.g); store.writes.length = 0; return { ...store, ...f, code, tokens, auths, ids, save }; } type Fixture = Awaited>; const row = (f: Fixture) => f.sqlite .prepare('SELECT state,version FROM rooms WHERE code = ?') .get(f.code); async function act(f: Fixture, seat: number, action: Action) { const g = await f.restart().readRoom(f.code); await f.restart().act(f.code, f.auths[seat], g.version, action, clock); return f.restart().readRoom(f.code); } async function restored(f: Fixture, g: Game) { const before = row(f), rooms = f.restart(); for (const [i, token] of f.tokens.entries()) { const auth = await rooms.authenticate(f.code, token); const v = await rooms.readSeatView(f.code, auth); assert.deepEqual(v, viewGame(g, f.ids[i])); assert.equal(Object.hasOwn(v, 'nexusRicheseHistory'), false); if (i !== 0) assert.equal(v.nexusRichese, null); } await rooms.continueRoomAutomatic(f.code, clock); assert.deepEqual(row(f), before); nexusRicheseInventory(g); } async function compete(f: Fixture, version: number, actions: Action[]) { let arrivals = 0, release!: () => void; const gate = new Promise((resolve) => { release = resolve; }); f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; }; const timer = setTimeout(release, 2000); const results = await Promise.allSettled( actions.map((action) => f.restart().act(f.code, f.auths[0], version, action, clock), ), ); clearTimeout(timer); delete f.hooks.beforeWrite; assert.equal(arrivals, 2); assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1); assert.deepEqual( f.writes.map((w) => w.changes).sort((a, b) => a - b), [0, 1], ); } void test('SQLite competing Richese declarations spend one Nexus and restore one actual pending shipment after a lost acknowledgment', async () => { const f = await fixture({ spice: 1 }); try { const unrelated = await f.rooms.createRoom( 'Unrelated saved table', 'atreides', false, [], ); const otherBefore = f.sqlite .prepare('SELECT state,version FROM rooms WHERE code = ?') .get(unrelated.view.code); await compete(f, f.g.version, [ nexusRicheseRequest(f, 5), nexusRicheseRequest(f, 4), ]); const pending = await f.restart().readRoom(f.code), amount = pending.pendingShipment!.amount; assert.ok(amount === 4 || amount === 5); assert.equal(pending.pendingShipment!.cost, 1); assert.equal(pending.players[0].reserves, 20); assert.equal(pending.players[0].spice, 1); assert.equal( pending.nexusCards!.cards!.discard.filter((c) => c === 'richese').length, 1, ); await restored(f, pending); const done = await act(f, 1, { type: 'decision', allow: true }); assert.equal(done.players[0].reserves, 20 - amount); assert.equal(done.players[0].forces['arrakeen:10'], amount); assert.equal(done.players[0].spice, 0); assert.equal(done.players[1].spice, 21); await restored(f, done); const before = row(f); await assert.rejects( f .restart() .act(f.code, f.auths[0], done.version, nexusRicheseRequest(f), clock), ); assert.deepEqual(row(f), before); assert.deepEqual( f.sqlite .prepare('SELECT state,version FROM rooms WHERE code = ?') .get(unrelated.view.code), otherBefore, ); } finally { f.sqlite.close(); } }); void test('SQLite competing native Cunning pairs settle one concealed marker and one immediate faceup group after reload', async () => { const f = await fixture({ ownerFaction: 'richese', opponentFaction: 'guild', spice: 20 }); try { const tokens = f.g.players[0].noField!.tokens; const three = tokens.find(token => token.value === 3)!.id; const five = tokens.find(token => token.value === 5)!.id; const event = viewGame(f.g, f.owner).nexusRicheseCunning!.event; const request = (concealed: string, revealed: string): Action => ({ type: 'ship', territory: 'arrakeen', sector: 10, noField: concealed, revealedToken: revealed, event: f.g.players[0].noFieldEvent, nexus: event, allyPayment: 0, }); await compete(f, f.g.version, [request(three, five), request(five, three)]); let pending = await f.restart().readRoom(f.code); assert.equal(pending.nexusRicheseCunningLast?.stage, 'pending'); assert.equal(pending.nexusCards!.cards!.discard.filter(card => card === 'richese').length, 1); await restored(f, pending); while (pending.response) { const seat = f.ids.findIndex(id => !pending.response!.passed.includes(id)); pending = await act(f, seat, { type: 'passResponse' }); } assert.equal(pending.decision?.kind, 'guildShipment'); const revealedId = pending.pendingShipment!.richesePair!.revealedTokenId; await restored(f, pending); const done = await act(f, 1, { type: 'decision', allow: true }); const marker = done.players[0].noField!.deployed!; const revealed = tokens.find(token => token.id === revealedId)!; assert.equal(done.nexusRicheseCunningLast?.stage, 'shipped'); assert.equal(done.players[0].noField!.lastShipped, marker.tokenId); assert.equal(done.players[0].forces['arrakeen:10'], revealed.value); assert.equal(done.players[0].reserves, 20 - revealed.value); assert.equal(done.players[0].spice, 19); assert.equal(done.players[1].spice, 21); await restored(f, done); const before = row(f); await assert.rejects(() => f.restart().act( f.code, f.auths[0], done.version, request(marker.tokenId, revealed.id), clock, )); assert.deepEqual(row(f), before); } finally { f.sqlite.close(); } }); void test('SQLite Guild special stop preserves physical reserves and payment while keeping Richese Nexus spent across reload', async () => { const f = await fixture({ spice: 1 }); try { await act(f, 0, nexusRicheseRequest(f)); const stopped = await act(f, 1, { type: 'card', mode: 'special', card: f.karama, }); assert.equal(stopped.players[0].reserves, 20); assert.equal(stopped.players[0].spice, 1); assert.equal(stopped.players[0].forces['arrakeen:10'], undefined); assert.equal(stopped.nexusCards!.cards!.hands[f.owner], null); assert.equal(stopped.discard.filter((c) => c.id === f.karama).length, 1); await restored(f, stopped); const before = row(f); await assert.rejects( f .restart() .act( f.code, f.auths[1], stopped.version, { type: 'card', mode: 'special', card: f.karama }, clock, ), ); assert.deepEqual(row(f), before); } finally { f.sqlite.close(); } }); void test('SQLite rejects changed Richese paid intent and missing saved source without projection, normalization or action writes', async () => { const f = await fixture({ spice: 1 }); try { const pending = await act(f, 0, nexusRicheseRequest(f)); for (const edit of [ (g: Game) => { delete g.nexusRicheseHistory; }, (g: Game) => { delete g.pendingShipment!.nexusEvent; }, (g: Game) => { delete g.nexusRicheseLast; }, (g: Game) => { g.nexusRicheseHistory![0].signature = 'changed'; }, (g: Game) => { if (g.decision?.kind === 'guildShipment') g.decision.amount = 4; }, (g: Game) => { if (g.decision?.kind === 'guildShipment') g.decision.player = f.observer; }, (g: Game) => { g.pendingShipment!.amount = 4; }, (g: Game) => { g.pendingShipment!.cost = 0; }, (g: Game) => { g.pendingShipment!.territory = 'carthag'; g.pendingShipment!.sector = 11; }, (g: Game) => { g.decision = null; }, ]) { const bad = structuredClone(pending); edit(bad); f.save(bad); f.writes.length = 0; const before = row(f), rooms = f.restart(); for (const auth of f.auths) await assert.rejects(rooms.readSeatView(f.code, auth)); assert.throws(() => normalizeAutomaticGame(bad)); // A waiting human shipment does not require automatic room recovery; the // room adapter may return before dispatching the rejecting normalizer. await Promise.allSettled([rooms.continueRoomAutomatic(f.code, clock)]); await assert.rejects( rooms.act( f.code, f.auths[1], bad.version, { type: 'decision', allow: true }, clock, ), ); assert.deepEqual(row(f), before); assert.equal(f.writes.length, 0); } } finally { f.sqlite.close(); } }); void test('SQLite typed Kaitain and Salusa shipment settles exactly once after a saved Guild interception', async () => { const f = await fixture({ ownerFaction: 'emperor', homeworlds: true, spice: 1, }); try { const request = { ...nexusRicheseRequest(f), elite: 2, homeworldSources: { 'homeworld:emperor': { normal: 3, elite: 0 }, 'homeworld:emperor:salusa': { normal: 0, elite: 2 }, }, }; const pending = await act(f, 0, request); await restored(f, pending); const changed = structuredClone(pending); changed.pendingShipment!.elite = 1; changed.pendingShipment!.homeworldSources!['homeworld:emperor'].normal = 4; changed.pendingShipment!.homeworldSources![ 'homeworld:emperor:salusa' ].elite = 1; f.save(changed); f.writes.length = 0; const corruptRow = row(f); for (const auth of f.auths) await assert.rejects(f.restart().readSeatView(f.code, auth)); await assert.rejects( f .restart() .act( f.code, f.auths[1], changed.version, { type: 'decision', allow: true }, clock, ), ); assert.deepEqual(row(f), corruptRow); assert.equal(f.writes.length, 0); f.save(pending); const done = await act(f, 1, { type: 'decision', allow: true }); assert.equal(done.players[0].reserves, 15); assert.equal(done.players[0].elites!.forces['arrakeen:10'], 2); assert.equal(done.homeworlds!.custody!.salusa!.elite, 3); assert.equal(done.players[0].spice, 0); await restored(f, done); assert.equal( done.nexusCards!.cards!.discard.filter((c) => c === 'richese').length, 1, ); } finally { f.sqlite.close(); } }); void test('SQLite restores a committed five-force shipment awaiting BG accompaniment without replaying its payment or arrival', async () => { const f = await fixture({ opponentFaction: 'beneGesserit', spice: 1 }); try { const arrived = await act(f, 0, nexusRicheseRequest(f)); assert.equal(arrived.decision?.kind, 'advisor'); assert.equal(arrived.players[0].spice, 0); assert.equal(arrived.players[0].forces['arrakeen:10'], 5); await restored(f, arrived); const done = await act(f, 1, { type: 'decision', accept: true, accompany: true, }); assert.equal(done.players[0].reserves, 15); assert.equal(done.players[0].forces['arrakeen:10'], 5); assert.equal(done.players[0].spice, 0); assert.equal(done.players[1].forces['arrakeen:10'], 1); assert.equal(done.players[1].reserves, 19); await restored(f, done); const before = row(f); await assert.rejects( f .restart() .act( f.code, f.auths[1], done.version, { type: 'decision', accept: true }, clock, ), ); assert.deepEqual(row(f), before); } finally { f.sqlite.close(); } });