ai-town / patches /world.ts
github-actions[bot]
Sync to HuggingFace Spaces
30e8a79
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;
},
});