Matou-Garou / convex /engine /abstractGame.ts
Jofthomas's picture
Jofthomas HF staff
bulk
ce8b18b
raw
history blame
5.96 kB
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);
}
}