import { ConvexError, Infer, Value, v } from 'convex/values'; import { Doc, Id } from '../_generated/dataModel'; import { ActionCtx, DatabaseReader, MutationCtx, internalQuery } from '../_generated/server'; import { engine } from '../engine/schema'; import { internal } from '../_generated/api'; export abstract class AbstractGame { abstract tickDuration: number; abstract stepDuration: number; abstract maxTicksPerStep: number; abstract maxInputsPerStep: number; constructor(public engine: Doc<'engines'>) {} abstract handleInput(now: number, name: string, args: object): Value; abstract tick(now: number): void; // Optional callback at the beginning of each step. beginStep(now: number) {} abstract saveStep(ctx: ActionCtx, engineUpdate: EngineUpdate): Promise; async runStep(ctx: ActionCtx, now: number) { const inputs = await ctx.runQuery(internal.engine.abstractGame.loadInputs, { engineId: this.engine._id, processedInputNumber: this.engine.processedInputNumber, max: this.maxInputsPerStep, }); const lastStepTs = this.engine.currentTime; const startTs = lastStepTs ? lastStepTs + this.tickDuration : now; let currentTs = startTs; let inputIndex = 0; let numTicks = 0; let processedInputNumber = this.engine.processedInputNumber; const completedInputs = []; this.beginStep(currentTs); while (numTicks < this.maxTicksPerStep) { numTicks += 1; // Collect all of the inputs for this tick. const tickInputs = []; while (inputIndex < inputs.length) { const input = inputs[inputIndex]; if (input.received > currentTs) { break; } inputIndex += 1; processedInputNumber = input.number; tickInputs.push(input); } // Feed the inputs to the game. for (const input of tickInputs) { let returnValue; try { const value = this.handleInput(currentTs, input.name, input.args); returnValue = { kind: 'ok' as const, value }; } catch (e: any) { console.error(`Input ${input._id} failed: ${e.message}`); returnValue = { kind: 'error' as const, message: e.message }; } completedInputs.push({ inputId: input._id, returnValue }); } // Simulate the game forward one tick. this.tick(currentTs); const candidateTs = currentTs + this.tickDuration; if (now < candidateTs) { break; } currentTs = candidateTs; } // Commit the step by moving time forward, consuming our inputs, and saving the game's state. const expectedGenerationNumber = this.engine.generationNumber; this.engine.currentTime = currentTs; this.engine.lastStepTs = lastStepTs; this.engine.generationNumber += 1; this.engine.processedInputNumber = processedInputNumber; const { _id, _creationTime, ...engine } = this.engine; const engineUpdate = { engine, completedInputs, expectedGenerationNumber }; await this.saveStep(ctx, engineUpdate); console.debug(`Simulated from ${startTs} to ${currentTs} (${currentTs - startTs}ms)`); } } const completedInput = v.object({ inputId: v.id('inputs'), returnValue: v.union( v.object({ kind: v.literal('ok'), value: v.any(), }), v.object({ kind: v.literal('error'), message: v.string(), }), ), }); export const engineUpdate = v.object({ engine, expectedGenerationNumber: v.number(), completedInputs: v.array(completedInput), }); export type EngineUpdate = Infer; export async function loadEngine( db: DatabaseReader, engineId: Id<'engines'>, generationNumber: number, ) { const engine = await db.get(engineId); if (!engine) { throw new Error(`No engine found with id ${engineId}`); } if (!engine.running) { throw new ConvexError({ kind: 'engineNotRunning', message: `Engine ${engineId} is not running`, }); } if (engine.generationNumber !== generationNumber) { throw new ConvexError({ kind: 'generationNumber', message: 'Generation number mismatch' }); } return engine; } export async function engineInsertInput( ctx: MutationCtx, engineId: Id<'engines'>, name: string, args: any, ): Promise> { const now = Date.now(); const prevInput = await ctx.db .query('inputs') .withIndex('byInputNumber', (q) => q.eq('engineId', engineId)) .order('desc') .first(); const number = prevInput ? prevInput.number + 1 : 0; const inputId = await ctx.db.insert('inputs', { engineId, number, name, args, received: now, }); return inputId; } export const loadInputs = internalQuery({ args: { engineId: v.id('engines'), processedInputNumber: v.optional(v.number()), max: v.number(), }, handler: async (ctx, args) => { return await ctx.db .query('inputs') .withIndex('byInputNumber', (q) => q.eq('engineId', args.engineId).gt('number', args.processedInputNumber ?? -1), ) .order('asc') .take(args.max); }, }); export async function applyEngineUpdate( ctx: MutationCtx, engineId: Id<'engines'>, update: EngineUpdate, ) { const engine = await loadEngine(ctx.db, engineId, update.expectedGenerationNumber); if ( engine.currentTime && update.engine.currentTime && update.engine.currentTime < engine.currentTime ) { throw new Error('Time moving backwards'); } await ctx.db.replace(engine._id, update.engine); for (const completedInput of update.completedInputs) { const input = await ctx.db.get(completedInput.inputId); if (!input) { throw new Error(`Input ${completedInput.inputId} not found`); } if (input.returnValue) { throw new Error(`Input ${completedInput.inputId} already completed`); } input.returnValue = completedInput.returnValue; await ctx.db.replace(input._id, input); } }