import { ConvexError, v } from 'convex/values'; import { internalMutation, mutation, query } from './_generated/server'; import { characters } from '../data/characters'; import { Descriptions } from '../data/characters'; import { insertInput } from './aiTown/insertInput'; import { DEFAULT_NAME, ENGINE_ACTION_DURATION, IDLE_WORLD_TIMEOUT, WORLD_HEARTBEAT_INTERVAL, } from './constants'; import { playerId } from './aiTown/ids'; import { kickEngine, startEngine, stopEngine } from './aiTown/main'; import { engineInsertInput } from './engine/abstractGame'; export const defaultWorldStatus = query({ handler: async (ctx) => { const worldStatus = await ctx.db .query('worldStatus') .filter((q) => q.eq(q.field('isDefault'), true)) .first(); return worldStatus; }, }); export const heartbeatWorld = mutation({ args: { worldId: v.id('worlds'), }, handler: async (ctx, args) => { const worldStatus = await ctx.db .query('worldStatus') .withIndex('worldId', (q) => q.eq('worldId', args.worldId)) .first(); if (!worldStatus) { throw new Error(`Invalid world ID: ${args.worldId}`); } const now = Date.now(); // Skip the update (and then potentially make the transaction readonly) // if it's been viewed sufficiently recently.. if (!worldStatus.lastViewed || worldStatus.lastViewed < now - WORLD_HEARTBEAT_INTERVAL / 2) { await ctx.db.patch(worldStatus._id, { lastViewed: Math.max(worldStatus.lastViewed ?? now, now), }); } // Restart inactive worlds, but leave worlds explicitly stopped by the developer alone. if (worldStatus.status === 'stoppedByDeveloper') { console.debug(`World ${worldStatus._id} is stopped by developer, not restarting.`); } if (worldStatus.status === 'inactive') { console.log(`Restarting inactive world ${worldStatus._id}...`); await ctx.db.patch(worldStatus._id, { status: 'running' }); await startEngine(ctx, worldStatus.worldId); } }, }); export const stopInactiveWorlds = internalMutation({ handler: async (ctx) => { const cutoff = Date.now() - IDLE_WORLD_TIMEOUT; const worlds = await ctx.db.query('worldStatus').collect(); for (const worldStatus of worlds) { if (cutoff < worldStatus.lastViewed || worldStatus.status !== 'running') { continue; } console.log(`Stopping inactive world ${worldStatus._id}`); await ctx.db.patch(worldStatus._id, { status: 'inactive' }); await stopEngine(ctx, worldStatus.worldId); } }, }); export const restartDeadWorlds = internalMutation({ handler: async (ctx) => { const now = Date.now(); // Restart an engine if it hasn't run for 2x its action duration. const engineTimeout = now - ENGINE_ACTION_DURATION * 2; const worlds = await ctx.db.query('worldStatus').collect(); for (const worldStatus of worlds) { if (worldStatus.status !== 'running') { continue; } const engine = await ctx.db.get(worldStatus.engineId); if (!engine) { throw new Error(`Invalid engine ID: ${worldStatus.engineId}`); } if (engine.currentTime && engine.currentTime < engineTimeout) { console.warn(`Restarting dead engine ${engine._id}...`); await kickEngine(ctx, worldStatus.worldId); } } }, }); export const userStatus = query({ args: { worldId: v.id('worlds'), oauthToken: v.optional(v.string()), }, handler: async (ctx, args) => { const { worldId, oauthToken } = args; if (!oauthToken) { return null; } return oauthToken; }, }); export const joinWorld = mutation({ args: { worldId: v.id('worlds'), oauthToken: v.optional(v.string()), }, handler: async (ctx, args) => { const { worldId, oauthToken } = args; if (!oauthToken) { throw new ConvexError(`Not logged in`); } // if (!identity) { // throw new ConvexError(`Not logged in`); // } // const name = // identity.givenName || identity.nickname || (identity.email && identity.email.split('@')[0]); const name = oauthToken; // if (!name) { // throw new ConvexError(`Missing name on ${JSON.stringify(identity)}`); // } const world = await ctx.db.get(args.worldId); if (!world) { throw new ConvexError(`Invalid world ID: ${args.worldId}`); } // Select a random character description const randomCharacter = Descriptions[Math.floor(Math.random() * Descriptions.length)]; return await insertInput(ctx, world._id, 'join', { name: oauthToken, character: randomCharacter.character, description: `${oauthToken} is a Human player !`, tokenIdentifier: oauthToken, }); }, }); export const leaveWorld = mutation({ args: { worldId: v.id('worlds'), oauthToken: v.optional(v.string()), }, handler: async (ctx, args) => { const { worldId, oauthToken } = args; if (!oauthToken) { throw new ConvexError(`Not logged in`); } const world = await ctx.db.get(args.worldId); if (!world) { throw new Error(`Invalid world ID: ${args.worldId}`); } // const existingPlayer = world.players.find((p) => p.human === tokenIdentifier); const existingPlayer = world.players.find((p) => p.human === oauthToken); if (!existingPlayer) { return; } await insertInput(ctx, world._id, 'leave', { playerId: existingPlayer.id, }); }, }); export const sendWorldInput = mutation({ args: { engineId: v.id('engines'), name: v.string(), args: v.any(), }, handler: async (ctx, args) => { // const identity = await ctx.auth.getUserIdentity(); // if (!identity) { // throw new Error(`Not logged in`); // } return await engineInsertInput(ctx, args.engineId, args.name as any, args.args); }, }); export const worldState = query({ args: { worldId: v.id('worlds'), }, handler: async (ctx, args) => { const world = await ctx.db.get(args.worldId); if (!world) { throw new Error(`Invalid world ID: ${args.worldId}`); } const worldStatus = await ctx.db .query('worldStatus') .withIndex('worldId', (q) => q.eq('worldId', world._id)) .unique(); if (!worldStatus) { throw new Error(`Invalid world status ID: ${world._id}`); } const engine = await ctx.db.get(worldStatus.engineId); if (!engine) { throw new Error(`Invalid engine ID: ${worldStatus.engineId}`); } return { world, engine }; }, }); export const gameDescriptions = query({ args: { worldId: v.id('worlds'), }, handler: async (ctx, args) => { const playerDescriptions = await ctx.db .query('playerDescriptions') .withIndex('worldId', (q) => q.eq('worldId', args.worldId)) .collect(); const agentDescriptions = await ctx.db .query('agentDescriptions') .withIndex('worldId', (q) => q.eq('worldId', args.worldId)) .collect(); const worldMap = await ctx.db .query('maps') .withIndex('worldId', (q) => q.eq('worldId', args.worldId)) .first(); if (!worldMap) { throw new Error(`No map for world: ${args.worldId}`); } return { worldMap, playerDescriptions, agentDescriptions }; }, }); export const previousConversation = query({ args: { worldId: v.id('worlds'), playerId, }, handler: async (ctx, args) => { // Walk the player's history in descending order, looking for a nonempty // conversation. const members = ctx.db .query('participatedTogether') .withIndex('playerHistory', (q) => q.eq('worldId', args.worldId).eq('player1', args.playerId)) .order('desc'); for await (const member of members) { const conversation = await ctx.db .query('archivedConversations') .withIndex('worldId', (q) => q.eq('worldId', args.worldId).eq('id', member.conversationId)) .unique(); if (!conversation) { throw new Error(`Invalid conversation ID: ${member.conversationId}`); } if (conversation.numMessages > 0) { return conversation; } } return null; }, });