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 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( advanced = false, faction: 'emperor' | 'beneGesserit' = 'emperor', ) { const store = unitStore(); const made = await store.rooms.createRoom('Asker', 'atreides', false, []); const code = made.view.code; const target = await store.rooms.joinRoom(code, 'Shipper', faction); const observer = await store.rooms.joinRoom(code, 'Observer', 'harkonnen'); const seats = await Promise.all( [made.token, target.token!, observer.token!].map((token) => store.rooms.authenticate(code, token), ), ); let initial = await store.rooms.readRoom(code); if (advanced) { initial.advanced = true; // Set the requested mode on the still-uninitialized lobby. initial.players.forEach((p) => { p.ready = true; }); initial = engine.initializeBaseGameForAudit(initial); for (let n = 0; initial.status === 'setup' && n < 50; n++) { const id = engine.viewGame(initial, initial.host).setupPending[0]; assert.ok(id, 'Genuine Advanced setup retains a decision owner.'); const view = engine.viewGame(initial, id); view.players.find((p) => p.id === id)!.bot = 'Hard'; const action = bots.botActions(view)[0]; assert.ok(action, 'Genuine Advanced setup has a legal next decision.'); initial = engine.applyAction(initial, id, action); } assert.equal(initial.status, 'playing'); } Object.assign(initial, { status: 'playing', phase: 5, turn: 2, active: seats[1].playerId, order: [seats[1].playerId, seats[0].playerId, seats[2].playerId], movementRemaining: [ seats[1].playerId, seats[0].playerId, seats[2].playerId, ], storm: 18, deck: baseDeck(), response: null, decision: null, phaseOpening: null, }); for (const p of initial.players) { p.hand = []; p.spice = 10; p.forces = {}; p.reserves = 20; p.tanks = 0; if (p.elites) { p.elites.forces = {}; p.elites.reserves = p.faction === 'emperor' ? 5 : 3; p.elites.tanks = 0; p.elites.revived = 0; } if (p.advisors) p.advisors = {}; p.shipped = false; p.moved = 0; if (!advanced) p.traitors = [p.leaders[0].id]; p.traitorChoices = []; } initial.players[1].forces = { 'polar_sink:0': 2 }; initial.players[1].reserves = 18; const hold = (index: number, name: string) => { const at = initial.deck.findIndex((card) => card.name === name); assert.ok(at >= 0, name); const card = initial.deck.splice(at, 1)[0]; initial.players[index].hand.push(card); return card; }; const card = hold(0, 'Truthtrance'); hold(1, 'Shield'); hold(2, 'Gom Jabbar'); store.sqlite .prepare('UPDATE rooms SET state=?,version=? WHERE code=?') .run(JSON.stringify(initial), initial.version, code); store.writes.length = 0; return { ...store, code, seats, initial, card }; } type Fixture = Awaited>; async function act(f: Fixture, index: number, action: engine.Action) { const current = await f.restart().readRoom(f.code); await f.restart().act(f.code, f.seats[index], current.version, action, clock); return f.restart().readRoom(f.code); } const claim = { kind: 'shipment', territory: 'carthag', minimum: 6 } as const; async function priority(f: Fixture) { await act(f, 0, { type: 'card', card: f.card.id }); for (const index of [1, 2]) await act(f, index, { type: 'truthPass' }); return f.restart().readRoom(f.code); } async function ask(f: Fixture) { await priority(f); return act(f, 0, { type: 'truthAsk', question: { ...claim, target: f.seats[1].playerId }, }); } async function restored(f: Fixture) { const before = await f.restart().readRoom(f.code); const writes = f.writes.length; await f.restart().continueRoomAutomatic(f.code, clock); assert.equal( f.writes.length, writes, 'Refresh must not answer or ship on behalf of a human.', ); assert.deepEqual(await f.restart().readRoom(f.code), before); return before; } function inventory(g: engine.Game) { return [...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)] .map((card) => card.id) .sort(); } async function privateViews( f: Fixture, answering: boolean, completion: boolean, ) { const state = await f.restart().readRoom(f.code); for (const [index, seat] of f.seats.entries()) { const view = await f.restart().readSeatView(f.code, seat); assert.deepEqual( view.truthShipmentAnswers, index === 1 && answering ? ['yes', 'no'] : null, ); if (index === 1 && completion) assert.ok(view.shipmentCompletion?.actions.length); else assert.equal(view.shipmentCompletion, null); assert.deepEqual(view.shipmentPromises, state.shipmentPromises ?? []); for (const p of view.players) { if (p.id === seat.playerId) { assert.deepEqual(p.hand, state.players[index].hand); continue; } for (const key of [ 'spice', 'hand', 'handCount', 'traitors', 'traitorChoices', 'bribes', ]) assert.equal(key in p, false, `Other seat disclosed ${key}`); } assert.equal('deck' in view, false); } } async function rejectsWithoutWrite( f: Fixture, index: number, action: engine.Action, pattern?: RegExp, ) { const before = await f.restart().readRoom(f.code); const writes = f.writes.length; const attempt = f .restart() .act(f.code, f.seats[index], before.version, action, clock); if (pattern) await assert.rejects(attempt, pattern); else await assert.rejects(attempt); assert.deepEqual(await f.restart().readRoom(f.code), before); assert.equal(f.writes.length, writes); } function barrier() { let release!: () => void; const waiting = new Promise((resolve) => { release = resolve; }); let arrivals = 0; return async () => { if (++arrivals === 2) release(); await waiting; }; } async function race( f: Fixture, index: number, version: number, actions: engine.Action[], ) { f.writes.length = 0; f.hooks.beforeWrite = barrier(); const outcomes = await Promise.allSettled( actions.map((action) => f.restart().act(f.code, f.seats[index], version, action, clock), ), ); f.hooks.beforeWrite = undefined; assert.equal( outcomes.filter((outcome) => outcome.status === 'fulfilled').length, 1, ); assert.equal( outcomes.filter((outcome) => outcome.status === 'rejected').length, 1, ); assert.deepEqual( f.writes.map((write) => write.changes).sort((a, b) => a - b), [0, 1], ); return outcomes; } function assertSingleAnswer(f: Fixture, g: engine.Game, answer: boolean) { assert.equal(g.truthtrance, null); assert.equal(g.shipmentPromises?.length, 1); assert.equal(g.shipmentPromises![0].answer, answer); assert.equal(g.shipmentPromises![0].player, f.seats[1].playerId); assert.equal(g.shipmentPromises![0].asker, f.seats[0].playerId); assert.equal(g.shipmentPromises![0].turn, 2); assert.equal(g.shipmentPromises![0].territory, 'carthag'); assert.equal(g.shipmentPromises![0].minimum, 6); assert.equal(g.truthHistory?.length, 1); assert.equal(g.truthHistory![0].answer, answer ? 'yes' : 'no'); assert.deepEqual(g.truthHistory![0].question, { ...claim, target: f.seats[1].playerId, }); assert.equal(g.discard.filter((card) => card.id === f.card.id).length, 1); assert.equal( g.players[0].hand.some((card) => card.id === f.card.id), false, ); assert.deepEqual(inventory(g), inventory(f.initial)); } void test('shipment question and accepted promise survive fresh room modules with only target-private answers and completion choices', async () => { const f = await fixture(); try { const unasked = await priority(f); await restored(f); for (const minimum of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1]) await rejectsWithoutWrite(f, 0, { type: 'truthAsk', question: { ...claim, target: f.seats[1].playerId, minimum }, }); await rejectsWithoutWrite(f, 0, { type: 'truthAsk', question: { ...claim, target: f.seats[1].playerId, territory: 'not-a-territory', }, }); await rejectsWithoutWrite(f, 0, { type: 'truthAsk', question: { ...claim, target: f.seats[2].playerId }, }); await rejectsWithoutWrite(f, 2, { type: 'truthAsk', question: { ...claim, target: f.seats[1].playerId }, }); assert.deepEqual(await f.restart().readRoom(f.code), unasked); await act(f, 0, { type: 'truthAsk', question: { ...claim, target: f.seats[1].playerId }, }); const pending = await restored(f); await privateViews(f, true, false); const poorTarget = structuredClone(pending); poorTarget.players[1].spice = 0; assert.deepEqual( engine.viewGame(poorTarget, f.seats[1].playerId).truthShipmentAnswers, ['no'], ); for (const index of [0, 2]) assert.deepEqual( engine.viewGame(poorTarget, f.seats[index].playerId), engine.viewGame(pending, f.seats[index].playerId), 'Rival projections must not disclose whether private funding permits Yes.', ); for (const index of [0, 2]) await rejectsWithoutWrite( f, index, { type: 'truthAnswer', answer: 'yes' }, /questioned player/, ); await rejectsWithoutWrite(f, 1, { type: 'truthAnswer', answer: 'unknown' }); assert.deepEqual(pending.players, f.initial.players); const accepted = await act(f, 1, { type: 'truthAnswer', answer: 'yes' }); assertSingleAnswer(f, accepted, true); assert.equal(accepted.players[1].shipped, false); assert.deepEqual(accepted.players[1], f.initial.players[1]); await privateViews(f, false, true); assert.deepEqual(await restored(f), accepted); await rejectsWithoutWrite(f, 1, { type: 'truthAnswer', answer: 'yes' }); } finally { f.hooks.beforeWrite = undefined; f.sqlite.close(); } }); void test('persisted Yes rejects evasion and a duplicated compatible reserve shipment pays, arrives and fulfills exactly once', async () => { const f = await fixture(); try { // These actions are legal before the promise, so their later rejection is meaningful. const attempts: engine.Action[] = [ { type: 'endMovement' }, { type: 'move', forces: { 'polar_sink:0': 1 }, territory: 'imperial_basin', sector: 11, }, { type: 'ship', territory: 'carthag', sector: 11, amount: 5 }, { type: 'ship', territory: 'arrakeen', sector: 10, amount: 6 }, ]; for (const action of attempts) assert.doesNotThrow(() => engine.applyAction(f.initial, f.seats[1].playerId, action), ); await ask(f); await act(f, 1, { type: 'truthAnswer', answer: 'yes' }); const accepted = await restored(f); for (const action of attempts) await rejectsWithoutWrite(f, 1, action, /Truthtrance|promise/i); const action: engine.Action = { type: 'ship', territory: 'carthag', sector: 11, amount: 7, }; await race(f, 1, accepted.version, [action, action]); const done = await f.restart().readRoom(f.code); assert.equal(done.version, accepted.version + 1); assertSingleAnswer(f, done, true); assert.equal(done.shipmentPromises![0].fulfilled, true); assert.notEqual(done.shipmentPromises![0].released, true); assert.equal(done.players[1].spice, 3); assert.equal(done.players[1].reserves, 11); assert.equal(done.players[1].forces['carthag:11'], 7); assert.equal(done.players[1].forces['polar_sink:0'], 2); assert.equal(done.players[1].shipped, true); const writes = f.writes.length; for (const version of [accepted.version, done.version]) await assert.rejects( f.restart().act(f.code, f.seats[1], version, action, clock), ); assert.equal(f.writes.length, writes); assert.deepEqual(await restored(f), done); await privateViews(f, false, false); } finally { f.hooks.beforeWrite = undefined; f.sqlite.close(); } }); void test('competing persisted Yes and No answers bind only the winning CAS and retain its exact shipment restriction', async () => { for (const firstAnswer of ['yes', 'no'] as const) { const f = await fixture(); try { await ask(f); const pending = await restored(f); const answers = [firstAnswer, firstAnswer === 'yes' ? 'no' : 'yes']; const outcomes = await race( f, 1, pending.version, answers.map((answer) => ({ type: 'truthAnswer', answer })), ); const winningIndex = outcomes.findIndex( (outcome) => outcome.status === 'fulfilled', ); const yes = answers[winningIndex] === 'yes'; const accepted = await restored(f); assert.equal(accepted.version, pending.version + 1); assertSingleAnswer(f, accepted, yes); assert.equal(accepted.players[1].shipped, false); const matching: engine.Action = { type: 'ship', territory: 'carthag', sector: 11, amount: 6, }; const smaller: engine.Action = { type: 'ship', territory: 'carthag', sector: 11, amount: 5, }; await rejectsWithoutWrite( f, 1, yes ? smaller : matching, /Truthtrance|promise/i, ); if (!yes) assert.doesNotThrow(() => engine.applyAction(accepted, f.seats[1].playerId, { type: 'endMovement', }), ); const shipped = await act(f, 1, yes ? matching : smaller); assertSingleAnswer(f, shipped, yes); assert.equal(shipped.players[1].forces['carthag:11'], yes ? 6 : 5); assert.equal(shipped.players[1].spice, yes ? 4 : 5); assert.deepEqual(await restored(f), shipped); } finally { f.hooks.beforeWrite = undefined; f.sqlite.close(); } } }); void test('corrupt persisted promise records and pending shipment questions reject before any answer, discard or CAS write', async () => { const f = await fixture(); try { await ask(f); const pending = await restored(f); const accepted = await act(f, 1, { type: 'truthAnswer', answer: 'yes' }); const corruptions: ((g: engine.Game) => void)[] = [ (g) => { g.shipmentPromises![0].minimum = 0; }, (g) => { g.shipmentPromises![0].minimum = 1.5; }, (g) => { g.shipmentPromises![0].turn = g.turn + 1; }, (g) => { g.shipmentPromises![0].player = 'missing-seat'; }, (g) => { g.shipmentPromises![0].asker = g.shipmentPromises![0].player; }, (g) => { g.shipmentPromises![0].territory = 'missing-territory'; }, (g) => { g.shipmentPromises![0].released = true; g.shipmentPromises![0].fulfilled = true; }, (g) => { g.players[1].shipped = true; }, (g) => { g.active = f.seats[2].playerId; g.movementRemaining = g.movementRemaining!.filter(id => id !== f.seats[1].playerId); }, (g) => { g.phase = 6; }, (g) => { g.expansions = ['ix']; }, ]; for (const corrupt of corruptions) { const bad = structuredClone(accepted); corrupt(bad); f.sqlite .prepare('UPDATE rooms SET state=?,version=? WHERE code=?') .run(JSON.stringify(bad), bad.version, f.code); assert.throws( () => engine.normalizeAutomaticGame(bad), /shipment promise/i, ); for (const seat of f.seats) await assert.rejects( f.restart().readSeatView(f.code, seat), /shipment promise/i, ); await rejectsWithoutWrite( f, 1, { type: 'endMovement' }, /shipment promise/i, ); } const questionCorruptions: ((g: engine.Game) => void)[] = [ (g) => { Object.assign(g.truthtrance!.question!, { minimum: 0 }); }, (g) => { Object.assign(g.truthtrance!.question!, { minimum: 1.5 }); }, (g) => { Object.assign(g.truthtrance!.question!, { target: f.seats[2].playerId, }); g.movementRemaining = g.movementRemaining!.filter(id => id !== f.seats[2].playerId); }, (g) => { g.truthtrance!.queue[0].player = 'missing-holder'; }, (g) => { g.truthtrance!.queue[0].card = 'missing-card'; }, (g) => { g.truthtrance!.queue[0] = { player: f.seats[2].playerId, card: g.players[2].hand[0].id, }; }, ]; for (const corrupt of questionCorruptions) { const bad = structuredClone(pending); corrupt(bad); f.sqlite .prepare('UPDATE rooms SET state=?,version=? WHERE code=?') .run(JSON.stringify(bad), bad.version, f.code); await rejectsWithoutWrite(f, 1, { type: 'truthAnswer', answer: 'yes' }); assert.throws(() => engine.normalizeAutomaticGame(bad)); for (const seat of f.seats) await assert.rejects(f.restart().readSeatView(f.code, seat)); } } finally { f.hooks.beforeWrite = undefined; f.sqlite.close(); } }); void test('older-turn and fulfilled shipment promises remain readable without reactivation, while absent legacy fields remain absent', async () => { const f = await fixture(); try { const legacy = await restored(f); assert.equal(legacy.shipmentPromises, undefined); for (const seat of f.seats) { const view = await f.restart().readSeatView(f.code, seat); assert.deepEqual(view.shipmentPromises, []); assert.equal(view.shipmentCompletion, null); } assert.equal( (await f.restart().readRoom(f.code)).shipmentPromises, undefined, ); await ask(f); await act(f, 1, { type: 'truthAnswer', answer: 'yes' }); const accepted = await restored(f); const expired = structuredClone(accepted); expired.turn++; f.sqlite .prepare('UPDATE rooms SET state=?,version=? WHERE code=?') .run(JSON.stringify(expired), expired.version, f.code); const normalized = engine.normalizeAutomaticGame(expired); assert.deepEqual(normalized.shipmentPromises, expired.shipmentPromises); assert.deepEqual(normalized.truthHistory, expired.truthHistory); assert.deepEqual(engine.normalizeAutomaticGame(normalized), normalized); for (const seat of f.seats) assert.equal( (await f.restart().readSeatView(f.code, seat)).shipmentCompletion, null, ); const newShipment = await act(f, 1, { type: 'ship', territory: 'carthag', sector: 11, amount: 5, }); assert.equal(newShipment.players[1].forces['carthag:11'], 5); assert.deepEqual(newShipment.shipmentPromises, accepted.shipmentPromises); assert.deepEqual(newShipment.truthHistory, accepted.truthHistory); assert.deepEqual(inventory(newShipment), inventory(accepted)); assert.deepEqual(await restored(f), newShipment); // A genuine completed record remains valid after leaving Shipment & Movement. f.sqlite .prepare('UPDATE rooms SET state=?,version=? WHERE code=?') .run(JSON.stringify(accepted), accepted.version, f.code); const completed = await act(f, 1, { type: 'ship', territory: 'carthag', sector: 11, amount: 6, }); completed.phase = 6; f.sqlite .prepare('UPDATE rooms SET state=?,version=? WHERE code=?') .run(JSON.stringify(completed), completed.version, f.code); assert.equal(completed.shipmentPromises![0].fulfilled, true); for (const seat of f.seats) { const view = await f.restart().readSeatView(f.code, seat); assert.deepEqual(view.shipmentPromises, completed.shipmentPromises); assert.equal(view.shipmentCompletion, null); } assert.deepEqual( engine.normalizeAutomaticGame(completed).shipmentPromises, completed.shipmentPromises, ); assert.deepEqual(await restored(f), completed); } finally { f.sqlite.close(); } }); void test('Advanced typed Ghola preparation and its shipment survive room reload and duplicate submissions', async () => { const f = await fixture(true); try { const staged = await f.restart().readRoom(f.code), p = staged.players[1]; p.reserves = 2; p.tanks = 5; p.forces = { 'polar_sink:0': 13 }; p.spice = 6; p.elites = { reserves: 0, tanks: 2, forces: { 'polar_sink:0': 3 }, revived: 0, }; const at = staged.deck.findIndex((c) => c.effect === 'ghola'); assert.ok(at >= 0); const ghola = staged.deck.splice(at, 1)[0]; p.hand.push(ghola); f.sqlite .prepare('UPDATE rooms SET state=?,version=? WHERE code=?') .run(JSON.stringify(staged), staged.version, f.code); await ask(f); await restored(f); const accepted = await act(f, 1, { type: 'truthAnswer', answer: 'yes' }); const preparation = engine.viewGame(accepted, p.id).shipmentCompletion! .actions[0]; assert.equal(preparation.card, ghola.id); assert.equal(preparation.amount, 4); assert.equal(preparation.elite, 1); await race(f, 1, accepted.version, [preparation, preparation]); const revived = await restored(f); assert.equal(revived.players[1].reserves, 6); assert.equal(revived.players[1].elites!.revived, 1); assert.equal(revived.discard.filter((c) => c.id === ghola.id).length, 1); const shipment = engine.viewGame(revived, p.id).shipmentCompletion! .actions[0]; assert.equal(shipment.type, 'ship'); assert.equal(shipment.elite, 1); await race(f, 1, revived.version, [shipment, shipment]); const arrived = await restored(f); assert.equal(arrived.players[1].spice, 0); assert.equal(arrived.players[1].reserves, 0); assert.equal( arrived.players[1].forces[`carthag:${Number(shipment.sector)}`], 6, ); assert.equal( arrived.players[1].elites!.forces[`carthag:${Number(shipment.sector)}`], 1, ); assert.equal(arrived.shipmentPromises![0].fulfilled, true); assert.deepEqual(inventory(arrived), inventory(staged)); for (const other of [0, 2]) { const view = engine.viewGame(arrived, f.seats[other].playerId); assert.equal(view.shipmentCompletion, null); assert.equal('hand' in view.players[1], false); } } finally { f.sqlite.close(); } }); void test('Advanced BG pending conversion keeps its promise through restart and commits or releases once', async () => { for (const canceled of [false, true]) { const f = await fixture(true, 'beneGesserit'); try { const staged = await f.restart().readRoom(f.code), p = staged.players[1]; p.spice = 3; p.forces = { 'carthag:11': 1 }; p.reserves = 19; p.advisors = { carthag: {} }; const hold = (seat: number, effect: 'worthless' | 'karama') => { const at = staged.deck.findIndex((c) => effect === 'worthless' ? c.kind === 'worthless' : c.effect === effect, ); assert.ok(at >= 0); const card = staged.deck.splice(at, 1)[0]; staged.players[seat].hand.push(card); return card.id; }; const conversion = hold(1, 'worthless'), cancel = hold(0, 'karama'); staged.players[2].forces = { 'carthag:11': 2 }; staged.players[2].reserves = 18; f.sqlite .prepare('UPDATE rooms SET state=?,version=? WHERE code=?') .run(JSON.stringify(staged), staged.version, f.code); await ask(f); const accepted = await act(f, 1, { type: 'truthAnswer', answer: 'yes' }); await act( f, 1, engine.viewGame(accepted, p.id).shipmentCompletion!.actions[0], ); let pending = await restored(f); assert.equal(pending.response?.kind, 'worthlessKarama'); assert.equal(pending.shipmentPromises![0].released, undefined); assert.equal( engine.viewGame(pending, p.id).shipmentCompletion, null, 'A pending discounted route is not yet an executable shipment or a current price.', ); await rejectsWithoutWrite(f, 1, { type: 'endMovement' }); if (canceled) { const action = { type: 'card', card: cancel, mode: 'cancel' }; await race(f, 0, pending.version, [action, action]); pending = await restored(f); assert.equal(pending.shipmentPromises![0].released, true); assert.equal(pending.players[1].spice, 3); assert.equal(pending.players[1].shipped, false); } else { for (let n = 0; pending.response && n < 10; n++) { const seat = pending.players.findIndex( (player) => !pending.response!.passed.includes(player.id), ); assert.ok(seat >= 0); pending = await act(f, seat, { type: 'passResponse' }); } assert.equal(pending.response, null); await restored(f); const shipment = engine.viewGame(pending, p.id).shipmentCompletion! .actions[0]; await race(f, 1, pending.version, [shipment, shipment]); pending = await restored(f); assert.equal(pending.shipmentPromises![0].fulfilled, true); assert.equal(pending.players[1].spice, 0); assert.ok(pending.players[1].advisors!.carthag); } assert.equal( pending.discard.filter((c) => c.id === conversion).length, 1, ); assert.deepEqual(inventory(pending), inventory(staged)); } finally { f.sqlite.close(); } } });