Spaces:
Sleeping
Sleeping
| 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<void>; | |
| 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<typeof engineUpdate>; | |
| 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<Id<'inputs'>> { | |
| 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); | |
| } | |
| } | |