Spaces:
Sleeping
Sleeping
import { ConvexError, v } from 'convex/values'; | |
import { internalMutation, mutation, query } from './_generated/server'; | |
import { characters } from '../data/characters'; | |
import { insertInput } from './aiTown/insertInput'; | |
import { Descriptions } from '../data/characters'; | |
import { | |
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; | |
} | |
console.log("oauthToken",oauthToken) | |
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}`); | |
} | |
const playerIds = [...world.playersInit.values()].map(player => player.id) | |
const playerDescriptions = await ctx.db | |
.query('playerDescriptions') | |
.withIndex('worldId', (q) => q.eq('worldId', args.worldId)) | |
.collect(); | |
const namesInGame = playerDescriptions.map(description => { | |
if (playerIds.includes(description.playerId)) { | |
return description.name | |
} | |
}) | |
const availableDescriptions = Descriptions.filter( | |
description => !namesInGame.includes(description.name) | |
); | |
const randomCharacter = availableDescriptions[Math.floor(Math.random() * availableDescriptions.length)]; | |
// const { tokenIdentifier } = identity; | |
return await insertInput(ctx, world._id, 'join', { | |
name: randomCharacter.name, | |
character: randomCharacter.character, | |
description: randomCharacter.identity, | |
// description: `${identity.givenName} is a human player`, | |
tokenIdentifier: oauthToken, // TODO: change for multiplayer to oauth | |
// By default everybody is a villager | |
type: 'villager', | |
}); | |
}, | |
}); | |
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; | |
}, | |
}); | |