Spaces:
Sleeping
Sleeping
push bulk 1
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- Dockerfile +10 -5
- README.md +2 -1
- README.md.yml +8 -0
- map.png +0 -0
- patches/PixiStaticMap.tsx +0 -128
- patches/assets/GrayCat.png +0 -0
- patches/assets/OrangeCat.png +0 -0
- patches/assets/hf.svg +18 -0
- patches/assets/map.png +0 -0
- patches/assets/map_night.png +0 -0
- patches/characters.ts +26 -14
- patches/convex/agent/conversation.ts +345 -0
- patches/convex/agent/embeddingsCache.ts +110 -0
- patches/convex/agent/memory.ts +450 -0
- patches/convex/agent/schema.ts +53 -0
- patches/convex/aiTown/agent.ts +368 -0
- patches/convex/aiTown/agentDescription.ts +27 -0
- patches/convex/aiTown/agentInputs.ts +155 -0
- patches/convex/aiTown/agentOperations.ts +182 -0
- patches/convex/aiTown/conversation.ts +395 -0
- patches/convex/aiTown/conversationMembership.ts +38 -0
- patches/convex/aiTown/dayNightCycle.ts +71 -0
- patches/convex/aiTown/game.ts +374 -0
- patches/convex/aiTown/ids.ts +32 -0
- patches/convex/aiTown/inputHandler.ts +9 -0
- patches/convex/aiTown/inputs.ts +25 -0
- patches/convex/aiTown/insertInput.ts +20 -0
- patches/convex/aiTown/location.ts +32 -0
- patches/convex/aiTown/main.ts +154 -0
- patches/convex/aiTown/movement.ts +189 -0
- patches/convex/aiTown/player.ts +310 -0
- patches/convex/aiTown/playerDescription.ts +35 -0
- patches/convex/aiTown/schema.ts +79 -0
- patches/convex/aiTown/world.ts +70 -0
- patches/convex/aiTown/worldMap.ts +94 -0
- patches/{constants.ts → convex/constants.ts} +81 -78
- patches/convex/crons.ts +89 -0
- patches/convex/engine/abstractGame.ts +199 -0
- patches/convex/engine/historicalObject.test.ts +47 -0
- patches/convex/engine/historicalObject.ts +355 -0
- patches/convex/engine/schema.ts +56 -0
- patches/convex/http.ts +10 -0
- patches/convex/init.ts +125 -0
- patches/convex/messages.ts +53 -0
- patches/{music.ts → convex/music.ts} +135 -135
- patches/convex/schema.ts +27 -0
- patches/convex/testing.ts +202 -0
- patches/convex/util/FastIntegerCompression.ts +221 -0
- patches/convex/util/assertNever.ts +4 -0
- patches/convex/util/asyncMap.test.ts +15 -0
Dockerfile
CHANGED
@@ -24,16 +24,21 @@ RUN git clone https://github.com/a16z-infra/ai-town.git . && \
|
|
24 |
RUN npm install --include=dev @huggingface/inference
|
25 |
RUN npm install --include=dev @huggingface/hub
|
26 |
|
|
|
27 |
RUN curl -L -O https://github.com/get-convex/convex-backend/releases/download/precompiled-2024-05-07-13337fd/convex-local-backend-x86_64-unknown-linux-gnu.zip && \
|
28 |
unzip convex-local-backend-x86_64-unknown-linux-gnu.zip
|
29 |
|
30 |
-
COPY ./patches
|
31 |
COPY ./patches/vite.config.ts ./
|
32 |
-
COPY ./patches/constants.ts ./patches/music.ts ./convex/
|
33 |
COPY ./patches/characters.ts ./patches/gentle.js ./data/
|
34 |
-
COPY ./patches/PixiStaticMap.tsx ./src/components/
|
35 |
-
COPY ./patches/Button.tsx ./src/components/buttons/Button.tsx
|
36 |
-
COPY ./patches/App.tsx ./src/App.tsx
|
37 |
COPY ./patches/run.sh ./
|
38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
39 |
CMD ["./run.sh"]
|
|
|
24 |
RUN npm install --include=dev @huggingface/inference
|
25 |
RUN npm install --include=dev @huggingface/hub
|
26 |
|
27 |
+
|
28 |
RUN curl -L -O https://github.com/get-convex/convex-backend/releases/download/precompiled-2024-05-07-13337fd/convex-local-backend-x86_64-unknown-linux-gnu.zip && \
|
29 |
unzip convex-local-backend-x86_64-unknown-linux-gnu.zip
|
30 |
|
31 |
+
COPY ./patches ./
|
32 |
COPY ./patches/vite.config.ts ./
|
|
|
33 |
COPY ./patches/characters.ts ./patches/gentle.js ./data/
|
|
|
|
|
|
|
34 |
COPY ./patches/run.sh ./
|
35 |
|
36 |
+
COPY ./map.png ./assets/map.png
|
37 |
+
COPY ./patches/assets/GrayCat.png ./assets/GrayCat.png
|
38 |
+
COPY ./patches/assets/OrangeCat.png ./assets/OrangeCat.png
|
39 |
+
COPY ./patches/assets/hf.svg ./assets/hf.svg
|
40 |
+
COPY ./patches/data/spritesheets/c1.ts ./data/spritesheets/c1.ts
|
41 |
+
|
42 |
+
|
43 |
+
|
44 |
CMD ["./run.sh"]
|
README.md
CHANGED
@@ -1,6 +1,6 @@
|
|
1 |
---
|
2 |
title: AI Town on HuggingFace
|
3 |
-
emoji:
|
4 |
colorFrom: green
|
5 |
colorTo: red
|
6 |
sdk: docker
|
@@ -9,6 +9,7 @@ pinned: false
|
|
9 |
disable_embedding: true
|
10 |
# header: mini
|
11 |
short_description: AI Town on HuggingFace
|
|
|
12 |
---
|
13 |
|
14 |
# AI Town 🏠💻💌 on Hugging Face 🤗
|
|
|
1 |
---
|
2 |
title: AI Town on HuggingFace
|
3 |
+
emoji: 🏠🐈⬛
|
4 |
colorFrom: green
|
5 |
colorTo: red
|
6 |
sdk: docker
|
|
|
9 |
disable_embedding: true
|
10 |
# header: mini
|
11 |
short_description: AI Town on HuggingFace
|
12 |
+
hf_oauth: true
|
13 |
---
|
14 |
|
15 |
# AI Town 🏠💻💌 on Hugging Face 🤗
|
README.md.yml
CHANGED
@@ -8,3 +8,11 @@ pinned: false
|
|
8 |
disable_embedding: true
|
9 |
# header: mini
|
10 |
short_description: AI Town on HuggingFace
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
8 |
disable_embedding: true
|
9 |
# header: mini
|
10 |
short_description: AI Town on HuggingFace
|
11 |
+
hf_oauth: true
|
12 |
+
# optional, default duration is 8 hours/480 minutes. Max duration is 30 days/43200 minutes.
|
13 |
+
hf_oauth_expiration_minutes: 480
|
14 |
+
# optional, see "Scopes" below. "openid profile" is always included.
|
15 |
+
hf_oauth_scopes:
|
16 |
+
- read-repos
|
17 |
+
- manage-repos
|
18 |
+
- inference-api
|
map.png
ADDED
patches/PixiStaticMap.tsx
DELETED
@@ -1,128 +0,0 @@
|
|
1 |
-
import { PixiComponent, applyDefaultProps } from '@pixi/react';
|
2 |
-
import * as PIXI from 'pixi.js';
|
3 |
-
import { AnimatedSprite, WorldMap } from '../../convex/aiTown/worldMap';
|
4 |
-
import * as campfire from '../../data/animations/campfire.json';
|
5 |
-
import * as gentlesparkle from '../../data/animations/gentlesparkle.json';
|
6 |
-
import * as gentlewaterfall from '../../data/animations/gentlewaterfall.json';
|
7 |
-
import * as gentlesplash from '../../data/animations/gentlesplash.json';
|
8 |
-
import * as windmill from '../../data/animations/windmill.json';
|
9 |
-
|
10 |
-
const animations = {
|
11 |
-
'campfire.json': { spritesheet: campfire, url: '/assets/spritesheets/campfire.png' },
|
12 |
-
'gentlesparkle.json': {
|
13 |
-
spritesheet: gentlesparkle,
|
14 |
-
url: '/assets/spritesheets/gentlesparkle32.png',
|
15 |
-
},
|
16 |
-
'gentlewaterfall.json': {
|
17 |
-
spritesheet: gentlewaterfall,
|
18 |
-
url: '/assets/spritesheets/gentlewaterfall32.png',
|
19 |
-
},
|
20 |
-
'windmill.json': { spritesheet: windmill, url: '/assets/spritesheets/windmill.png' },
|
21 |
-
'gentlesplash.json': {
|
22 |
-
spritesheet: gentlesplash,
|
23 |
-
url: '/assets/spritesheets/gentlewaterfall32.png',
|
24 |
-
},
|
25 |
-
};
|
26 |
-
|
27 |
-
export const PixiStaticMap = PixiComponent('StaticMap', {
|
28 |
-
create: (props: { map: WorldMap; [k: string]: any }) => {
|
29 |
-
const map = props.map;
|
30 |
-
const numxtiles = Math.floor(map.tileSetDimX / map.tileDim);
|
31 |
-
const numytiles = Math.floor(map.tileSetDimY / map.tileDim);
|
32 |
-
const bt = PIXI.BaseTexture.from(map.tileSetUrl, {
|
33 |
-
scaleMode: PIXI.SCALE_MODES.NEAREST,
|
34 |
-
});
|
35 |
-
|
36 |
-
const tiles = [];
|
37 |
-
for (let x = 0; x < numxtiles; x++) {
|
38 |
-
for (let y = 0; y < numytiles; y++) {
|
39 |
-
tiles[x + y * numxtiles] = new PIXI.Texture(
|
40 |
-
bt,
|
41 |
-
new PIXI.Rectangle(x * map.tileDim, y * map.tileDim, map.tileDim, map.tileDim),
|
42 |
-
);
|
43 |
-
}
|
44 |
-
}
|
45 |
-
const screenxtiles = map.bgTiles[0].length;
|
46 |
-
const screenytiles = map.bgTiles[0][0].length;
|
47 |
-
|
48 |
-
const container = new PIXI.Container();
|
49 |
-
const allLayers = [...map.bgTiles, ...map.objectTiles];
|
50 |
-
|
51 |
-
// blit bg & object layers of map onto canvas
|
52 |
-
for (let i = 0; i < screenxtiles * screenytiles; i++) {
|
53 |
-
const x = i % screenxtiles;
|
54 |
-
const y = Math.floor(i / screenxtiles);
|
55 |
-
const xPx = x * map.tileDim;
|
56 |
-
const yPx = y * map.tileDim;
|
57 |
-
|
58 |
-
// Add all layers of backgrounds.
|
59 |
-
for (const layer of allLayers) {
|
60 |
-
const tileIndex = layer[x][y];
|
61 |
-
// Some layers may not have tiles at this location.
|
62 |
-
if (tileIndex === -1) continue;
|
63 |
-
const ctile = new PIXI.Sprite(tiles[tileIndex]);
|
64 |
-
ctile.x = xPx;
|
65 |
-
ctile.y = yPx;
|
66 |
-
container.addChild(ctile);
|
67 |
-
}
|
68 |
-
}
|
69 |
-
|
70 |
-
// TODO: Add layers.
|
71 |
-
const spritesBySheet = new Map<string, AnimatedSprite[]>();
|
72 |
-
for (const sprite of map.animatedSprites) {
|
73 |
-
const sheet = sprite.sheet;
|
74 |
-
if (!spritesBySheet.has(sheet)) {
|
75 |
-
spritesBySheet.set(sheet, []);
|
76 |
-
}
|
77 |
-
spritesBySheet.get(sheet)!.push(sprite);
|
78 |
-
}
|
79 |
-
for (const [sheet, sprites] of spritesBySheet.entries()) {
|
80 |
-
const animation = (animations as any)[sheet];
|
81 |
-
if (!animation) {
|
82 |
-
console.error('Could not find animation', sheet);
|
83 |
-
continue;
|
84 |
-
}
|
85 |
-
const { spritesheet, url } = animation;
|
86 |
-
const texture = PIXI.BaseTexture.from(url, {
|
87 |
-
scaleMode: PIXI.SCALE_MODES.NEAREST,
|
88 |
-
});
|
89 |
-
const spriteSheet = new PIXI.Spritesheet(texture, spritesheet);
|
90 |
-
spriteSheet.parse().then(() => {
|
91 |
-
for (const sprite of sprites) {
|
92 |
-
const pixiAnimation = spriteSheet.animations[sprite.animation];
|
93 |
-
if (!pixiAnimation) {
|
94 |
-
console.error('Failed to load animation', sprite);
|
95 |
-
continue;
|
96 |
-
}
|
97 |
-
const pixiSprite = new PIXI.AnimatedSprite(pixiAnimation);
|
98 |
-
pixiSprite.animationSpeed = 0.1;
|
99 |
-
pixiSprite.autoUpdate = true;
|
100 |
-
pixiSprite.x = sprite.x;
|
101 |
-
pixiSprite.y = sprite.y;
|
102 |
-
pixiSprite.width = sprite.w;
|
103 |
-
pixiSprite.height = sprite.h;
|
104 |
-
container.addChild(pixiSprite);
|
105 |
-
pixiSprite.play();
|
106 |
-
}
|
107 |
-
});
|
108 |
-
}
|
109 |
-
|
110 |
-
container.x = 0;
|
111 |
-
container.y = 0;
|
112 |
-
|
113 |
-
// Set the hit area manually to ensure `pointerdown` events are delivered to this container.
|
114 |
-
container.interactive = true;
|
115 |
-
container.hitArea = new PIXI.Rectangle(
|
116 |
-
0,
|
117 |
-
0,
|
118 |
-
screenxtiles * map.tileDim,
|
119 |
-
screenytiles * map.tileDim,
|
120 |
-
);
|
121 |
-
|
122 |
-
return container;
|
123 |
-
},
|
124 |
-
|
125 |
-
applyProps: (instance, oldProps, newProps) => {
|
126 |
-
applyDefaultProps(instance, oldProps, newProps);
|
127 |
-
},
|
128 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
patches/assets/GrayCat.png
ADDED
patches/assets/OrangeCat.png
ADDED
patches/assets/hf.svg
ADDED
patches/assets/map.png
ADDED
patches/assets/map_night.png
ADDED
patches/characters.ts
CHANGED
@@ -6,7 +6,7 @@ import { data as f5SpritesheetData } from './spritesheets/f5';
|
|
6 |
import { data as f6SpritesheetData } from './spritesheets/f6';
|
7 |
import { data as f7SpritesheetData } from './spritesheets/f7';
|
8 |
import { data as f8SpritesheetData } from './spritesheets/f8';
|
9 |
-
|
10 |
export const Descriptions = [
|
11 |
// {
|
12 |
// name: 'Alex',
|
@@ -19,7 +19,7 @@ export const Descriptions = [
|
|
19 |
// },
|
20 |
{
|
21 |
name: 'Lucky',
|
22 |
-
character: '
|
23 |
identity: `Lucky is always happy and curious, and he loves cheese. He spends
|
24 |
most of his time reading about the history of science and traveling
|
25 |
through the galaxy on whatever ship will take him. He's very articulate and
|
@@ -30,7 +30,7 @@ export const Descriptions = [
|
|
30 |
},
|
31 |
{
|
32 |
name: 'Bob',
|
33 |
-
character: '
|
34 |
identity: `Bob is always grumpy and he loves trees. He spends
|
35 |
most of his time gardening by himself. When spoken to he'll respond but try
|
36 |
and get out of the conversation as quickly as possible. Secretly he resents
|
@@ -39,7 +39,7 @@ export const Descriptions = [
|
|
39 |
},
|
40 |
{
|
41 |
name: 'Stella',
|
42 |
-
character: '
|
43 |
identity: `Stella can never be trusted. she tries to trick people all the time. normally
|
44 |
into giving her money, or doing things that will make her money. she's incredibly charming
|
45 |
and not afraid to use her charm. she's a sociopath who has no empathy. but hides it well.`,
|
@@ -55,7 +55,7 @@ export const Descriptions = [
|
|
55 |
// },
|
56 |
{
|
57 |
name: 'Alice',
|
58 |
-
character: '
|
59 |
identity: `Alice is a famous scientist. She is smarter than everyone else and has
|
60 |
discovered mysteries of the universe no one else can understand. As a result she often
|
61 |
speaks in oblique riddles. She comes across as confused and forgetful.`,
|
@@ -85,51 +85,63 @@ export const characters = [
|
|
85 |
name: 'f1',
|
86 |
textureUrl: '/assets/32x32folk.png',
|
87 |
spritesheetData: f1SpritesheetData,
|
88 |
-
speed: 0.
|
89 |
},
|
90 |
{
|
91 |
name: 'f2',
|
92 |
textureUrl: '/assets/32x32folk.png',
|
93 |
spritesheetData: f2SpritesheetData,
|
94 |
-
speed: 0.
|
95 |
},
|
96 |
{
|
97 |
name: 'f3',
|
98 |
textureUrl: '/assets/32x32folk.png',
|
99 |
spritesheetData: f3SpritesheetData,
|
100 |
-
speed: 0.
|
101 |
},
|
102 |
{
|
103 |
name: 'f4',
|
104 |
textureUrl: '/assets/32x32folk.png',
|
105 |
spritesheetData: f4SpritesheetData,
|
106 |
-
speed: 0.
|
107 |
},
|
108 |
{
|
109 |
name: 'f5',
|
110 |
textureUrl: '/assets/32x32folk.png',
|
111 |
spritesheetData: f5SpritesheetData,
|
112 |
-
speed: 0.
|
113 |
},
|
114 |
{
|
115 |
name: 'f6',
|
116 |
textureUrl: '/assets/32x32folk.png',
|
117 |
spritesheetData: f6SpritesheetData,
|
118 |
-
speed: 0.
|
119 |
},
|
120 |
{
|
121 |
name: 'f7',
|
122 |
textureUrl: '/assets/32x32folk.png',
|
123 |
spritesheetData: f7SpritesheetData,
|
124 |
-
speed: 0.
|
125 |
},
|
126 |
{
|
127 |
name: 'f8',
|
128 |
textureUrl: '/assets/32x32folk.png',
|
129 |
spritesheetData: f8SpritesheetData,
|
130 |
-
speed: 0.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
131 |
},
|
132 |
];
|
133 |
|
134 |
// Characters move at 0.75 tiles per second.
|
135 |
-
export const movementSpeed =
|
|
|
6 |
import { data as f6SpritesheetData } from './spritesheets/f6';
|
7 |
import { data as f7SpritesheetData } from './spritesheets/f7';
|
8 |
import { data as f8SpritesheetData } from './spritesheets/f8';
|
9 |
+
import { data as c1SpritesheetData } from './spritesheets/c1';
|
10 |
export const Descriptions = [
|
11 |
// {
|
12 |
// name: 'Alex',
|
|
|
19 |
// },
|
20 |
{
|
21 |
name: 'Lucky',
|
22 |
+
character: 'c1',
|
23 |
identity: `Lucky is always happy and curious, and he loves cheese. He spends
|
24 |
most of his time reading about the history of science and traveling
|
25 |
through the galaxy on whatever ship will take him. He's very articulate and
|
|
|
30 |
},
|
31 |
{
|
32 |
name: 'Bob',
|
33 |
+
character: 'c2',
|
34 |
identity: `Bob is always grumpy and he loves trees. He spends
|
35 |
most of his time gardening by himself. When spoken to he'll respond but try
|
36 |
and get out of the conversation as quickly as possible. Secretly he resents
|
|
|
39 |
},
|
40 |
{
|
41 |
name: 'Stella',
|
42 |
+
character: 'c1',
|
43 |
identity: `Stella can never be trusted. she tries to trick people all the time. normally
|
44 |
into giving her money, or doing things that will make her money. she's incredibly charming
|
45 |
and not afraid to use her charm. she's a sociopath who has no empathy. but hides it well.`,
|
|
|
55 |
// },
|
56 |
{
|
57 |
name: 'Alice',
|
58 |
+
character: 'c2',
|
59 |
identity: `Alice is a famous scientist. She is smarter than everyone else and has
|
60 |
discovered mysteries of the universe no one else can understand. As a result she often
|
61 |
speaks in oblique riddles. She comes across as confused and forgetful.`,
|
|
|
85 |
name: 'f1',
|
86 |
textureUrl: '/assets/32x32folk.png',
|
87 |
spritesheetData: f1SpritesheetData,
|
88 |
+
speed: 0.19,
|
89 |
},
|
90 |
{
|
91 |
name: 'f2',
|
92 |
textureUrl: '/assets/32x32folk.png',
|
93 |
spritesheetData: f2SpritesheetData,
|
94 |
+
speed: 0.19,
|
95 |
},
|
96 |
{
|
97 |
name: 'f3',
|
98 |
textureUrl: '/assets/32x32folk.png',
|
99 |
spritesheetData: f3SpritesheetData,
|
100 |
+
speed: 0.19,
|
101 |
},
|
102 |
{
|
103 |
name: 'f4',
|
104 |
textureUrl: '/assets/32x32folk.png',
|
105 |
spritesheetData: f4SpritesheetData,
|
106 |
+
speed: 0.19,
|
107 |
},
|
108 |
{
|
109 |
name: 'f5',
|
110 |
textureUrl: '/assets/32x32folk.png',
|
111 |
spritesheetData: f5SpritesheetData,
|
112 |
+
speed: 0.19,
|
113 |
},
|
114 |
{
|
115 |
name: 'f6',
|
116 |
textureUrl: '/assets/32x32folk.png',
|
117 |
spritesheetData: f6SpritesheetData,
|
118 |
+
speed: 0.19,
|
119 |
},
|
120 |
{
|
121 |
name: 'f7',
|
122 |
textureUrl: '/assets/32x32folk.png',
|
123 |
spritesheetData: f7SpritesheetData,
|
124 |
+
speed: 0.19,
|
125 |
},
|
126 |
{
|
127 |
name: 'f8',
|
128 |
textureUrl: '/assets/32x32folk.png',
|
129 |
spritesheetData: f8SpritesheetData,
|
130 |
+
speed: 0.19,
|
131 |
+
},
|
132 |
+
{
|
133 |
+
name: 'c1',
|
134 |
+
textureUrl: '/assets/GrayCat.png',
|
135 |
+
spritesheetData: c1SpritesheetData,
|
136 |
+
speed: 0.19,
|
137 |
+
},
|
138 |
+
{
|
139 |
+
name: 'c2',
|
140 |
+
textureUrl: '/assets/OrangeCat.png',
|
141 |
+
spritesheetData: c1SpritesheetData,
|
142 |
+
speed: 0.19,
|
143 |
},
|
144 |
];
|
145 |
|
146 |
// Characters move at 0.75 tiles per second.
|
147 |
+
export const movementSpeed = 2;
|
patches/convex/agent/conversation.ts
ADDED
@@ -0,0 +1,345 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { v } from 'convex/values';
|
2 |
+
import { Id } from '../_generated/dataModel';
|
3 |
+
import { ActionCtx, internalQuery } from '../_generated/server';
|
4 |
+
import { LLMMessage, chatCompletion } from '../util/llm';
|
5 |
+
import * as memory from './memory';
|
6 |
+
import { api, internal } from '../_generated/api';
|
7 |
+
import * as embeddingsCache from './embeddingsCache';
|
8 |
+
import { GameId, conversationId, playerId } from '../aiTown/ids';
|
9 |
+
import { NUM_MEMORIES_TO_SEARCH } from '../constants';
|
10 |
+
|
11 |
+
const selfInternal = internal.agent.conversation;
|
12 |
+
|
13 |
+
export async function startConversationMessage(
|
14 |
+
ctx: ActionCtx,
|
15 |
+
worldId: Id<'worlds'>,
|
16 |
+
conversationId: GameId<'conversations'>,
|
17 |
+
playerId: GameId<'players'>,
|
18 |
+
otherPlayerId: GameId<'players'>,
|
19 |
+
) {
|
20 |
+
const { player, otherPlayer, agent, otherAgent, lastConversation } = await ctx.runQuery(
|
21 |
+
selfInternal.queryPromptData,
|
22 |
+
{
|
23 |
+
worldId,
|
24 |
+
playerId,
|
25 |
+
otherPlayerId,
|
26 |
+
conversationId,
|
27 |
+
},
|
28 |
+
);
|
29 |
+
const embedding = await embeddingsCache.fetch(
|
30 |
+
ctx,
|
31 |
+
`${player.name} is talking to ${otherPlayer.name}`,
|
32 |
+
);
|
33 |
+
|
34 |
+
const memories = await memory.searchMemories(
|
35 |
+
ctx,
|
36 |
+
player.id as GameId<'players'>,
|
37 |
+
embedding,
|
38 |
+
Number(process.env.NUM_MEMORIES_TO_SEARCH) || NUM_MEMORIES_TO_SEARCH,
|
39 |
+
);
|
40 |
+
|
41 |
+
const memoryWithOtherPlayer = memories.find(
|
42 |
+
(m) => m.data.type === 'conversation' && m.data.playerIds.includes(otherPlayerId),
|
43 |
+
);
|
44 |
+
const prompt = [
|
45 |
+
`You are ${player.name}, and you just started a conversation with ${otherPlayer.name}.`,
|
46 |
+
];
|
47 |
+
prompt.push(...agentPrompts(otherPlayer, agent, otherAgent ?? null));
|
48 |
+
prompt.push(...previousConversationPrompt(otherPlayer, lastConversation));
|
49 |
+
prompt.push(...relatedMemoriesPrompt(memories));
|
50 |
+
if (memoryWithOtherPlayer) {
|
51 |
+
prompt.push(
|
52 |
+
`Be sure to include some detail or question about a previous conversation in your greeting.`,
|
53 |
+
);
|
54 |
+
}
|
55 |
+
prompt.push(`${player.name}:`);
|
56 |
+
|
57 |
+
const { content } = await chatCompletion({
|
58 |
+
messages: [
|
59 |
+
{
|
60 |
+
role: 'user',
|
61 |
+
content: prompt.join('\n'),
|
62 |
+
},
|
63 |
+
],
|
64 |
+
max_tokens: 300,
|
65 |
+
stream: true,
|
66 |
+
stop: stopWords(otherPlayer.name, player.name),
|
67 |
+
});
|
68 |
+
return content;
|
69 |
+
}
|
70 |
+
|
71 |
+
export async function continueConversationMessage(
|
72 |
+
ctx: ActionCtx,
|
73 |
+
worldId: Id<'worlds'>,
|
74 |
+
conversationId: GameId<'conversations'>,
|
75 |
+
playerId: GameId<'players'>,
|
76 |
+
otherPlayerId: GameId<'players'>,
|
77 |
+
) {
|
78 |
+
const { player, otherPlayer, conversation, agent, otherAgent } = await ctx.runQuery(
|
79 |
+
selfInternal.queryPromptData,
|
80 |
+
{
|
81 |
+
worldId,
|
82 |
+
playerId,
|
83 |
+
otherPlayerId,
|
84 |
+
conversationId,
|
85 |
+
},
|
86 |
+
);
|
87 |
+
const now = Date.now();
|
88 |
+
const started = new Date(conversation.created);
|
89 |
+
const embedding = await embeddingsCache.fetch(
|
90 |
+
ctx,
|
91 |
+
`What do you think about ${otherPlayer.name}?`,
|
92 |
+
);
|
93 |
+
const memories = await memory.searchMemories(ctx, player.id as GameId<'players'>, embedding, 3);
|
94 |
+
const prompt = [
|
95 |
+
`You are ${player.name}, and you're currently in a conversation with ${otherPlayer.name}.`,
|
96 |
+
`The conversation started at ${started.toLocaleString()}. It's now ${now.toLocaleString()}.`,
|
97 |
+
];
|
98 |
+
prompt.push(...agentPrompts(otherPlayer, agent, otherAgent ?? null));
|
99 |
+
prompt.push(...relatedMemoriesPrompt(memories));
|
100 |
+
prompt.push(
|
101 |
+
`Below is the current chat history between you and ${otherPlayer.name}.`,
|
102 |
+
`DO NOT greet them again. Do NOT use the word "Hey" too often. Your response should be brief and within 200 characters.`,
|
103 |
+
);
|
104 |
+
|
105 |
+
const llmMessages: LLMMessage[] = [
|
106 |
+
{
|
107 |
+
role: 'user',
|
108 |
+
content: prompt.join('\n'),
|
109 |
+
},
|
110 |
+
...(await previousMessages(
|
111 |
+
ctx,
|
112 |
+
worldId,
|
113 |
+
player,
|
114 |
+
otherPlayer,
|
115 |
+
conversation.id as GameId<'conversations'>,
|
116 |
+
)),
|
117 |
+
];
|
118 |
+
llmMessages.push({ role: 'user', content: `${player.name}:` });
|
119 |
+
|
120 |
+
const { content } = await chatCompletion({
|
121 |
+
messages: llmMessages,
|
122 |
+
max_tokens: 300,
|
123 |
+
stream: true,
|
124 |
+
stop: stopWords(otherPlayer.name, player.name),
|
125 |
+
});
|
126 |
+
return content;
|
127 |
+
}
|
128 |
+
|
129 |
+
export async function leaveConversationMessage(
|
130 |
+
ctx: ActionCtx,
|
131 |
+
worldId: Id<'worlds'>,
|
132 |
+
conversationId: GameId<'conversations'>,
|
133 |
+
playerId: GameId<'players'>,
|
134 |
+
otherPlayerId: GameId<'players'>,
|
135 |
+
) {
|
136 |
+
const { player, otherPlayer, conversation, agent, otherAgent } = await ctx.runQuery(
|
137 |
+
selfInternal.queryPromptData,
|
138 |
+
{
|
139 |
+
worldId,
|
140 |
+
playerId,
|
141 |
+
otherPlayerId,
|
142 |
+
conversationId,
|
143 |
+
},
|
144 |
+
);
|
145 |
+
const prompt = [
|
146 |
+
`You are ${player.name}, and you're currently in a conversation with ${otherPlayer.name}.`,
|
147 |
+
`You've decided to leave the question and would like to politely tell them you're leaving the conversation.`,
|
148 |
+
];
|
149 |
+
prompt.push(...agentPrompts(otherPlayer, agent, otherAgent ?? null));
|
150 |
+
prompt.push(
|
151 |
+
`Below is the current chat history between you and ${otherPlayer.name}.`,
|
152 |
+
`How would you like to tell them that you're leaving? Your response should be brief and within 200 characters.`,
|
153 |
+
);
|
154 |
+
const llmMessages: LLMMessage[] = [
|
155 |
+
{
|
156 |
+
role: 'user',
|
157 |
+
content: prompt.join('\n'),
|
158 |
+
},
|
159 |
+
...(await previousMessages(
|
160 |
+
ctx,
|
161 |
+
worldId,
|
162 |
+
player,
|
163 |
+
otherPlayer,
|
164 |
+
conversation.id as GameId<'conversations'>,
|
165 |
+
)),
|
166 |
+
];
|
167 |
+
llmMessages.push({ role: 'user', content: `${player.name}:` });
|
168 |
+
|
169 |
+
const { content } = await chatCompletion({
|
170 |
+
messages: llmMessages,
|
171 |
+
max_tokens: 300,
|
172 |
+
stream: true,
|
173 |
+
stop: stopWords(otherPlayer.name, player.name),
|
174 |
+
});
|
175 |
+
return content;
|
176 |
+
}
|
177 |
+
|
178 |
+
function agentPrompts(
|
179 |
+
otherPlayer: { name: string },
|
180 |
+
agent: { identity: string; plan: string } | null,
|
181 |
+
otherAgent: { identity: string; plan: string } | null,
|
182 |
+
): string[] {
|
183 |
+
const prompt = [];
|
184 |
+
if (agent) {
|
185 |
+
prompt.push(`About you: ${agent.identity}`);
|
186 |
+
prompt.push(`Your goals for the conversation: ${agent.plan}`);
|
187 |
+
}
|
188 |
+
if (otherAgent) {
|
189 |
+
prompt.push(`About ${otherPlayer.name}: ${otherAgent.identity}`);
|
190 |
+
}
|
191 |
+
return prompt;
|
192 |
+
}
|
193 |
+
|
194 |
+
function previousConversationPrompt(
|
195 |
+
otherPlayer: { name: string },
|
196 |
+
conversation: { created: number } | null,
|
197 |
+
): string[] {
|
198 |
+
const prompt = [];
|
199 |
+
if (conversation) {
|
200 |
+
const prev = new Date(conversation.created);
|
201 |
+
const now = new Date();
|
202 |
+
prompt.push(
|
203 |
+
`Last time you chatted with ${
|
204 |
+
otherPlayer.name
|
205 |
+
} it was ${prev.toLocaleString()}. It's now ${now.toLocaleString()}.`,
|
206 |
+
);
|
207 |
+
}
|
208 |
+
return prompt;
|
209 |
+
}
|
210 |
+
|
211 |
+
function relatedMemoriesPrompt(memories: memory.Memory[]): string[] {
|
212 |
+
const prompt = [];
|
213 |
+
if (memories.length > 0) {
|
214 |
+
prompt.push(`Here are some related memories in decreasing relevance order:`);
|
215 |
+
for (const memory of memories) {
|
216 |
+
prompt.push(' - ' + memory.description);
|
217 |
+
}
|
218 |
+
}
|
219 |
+
return prompt;
|
220 |
+
}
|
221 |
+
|
222 |
+
async function previousMessages(
|
223 |
+
ctx: ActionCtx,
|
224 |
+
worldId: Id<'worlds'>,
|
225 |
+
player: { id: string; name: string },
|
226 |
+
otherPlayer: { id: string; name: string },
|
227 |
+
conversationId: GameId<'conversations'>,
|
228 |
+
) {
|
229 |
+
const llmMessages: LLMMessage[] = [];
|
230 |
+
const prevMessages = await ctx.runQuery(api.messages.listMessages, { worldId, conversationId });
|
231 |
+
for (const message of prevMessages) {
|
232 |
+
const author = message.author === player.id ? player : otherPlayer;
|
233 |
+
const recipient = message.author === player.id ? otherPlayer : player;
|
234 |
+
llmMessages.push({
|
235 |
+
role: 'user',
|
236 |
+
content: `${author.name} to ${recipient.name}: ${message.text}`,
|
237 |
+
});
|
238 |
+
}
|
239 |
+
return llmMessages;
|
240 |
+
}
|
241 |
+
|
242 |
+
export const queryPromptData = internalQuery({
|
243 |
+
args: {
|
244 |
+
worldId: v.id('worlds'),
|
245 |
+
playerId,
|
246 |
+
otherPlayerId: playerId,
|
247 |
+
conversationId,
|
248 |
+
},
|
249 |
+
handler: async (ctx, args) => {
|
250 |
+
const world = await ctx.db.get(args.worldId);
|
251 |
+
if (!world) {
|
252 |
+
throw new Error(`World ${args.worldId} not found`);
|
253 |
+
}
|
254 |
+
const player = world.players.find((p) => p.id === args.playerId);
|
255 |
+
if (!player) {
|
256 |
+
throw new Error(`Player ${args.playerId} not found`);
|
257 |
+
}
|
258 |
+
const playerDescription = await ctx.db
|
259 |
+
.query('playerDescriptions')
|
260 |
+
.withIndex('worldId', (q) => q.eq('worldId', args.worldId).eq('playerId', args.playerId))
|
261 |
+
.first();
|
262 |
+
if (!playerDescription) {
|
263 |
+
throw new Error(`Player description for ${args.playerId} not found`);
|
264 |
+
}
|
265 |
+
const otherPlayer = world.players.find((p) => p.id === args.otherPlayerId);
|
266 |
+
if (!otherPlayer) {
|
267 |
+
throw new Error(`Player ${args.otherPlayerId} not found`);
|
268 |
+
}
|
269 |
+
const otherPlayerDescription = await ctx.db
|
270 |
+
.query('playerDescriptions')
|
271 |
+
.withIndex('worldId', (q) => q.eq('worldId', args.worldId).eq('playerId', args.otherPlayerId))
|
272 |
+
.first();
|
273 |
+
if (!otherPlayerDescription) {
|
274 |
+
throw new Error(`Player description for ${args.otherPlayerId} not found`);
|
275 |
+
}
|
276 |
+
const conversation = world.conversations.find((c) => c.id === args.conversationId);
|
277 |
+
if (!conversation) {
|
278 |
+
throw new Error(`Conversation ${args.conversationId} not found`);
|
279 |
+
}
|
280 |
+
const agent = world.agents.find((a) => a.playerId === args.playerId);
|
281 |
+
if (!agent) {
|
282 |
+
throw new Error(`Player ${args.playerId} not found`);
|
283 |
+
}
|
284 |
+
const agentDescription = await ctx.db
|
285 |
+
.query('agentDescriptions')
|
286 |
+
.withIndex('worldId', (q) => q.eq('worldId', args.worldId).eq('agentId', agent.id))
|
287 |
+
.first();
|
288 |
+
if (!agentDescription) {
|
289 |
+
throw new Error(`Agent description for ${agent.id} not found`);
|
290 |
+
}
|
291 |
+
const otherAgent = world.agents.find((a) => a.playerId === args.otherPlayerId);
|
292 |
+
let otherAgentDescription;
|
293 |
+
if (otherAgent) {
|
294 |
+
otherAgentDescription = await ctx.db
|
295 |
+
.query('agentDescriptions')
|
296 |
+
.withIndex('worldId', (q) => q.eq('worldId', args.worldId).eq('agentId', otherAgent.id))
|
297 |
+
.first();
|
298 |
+
if (!otherAgentDescription) {
|
299 |
+
throw new Error(`Agent description for ${otherAgent.id} not found`);
|
300 |
+
}
|
301 |
+
}
|
302 |
+
const lastTogether = await ctx.db
|
303 |
+
.query('participatedTogether')
|
304 |
+
.withIndex('edge', (q) =>
|
305 |
+
q
|
306 |
+
.eq('worldId', args.worldId)
|
307 |
+
.eq('player1', args.playerId)
|
308 |
+
.eq('player2', args.otherPlayerId),
|
309 |
+
)
|
310 |
+
// Order by conversation end time descending.
|
311 |
+
.order('desc')
|
312 |
+
.first();
|
313 |
+
|
314 |
+
let lastConversation = null;
|
315 |
+
if (lastTogether) {
|
316 |
+
lastConversation = await ctx.db
|
317 |
+
.query('archivedConversations')
|
318 |
+
.withIndex('worldId', (q) =>
|
319 |
+
q.eq('worldId', args.worldId).eq('id', lastTogether.conversationId),
|
320 |
+
)
|
321 |
+
.first();
|
322 |
+
if (!lastConversation) {
|
323 |
+
throw new Error(`Conversation ${lastTogether.conversationId} not found`);
|
324 |
+
}
|
325 |
+
}
|
326 |
+
return {
|
327 |
+
player: { name: playerDescription.name, ...player },
|
328 |
+
otherPlayer: { name: otherPlayerDescription.name, ...otherPlayer },
|
329 |
+
conversation,
|
330 |
+
agent: { identity: agentDescription.identity, plan: agentDescription.plan, ...agent },
|
331 |
+
otherAgent: otherAgent && {
|
332 |
+
identity: otherAgentDescription!.identity,
|
333 |
+
plan: otherAgentDescription!.plan,
|
334 |
+
...otherAgent,
|
335 |
+
},
|
336 |
+
lastConversation,
|
337 |
+
};
|
338 |
+
},
|
339 |
+
});
|
340 |
+
|
341 |
+
function stopWords(otherPlayer: string, player: string) {
|
342 |
+
// These are the words we ask the LLM to stop on. OpenAI only supports 4.
|
343 |
+
const variants = [`${otherPlayer} to ${player}`];
|
344 |
+
return variants.flatMap((stop) => [stop + ':', stop.toLowerCase() + ':']);
|
345 |
+
}
|
patches/convex/agent/embeddingsCache.ts
ADDED
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { v } from 'convex/values';
|
2 |
+
import { ActionCtx, internalMutation, internalQuery } from '../_generated/server';
|
3 |
+
import { internal } from '../_generated/api';
|
4 |
+
import { Id } from '../_generated/dataModel';
|
5 |
+
import { fetchEmbeddingBatch } from '../util/llm';
|
6 |
+
|
7 |
+
const selfInternal = internal.agent.embeddingsCache;
|
8 |
+
|
9 |
+
export async function fetch(ctx: ActionCtx, text: string) {
|
10 |
+
const result = await fetchBatch(ctx, [text]);
|
11 |
+
return result.embeddings[0];
|
12 |
+
}
|
13 |
+
|
14 |
+
export async function fetchBatch(ctx: ActionCtx, texts: string[]) {
|
15 |
+
const start = Date.now();
|
16 |
+
|
17 |
+
const textHashes = await Promise.all(texts.map((text) => hashText(text)));
|
18 |
+
const results = new Array<number[]>(texts.length);
|
19 |
+
const cacheResults = await ctx.runQuery(selfInternal.getEmbeddingsByText, {
|
20 |
+
textHashes,
|
21 |
+
});
|
22 |
+
for (const { index, embedding } of cacheResults) {
|
23 |
+
results[index] = embedding;
|
24 |
+
}
|
25 |
+
const toWrite = [];
|
26 |
+
if (cacheResults.length < texts.length) {
|
27 |
+
const missingIndexes = [...results.keys()].filter((i) => !results[i]);
|
28 |
+
const missingTexts = missingIndexes.map((i) => texts[i]);
|
29 |
+
const response = await fetchEmbeddingBatch(missingTexts);
|
30 |
+
if (response.embeddings.length !== missingIndexes.length) {
|
31 |
+
throw new Error(
|
32 |
+
`Expected ${missingIndexes.length} embeddings, got ${response.embeddings.length}`,
|
33 |
+
);
|
34 |
+
}
|
35 |
+
for (let i = 0; i < missingIndexes.length; i++) {
|
36 |
+
const resultIndex = missingIndexes[i];
|
37 |
+
toWrite.push({
|
38 |
+
textHash: textHashes[resultIndex],
|
39 |
+
embedding: response.embeddings[i],
|
40 |
+
});
|
41 |
+
results[resultIndex] = response.embeddings[i];
|
42 |
+
}
|
43 |
+
}
|
44 |
+
if (toWrite.length > 0) {
|
45 |
+
await ctx.runMutation(selfInternal.writeEmbeddings, { embeddings: toWrite });
|
46 |
+
}
|
47 |
+
return {
|
48 |
+
embeddings: results,
|
49 |
+
hits: cacheResults.length,
|
50 |
+
ms: Date.now() - start,
|
51 |
+
};
|
52 |
+
}
|
53 |
+
|
54 |
+
async function hashText(text: string) {
|
55 |
+
const textEncoder = new TextEncoder();
|
56 |
+
const buf = textEncoder.encode(text);
|
57 |
+
if (typeof crypto === 'undefined') {
|
58 |
+
// Ugly, ugly hax to get ESBuild to not try to bundle this node dependency.
|
59 |
+
const f = () => 'node:crypto';
|
60 |
+
const crypto = (await import(f())) as typeof import('crypto');
|
61 |
+
const hash = crypto.createHash('sha256');
|
62 |
+
hash.update(buf);
|
63 |
+
return hash.digest().buffer;
|
64 |
+
} else {
|
65 |
+
return await crypto.subtle.digest('SHA-256', buf);
|
66 |
+
}
|
67 |
+
}
|
68 |
+
|
69 |
+
export const getEmbeddingsByText = internalQuery({
|
70 |
+
args: { textHashes: v.array(v.bytes()) },
|
71 |
+
handler: async (
|
72 |
+
ctx,
|
73 |
+
args,
|
74 |
+
): Promise<{ index: number; embeddingId: Id<'embeddingsCache'>; embedding: number[] }[]> => {
|
75 |
+
const out = [];
|
76 |
+
for (let i = 0; i < args.textHashes.length; i++) {
|
77 |
+
const textHash = args.textHashes[i];
|
78 |
+
const result = await ctx.db
|
79 |
+
.query('embeddingsCache')
|
80 |
+
.withIndex('text', (q) => q.eq('textHash', textHash))
|
81 |
+
.first();
|
82 |
+
if (result) {
|
83 |
+
out.push({
|
84 |
+
index: i,
|
85 |
+
embeddingId: result._id,
|
86 |
+
embedding: result.embedding,
|
87 |
+
});
|
88 |
+
}
|
89 |
+
}
|
90 |
+
return out;
|
91 |
+
},
|
92 |
+
});
|
93 |
+
|
94 |
+
export const writeEmbeddings = internalMutation({
|
95 |
+
args: {
|
96 |
+
embeddings: v.array(
|
97 |
+
v.object({
|
98 |
+
textHash: v.bytes(),
|
99 |
+
embedding: v.array(v.float64()),
|
100 |
+
}),
|
101 |
+
),
|
102 |
+
},
|
103 |
+
handler: async (ctx, args): Promise<Id<'embeddingsCache'>[]> => {
|
104 |
+
const ids = [];
|
105 |
+
for (const embedding of args.embeddings) {
|
106 |
+
ids.push(await ctx.db.insert('embeddingsCache', embedding));
|
107 |
+
}
|
108 |
+
return ids;
|
109 |
+
},
|
110 |
+
});
|
patches/convex/agent/memory.ts
ADDED
@@ -0,0 +1,450 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { v } from 'convex/values';
|
2 |
+
import { ActionCtx, DatabaseReader, internalMutation, internalQuery } from '../_generated/server';
|
3 |
+
import { Doc, Id } from '../_generated/dataModel';
|
4 |
+
import { internal } from '../_generated/api';
|
5 |
+
import { LLMMessage, chatCompletion, fetchEmbedding } from '../util/llm';
|
6 |
+
import { asyncMap } from '../util/asyncMap';
|
7 |
+
import { GameId, agentId, conversationId, playerId } from '../aiTown/ids';
|
8 |
+
import { SerializedPlayer } from '../aiTown/player';
|
9 |
+
import { memoryFields } from './schema';
|
10 |
+
|
11 |
+
// How long to wait before updating a memory's last access time.
|
12 |
+
export const MEMORY_ACCESS_THROTTLE = 300_000; // In ms
|
13 |
+
// We fetch 10x the number of memories by relevance, to have more candidates
|
14 |
+
// for sorting by relevance + recency + importance.
|
15 |
+
const MEMORY_OVERFETCH = 10;
|
16 |
+
const selfInternal = internal.agent.memory;
|
17 |
+
|
18 |
+
export type Memory = Doc<'memories'>;
|
19 |
+
export type MemoryType = Memory['data']['type'];
|
20 |
+
export type MemoryOfType<T extends MemoryType> = Omit<Memory, 'data'> & {
|
21 |
+
data: Extract<Memory['data'], { type: T }>;
|
22 |
+
};
|
23 |
+
|
24 |
+
export async function rememberConversation(
|
25 |
+
ctx: ActionCtx,
|
26 |
+
worldId: Id<'worlds'>,
|
27 |
+
agentId: GameId<'agents'>,
|
28 |
+
playerId: GameId<'players'>,
|
29 |
+
conversationId: GameId<'conversations'>,
|
30 |
+
) {
|
31 |
+
const data = await ctx.runQuery(selfInternal.loadConversation, {
|
32 |
+
worldId,
|
33 |
+
playerId,
|
34 |
+
conversationId,
|
35 |
+
});
|
36 |
+
const { player, otherPlayer } = data;
|
37 |
+
const messages = await ctx.runQuery(selfInternal.loadMessages, { worldId, conversationId });
|
38 |
+
if (!messages.length) {
|
39 |
+
return;
|
40 |
+
}
|
41 |
+
|
42 |
+
const llmMessages: LLMMessage[] = [
|
43 |
+
{
|
44 |
+
role: 'user',
|
45 |
+
content: `You are ${player.name}, and you just finished a conversation with ${otherPlayer.name}. I would
|
46 |
+
like you to summarize the conversation from ${player.name}'s perspective, using first-person pronouns like
|
47 |
+
"I," and add if you liked or disliked this interaction.`,
|
48 |
+
},
|
49 |
+
];
|
50 |
+
const authors = new Set<GameId<'players'>>();
|
51 |
+
for (const message of messages) {
|
52 |
+
const author = message.author === player.id ? player : otherPlayer;
|
53 |
+
authors.add(author.id as GameId<'players'>);
|
54 |
+
const recipient = message.author === player.id ? otherPlayer : player;
|
55 |
+
llmMessages.push({
|
56 |
+
role: 'user',
|
57 |
+
content: `${author.name} to ${recipient.name}: ${message.text}`,
|
58 |
+
});
|
59 |
+
}
|
60 |
+
llmMessages.push({ role: 'user', content: 'Summary:' });
|
61 |
+
const { content } = await chatCompletion({
|
62 |
+
messages: llmMessages,
|
63 |
+
max_tokens: 500,
|
64 |
+
});
|
65 |
+
const description = `Conversation with ${otherPlayer.name} at ${new Date(
|
66 |
+
data.conversation._creationTime,
|
67 |
+
).toLocaleString()}: ${content}`;
|
68 |
+
const importance = await calculateImportance(description);
|
69 |
+
const { embedding } = await fetchEmbedding(description);
|
70 |
+
authors.delete(player.id as GameId<'players'>);
|
71 |
+
await ctx.runMutation(selfInternal.insertMemory, {
|
72 |
+
agentId,
|
73 |
+
playerId: player.id,
|
74 |
+
description,
|
75 |
+
importance,
|
76 |
+
lastAccess: messages[messages.length - 1]._creationTime,
|
77 |
+
data: {
|
78 |
+
type: 'conversation',
|
79 |
+
conversationId,
|
80 |
+
playerIds: [...authors],
|
81 |
+
},
|
82 |
+
embedding,
|
83 |
+
});
|
84 |
+
await reflectOnMemories(ctx, worldId, playerId);
|
85 |
+
return description;
|
86 |
+
}
|
87 |
+
|
88 |
+
export const loadConversation = internalQuery({
|
89 |
+
args: {
|
90 |
+
worldId: v.id('worlds'),
|
91 |
+
playerId,
|
92 |
+
conversationId,
|
93 |
+
},
|
94 |
+
handler: async (ctx, args) => {
|
95 |
+
const world = await ctx.db.get(args.worldId);
|
96 |
+
if (!world) {
|
97 |
+
throw new Error(`World ${args.worldId} not found`);
|
98 |
+
}
|
99 |
+
const player = world.players.find((p) => p.id === args.playerId);
|
100 |
+
if (!player) {
|
101 |
+
throw new Error(`Player ${args.playerId} not found`);
|
102 |
+
}
|
103 |
+
const playerDescription = await ctx.db
|
104 |
+
.query('playerDescriptions')
|
105 |
+
.withIndex('worldId', (q) => q.eq('worldId', args.worldId).eq('playerId', args.playerId))
|
106 |
+
.first();
|
107 |
+
if (!playerDescription) {
|
108 |
+
throw new Error(`Player description for ${args.playerId} not found`);
|
109 |
+
}
|
110 |
+
const conversation = await ctx.db
|
111 |
+
.query('archivedConversations')
|
112 |
+
.withIndex('worldId', (q) => q.eq('worldId', args.worldId).eq('id', args.conversationId))
|
113 |
+
.first();
|
114 |
+
if (!conversation) {
|
115 |
+
throw new Error(`Conversation ${args.conversationId} not found`);
|
116 |
+
}
|
117 |
+
const otherParticipator = await ctx.db
|
118 |
+
.query('participatedTogether')
|
119 |
+
.withIndex('conversation', (q) =>
|
120 |
+
q
|
121 |
+
.eq('worldId', args.worldId)
|
122 |
+
.eq('player1', args.playerId)
|
123 |
+
.eq('conversationId', args.conversationId),
|
124 |
+
)
|
125 |
+
.first();
|
126 |
+
if (!otherParticipator) {
|
127 |
+
throw new Error(
|
128 |
+
`Couldn't find other participant in conversation ${args.conversationId} with player ${args.playerId}`,
|
129 |
+
);
|
130 |
+
}
|
131 |
+
const otherPlayerId = otherParticipator.player2;
|
132 |
+
let otherPlayer: SerializedPlayer | Doc<'archivedPlayers'> | null =
|
133 |
+
world.players.find((p) => p.id === otherPlayerId) ?? null;
|
134 |
+
if (!otherPlayer) {
|
135 |
+
otherPlayer = await ctx.db
|
136 |
+
.query('archivedPlayers')
|
137 |
+
.withIndex('worldId', (q) => q.eq('worldId', world._id).eq('id', otherPlayerId))
|
138 |
+
.first();
|
139 |
+
}
|
140 |
+
if (!otherPlayer) {
|
141 |
+
throw new Error(`Conversation ${args.conversationId} other player not found`);
|
142 |
+
}
|
143 |
+
const otherPlayerDescription = await ctx.db
|
144 |
+
.query('playerDescriptions')
|
145 |
+
.withIndex('worldId', (q) => q.eq('worldId', args.worldId).eq('playerId', otherPlayerId))
|
146 |
+
.first();
|
147 |
+
if (!otherPlayerDescription) {
|
148 |
+
throw new Error(`Player description for ${otherPlayerId} not found`);
|
149 |
+
}
|
150 |
+
return {
|
151 |
+
player: { ...player, name: playerDescription.name },
|
152 |
+
conversation,
|
153 |
+
otherPlayer: { ...otherPlayer, name: otherPlayerDescription.name },
|
154 |
+
};
|
155 |
+
},
|
156 |
+
});
|
157 |
+
|
158 |
+
export async function searchMemories(
|
159 |
+
ctx: ActionCtx,
|
160 |
+
playerId: GameId<'players'>,
|
161 |
+
searchEmbedding: number[],
|
162 |
+
n: number = 3,
|
163 |
+
) {
|
164 |
+
const candidates = await ctx.vectorSearch('memoryEmbeddings', 'embedding', {
|
165 |
+
vector: searchEmbedding,
|
166 |
+
filter: (q) => q.eq('playerId', playerId),
|
167 |
+
limit: n * MEMORY_OVERFETCH,
|
168 |
+
});
|
169 |
+
const rankedMemories = await ctx.runMutation(selfInternal.rankAndTouchMemories, {
|
170 |
+
candidates,
|
171 |
+
n,
|
172 |
+
});
|
173 |
+
return rankedMemories.map(({ memory }) => memory);
|
174 |
+
}
|
175 |
+
|
176 |
+
function makeRange(values: number[]) {
|
177 |
+
const min = Math.min(...values);
|
178 |
+
const max = Math.max(...values);
|
179 |
+
return [min, max] as const;
|
180 |
+
}
|
181 |
+
|
182 |
+
function normalize(value: number, range: readonly [number, number]) {
|
183 |
+
const [min, max] = range;
|
184 |
+
return (value - min) / (max - min);
|
185 |
+
}
|
186 |
+
|
187 |
+
export const rankAndTouchMemories = internalMutation({
|
188 |
+
args: {
|
189 |
+
candidates: v.array(v.object({ _id: v.id('memoryEmbeddings'), _score: v.number() })),
|
190 |
+
n: v.number(),
|
191 |
+
},
|
192 |
+
handler: async (ctx, args) => {
|
193 |
+
const ts = Date.now();
|
194 |
+
const relatedMemories = await asyncMap(args.candidates, async ({ _id }) => {
|
195 |
+
const memory = await ctx.db
|
196 |
+
.query('memories')
|
197 |
+
.withIndex('embeddingId', (q) => q.eq('embeddingId', _id))
|
198 |
+
.first();
|
199 |
+
if (!memory) throw new Error(`Memory for embedding ${_id} not found`);
|
200 |
+
return memory;
|
201 |
+
});
|
202 |
+
|
203 |
+
// TODO: fetch <count> recent memories and <count> important memories
|
204 |
+
// so we don't miss them in case they were a little less relevant.
|
205 |
+
const recencyScore = relatedMemories.map((memory) => {
|
206 |
+
const hoursSinceAccess = (ts - memory.lastAccess) / 1000 / 60 / 60;
|
207 |
+
return 0.99 ** Math.floor(hoursSinceAccess);
|
208 |
+
});
|
209 |
+
const relevanceRange = makeRange(args.candidates.map((c) => c._score));
|
210 |
+
const importanceRange = makeRange(relatedMemories.map((m) => m.importance));
|
211 |
+
const recencyRange = makeRange(recencyScore);
|
212 |
+
const memoryScores = relatedMemories.map((memory, idx) => ({
|
213 |
+
memory,
|
214 |
+
overallScore:
|
215 |
+
normalize(args.candidates[idx]._score, relevanceRange) +
|
216 |
+
normalize(memory.importance, importanceRange) +
|
217 |
+
normalize(recencyScore[idx], recencyRange),
|
218 |
+
}));
|
219 |
+
memoryScores.sort((a, b) => b.overallScore - a.overallScore);
|
220 |
+
const accessed = memoryScores.slice(0, args.n);
|
221 |
+
await asyncMap(accessed, async ({ memory }) => {
|
222 |
+
if (memory.lastAccess < ts - MEMORY_ACCESS_THROTTLE) {
|
223 |
+
await ctx.db.patch(memory._id, { lastAccess: ts });
|
224 |
+
}
|
225 |
+
});
|
226 |
+
return accessed;
|
227 |
+
},
|
228 |
+
});
|
229 |
+
|
230 |
+
export const loadMessages = internalQuery({
|
231 |
+
args: {
|
232 |
+
worldId: v.id('worlds'),
|
233 |
+
conversationId,
|
234 |
+
},
|
235 |
+
handler: async (ctx, args): Promise<Doc<'messages'>[]> => {
|
236 |
+
const messages = await ctx.db
|
237 |
+
.query('messages')
|
238 |
+
.withIndex('conversationId', (q) =>
|
239 |
+
q.eq('worldId', args.worldId).eq('conversationId', args.conversationId),
|
240 |
+
)
|
241 |
+
.collect();
|
242 |
+
return messages;
|
243 |
+
},
|
244 |
+
});
|
245 |
+
|
246 |
+
async function calculateImportance(description: string) {
|
247 |
+
const { content: importanceRaw } = await chatCompletion({
|
248 |
+
messages: [
|
249 |
+
{
|
250 |
+
role: 'user',
|
251 |
+
content: `On the scale of 0 to 9, where 0 is purely mundane (e.g., brushing teeth, making bed) and 9 is extremely poignant (e.g., a break up, college acceptance), rate the likely poignancy of the following piece of memory.
|
252 |
+
Memory: ${description}
|
253 |
+
Answer on a scale of 0 to 9. Respond with number only, e.g. "5"`,
|
254 |
+
},
|
255 |
+
],
|
256 |
+
temperature: 0.0,
|
257 |
+
max_tokens: 1,
|
258 |
+
});
|
259 |
+
|
260 |
+
let importance = parseFloat(importanceRaw);
|
261 |
+
if (isNaN(importance)) {
|
262 |
+
importance = +(importanceRaw.match(/\d+/)?.[0] ?? NaN);
|
263 |
+
}
|
264 |
+
if (isNaN(importance)) {
|
265 |
+
console.debug('Could not parse memory importance from: ', importanceRaw);
|
266 |
+
importance = 5;
|
267 |
+
}
|
268 |
+
return importance;
|
269 |
+
}
|
270 |
+
|
271 |
+
const { embeddingId: _embeddingId, ...memoryFieldsWithoutEmbeddingId } = memoryFields;
|
272 |
+
|
273 |
+
export const insertMemory = internalMutation({
|
274 |
+
args: {
|
275 |
+
agentId,
|
276 |
+
embedding: v.array(v.float64()),
|
277 |
+
...memoryFieldsWithoutEmbeddingId,
|
278 |
+
},
|
279 |
+
handler: async (ctx, { agentId: _, embedding, ...memory }): Promise<void> => {
|
280 |
+
const embeddingId = await ctx.db.insert('memoryEmbeddings', {
|
281 |
+
playerId: memory.playerId,
|
282 |
+
embedding,
|
283 |
+
});
|
284 |
+
await ctx.db.insert('memories', {
|
285 |
+
...memory,
|
286 |
+
embeddingId,
|
287 |
+
});
|
288 |
+
},
|
289 |
+
});
|
290 |
+
|
291 |
+
export const insertReflectionMemories = internalMutation({
|
292 |
+
args: {
|
293 |
+
worldId: v.id('worlds'),
|
294 |
+
playerId,
|
295 |
+
reflections: v.array(
|
296 |
+
v.object({
|
297 |
+
description: v.string(),
|
298 |
+
relatedMemoryIds: v.array(v.id('memories')),
|
299 |
+
importance: v.number(),
|
300 |
+
embedding: v.array(v.float64()),
|
301 |
+
}),
|
302 |
+
),
|
303 |
+
},
|
304 |
+
handler: async (ctx, { playerId, reflections }) => {
|
305 |
+
const lastAccess = Date.now();
|
306 |
+
for (const { embedding, relatedMemoryIds, ...rest } of reflections) {
|
307 |
+
const embeddingId = await ctx.db.insert('memoryEmbeddings', {
|
308 |
+
playerId,
|
309 |
+
embedding,
|
310 |
+
});
|
311 |
+
await ctx.db.insert('memories', {
|
312 |
+
playerId,
|
313 |
+
embeddingId,
|
314 |
+
lastAccess,
|
315 |
+
...rest,
|
316 |
+
data: {
|
317 |
+
type: 'reflection',
|
318 |
+
relatedMemoryIds,
|
319 |
+
},
|
320 |
+
});
|
321 |
+
}
|
322 |
+
},
|
323 |
+
});
|
324 |
+
|
325 |
+
async function reflectOnMemories(
|
326 |
+
ctx: ActionCtx,
|
327 |
+
worldId: Id<'worlds'>,
|
328 |
+
playerId: GameId<'players'>,
|
329 |
+
) {
|
330 |
+
const { memories, lastReflectionTs, name } = await ctx.runQuery(
|
331 |
+
internal.agent.memory.getReflectionMemories,
|
332 |
+
{
|
333 |
+
worldId,
|
334 |
+
playerId,
|
335 |
+
numberOfItems: 100,
|
336 |
+
},
|
337 |
+
);
|
338 |
+
|
339 |
+
// should only reflect if lastest 100 items have importance score of >500
|
340 |
+
const sumOfImportanceScore = memories
|
341 |
+
.filter((m) => m._creationTime > (lastReflectionTs ?? 0))
|
342 |
+
.reduce((acc, curr) => acc + curr.importance, 0);
|
343 |
+
const shouldReflect = sumOfImportanceScore > 500;
|
344 |
+
|
345 |
+
if (!shouldReflect) {
|
346 |
+
return false;
|
347 |
+
}
|
348 |
+
console.debug('sum of importance score = ', sumOfImportanceScore);
|
349 |
+
console.debug('Reflecting...');
|
350 |
+
const prompt = ['[no prose]', '[Output only JSON]', `You are ${name}, statements about you:`];
|
351 |
+
memories.forEach((m, idx) => {
|
352 |
+
prompt.push(`Statement ${idx}: ${m.description}`);
|
353 |
+
});
|
354 |
+
prompt.push('What 3 high-level insights can you infer from the above statements?');
|
355 |
+
prompt.push(
|
356 |
+
'Return in JSON format, where the key is a list of input statements that contributed to your insights and value is your insight. Make the response parseable by Typescript JSON.parse() function. DO NOT escape characters or include "\n" or white space in response.',
|
357 |
+
);
|
358 |
+
prompt.push(
|
359 |
+
'Example: [{insight: "...", statementIds: [1,2]}, {insight: "...", statementIds: [1]}, ...]',
|
360 |
+
);
|
361 |
+
|
362 |
+
const { content: reflection } = await chatCompletion({
|
363 |
+
messages: [
|
364 |
+
{
|
365 |
+
role: 'user',
|
366 |
+
content: prompt.join('\n'),
|
367 |
+
},
|
368 |
+
],
|
369 |
+
});
|
370 |
+
|
371 |
+
try {
|
372 |
+
const insights = JSON.parse(reflection) as { insight: string; statementIds: number[] }[];
|
373 |
+
const memoriesToSave = await asyncMap(insights, async (item) => {
|
374 |
+
const relatedMemoryIds = item.statementIds.map((idx: number) => memories[idx]._id);
|
375 |
+
const importance = await calculateImportance(item.insight);
|
376 |
+
const { embedding } = await fetchEmbedding(item.insight);
|
377 |
+
console.debug('adding reflection memory...', item.insight);
|
378 |
+
return {
|
379 |
+
description: item.insight,
|
380 |
+
embedding,
|
381 |
+
importance,
|
382 |
+
relatedMemoryIds,
|
383 |
+
};
|
384 |
+
});
|
385 |
+
|
386 |
+
await ctx.runMutation(selfInternal.insertReflectionMemories, {
|
387 |
+
worldId,
|
388 |
+
playerId,
|
389 |
+
reflections: memoriesToSave,
|
390 |
+
});
|
391 |
+
} catch (e) {
|
392 |
+
console.error('error saving or parsing reflection', e);
|
393 |
+
console.debug('reflection', reflection);
|
394 |
+
return false;
|
395 |
+
}
|
396 |
+
return true;
|
397 |
+
}
|
398 |
+
export const getReflectionMemories = internalQuery({
|
399 |
+
args: { worldId: v.id('worlds'), playerId, numberOfItems: v.number() },
|
400 |
+
handler: async (ctx, args) => {
|
401 |
+
const world = await ctx.db.get(args.worldId);
|
402 |
+
if (!world) {
|
403 |
+
throw new Error(`World ${args.worldId} not found`);
|
404 |
+
}
|
405 |
+
const player = world.players.find((p) => p.id === args.playerId);
|
406 |
+
if (!player) {
|
407 |
+
throw new Error(`Player ${args.playerId} not found`);
|
408 |
+
}
|
409 |
+
const playerDescription = await ctx.db
|
410 |
+
.query('playerDescriptions')
|
411 |
+
.withIndex('worldId', (q) => q.eq('worldId', args.worldId).eq('playerId', args.playerId))
|
412 |
+
.first();
|
413 |
+
if (!playerDescription) {
|
414 |
+
throw new Error(`Player description for ${args.playerId} not found`);
|
415 |
+
}
|
416 |
+
const memories = await ctx.db
|
417 |
+
.query('memories')
|
418 |
+
.withIndex('playerId', (q) => q.eq('playerId', player.id))
|
419 |
+
.order('desc')
|
420 |
+
.take(args.numberOfItems);
|
421 |
+
|
422 |
+
const lastReflection = await ctx.db
|
423 |
+
.query('memories')
|
424 |
+
.withIndex('playerId_type', (q) =>
|
425 |
+
q.eq('playerId', args.playerId).eq('data.type', 'reflection'),
|
426 |
+
)
|
427 |
+
.order('desc')
|
428 |
+
.first();
|
429 |
+
|
430 |
+
return {
|
431 |
+
name: playerDescription.name,
|
432 |
+
memories,
|
433 |
+
lastReflectionTs: lastReflection?._creationTime,
|
434 |
+
};
|
435 |
+
},
|
436 |
+
});
|
437 |
+
|
438 |
+
export async function latestMemoryOfType<T extends MemoryType>(
|
439 |
+
db: DatabaseReader,
|
440 |
+
playerId: GameId<'players'>,
|
441 |
+
type: T,
|
442 |
+
) {
|
443 |
+
const entry = await db
|
444 |
+
.query('memories')
|
445 |
+
.withIndex('playerId_type', (q) => q.eq('playerId', playerId).eq('data.type', type))
|
446 |
+
.order('desc')
|
447 |
+
.first();
|
448 |
+
if (!entry) return null;
|
449 |
+
return entry as MemoryOfType<T>;
|
450 |
+
}
|
patches/convex/agent/schema.ts
ADDED
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { v } from 'convex/values';
|
2 |
+
import { playerId, conversationId } from '../aiTown/ids';
|
3 |
+
import { defineTable } from 'convex/server';
|
4 |
+
import { LLM_CONFIG } from '../util/llm';
|
5 |
+
|
6 |
+
export const memoryFields = {
|
7 |
+
playerId,
|
8 |
+
description: v.string(),
|
9 |
+
embeddingId: v.id('memoryEmbeddings'),
|
10 |
+
importance: v.number(),
|
11 |
+
lastAccess: v.number(),
|
12 |
+
data: v.union(
|
13 |
+
// Setting up dynamics between players
|
14 |
+
v.object({
|
15 |
+
type: v.literal('relationship'),
|
16 |
+
// The player this memory is about, from the perspective of the player
|
17 |
+
// whose memory this is.
|
18 |
+
playerId,
|
19 |
+
}),
|
20 |
+
v.object({
|
21 |
+
type: v.literal('conversation'),
|
22 |
+
conversationId,
|
23 |
+
// The other player(s) in the conversation.
|
24 |
+
playerIds: v.array(playerId),
|
25 |
+
}),
|
26 |
+
v.object({
|
27 |
+
type: v.literal('reflection'),
|
28 |
+
relatedMemoryIds: v.array(v.id('memories')),
|
29 |
+
}),
|
30 |
+
),
|
31 |
+
};
|
32 |
+
export const memoryTables = {
|
33 |
+
memories: defineTable(memoryFields)
|
34 |
+
.index('embeddingId', ['embeddingId'])
|
35 |
+
.index('playerId_type', ['playerId', 'data.type'])
|
36 |
+
.index('playerId', ['playerId']),
|
37 |
+
memoryEmbeddings: defineTable({
|
38 |
+
playerId,
|
39 |
+
embedding: v.array(v.float64()),
|
40 |
+
}).vectorIndex('embedding', {
|
41 |
+
vectorField: 'embedding',
|
42 |
+
filterFields: ['playerId'],
|
43 |
+
dimensions: LLM_CONFIG.embeddingDimension,
|
44 |
+
}),
|
45 |
+
};
|
46 |
+
|
47 |
+
export const agentTables = {
|
48 |
+
...memoryTables,
|
49 |
+
embeddingsCache: defineTable({
|
50 |
+
textHash: v.bytes(),
|
51 |
+
embedding: v.array(v.float64()),
|
52 |
+
}).index('text', ['textHash']),
|
53 |
+
};
|
patches/convex/aiTown/agent.ts
ADDED
@@ -0,0 +1,368 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { ObjectType, v } from 'convex/values';
|
2 |
+
import { GameId, parseGameId } from './ids';
|
3 |
+
import { agentId, conversationId, playerId } from './ids';
|
4 |
+
import { serializedPlayer } from './player';
|
5 |
+
import { Game } from './game';
|
6 |
+
import {
|
7 |
+
ACTION_TIMEOUT,
|
8 |
+
AWKWARD_CONVERSATION_TIMEOUT,
|
9 |
+
CONVERSATION_COOLDOWN,
|
10 |
+
CONVERSATION_DISTANCE,
|
11 |
+
INVITE_ACCEPT_PROBABILITY,
|
12 |
+
INVITE_TIMEOUT,
|
13 |
+
MAX_CONVERSATION_DURATION,
|
14 |
+
MAX_CONVERSATION_MESSAGES,
|
15 |
+
MESSAGE_COOLDOWN,
|
16 |
+
MIDPOINT_THRESHOLD,
|
17 |
+
PLAYER_CONVERSATION_COOLDOWN,
|
18 |
+
} from '../constants';
|
19 |
+
import { FunctionArgs } from 'convex/server';
|
20 |
+
import { MutationCtx, internalMutation, internalQuery } from '../_generated/server';
|
21 |
+
import { distance } from '../util/geometry';
|
22 |
+
import { internal } from '../_generated/api';
|
23 |
+
import { movePlayer } from './movement';
|
24 |
+
import { insertInput } from './insertInput';
|
25 |
+
|
26 |
+
export class Agent {
|
27 |
+
id: GameId<'agents'>;
|
28 |
+
playerId: GameId<'players'>;
|
29 |
+
toRemember?: GameId<'conversations'>;
|
30 |
+
lastConversation?: number;
|
31 |
+
lastInviteAttempt?: number;
|
32 |
+
inProgressOperation?: {
|
33 |
+
name: string;
|
34 |
+
operationId: string;
|
35 |
+
started: number;
|
36 |
+
};
|
37 |
+
|
38 |
+
constructor(serialized: SerializedAgent) {
|
39 |
+
const { id, lastConversation, lastInviteAttempt, inProgressOperation } = serialized;
|
40 |
+
const playerId = parseGameId('players', serialized.playerId);
|
41 |
+
this.id = parseGameId('agents', id);
|
42 |
+
this.playerId = playerId;
|
43 |
+
this.toRemember =
|
44 |
+
serialized.toRemember !== undefined
|
45 |
+
? parseGameId('conversations', serialized.toRemember)
|
46 |
+
: undefined;
|
47 |
+
this.lastConversation = lastConversation;
|
48 |
+
this.lastInviteAttempt = lastInviteAttempt;
|
49 |
+
this.inProgressOperation = inProgressOperation;
|
50 |
+
}
|
51 |
+
|
52 |
+
tick(game: Game, now: number) {
|
53 |
+
const player = game.world.players.get(this.playerId);
|
54 |
+
if (!player) {
|
55 |
+
throw new Error(`Invalid player ID ${this.playerId}`);
|
56 |
+
}
|
57 |
+
if (this.inProgressOperation) {
|
58 |
+
if (now < this.inProgressOperation.started + ACTION_TIMEOUT) {
|
59 |
+
// Wait on the operation to finish.
|
60 |
+
return;
|
61 |
+
}
|
62 |
+
console.log(`Timing out ${JSON.stringify(this.inProgressOperation)}`);
|
63 |
+
delete this.inProgressOperation;
|
64 |
+
}
|
65 |
+
const conversation = game.world.playerConversation(player);
|
66 |
+
const member = conversation?.participants.get(player.id);
|
67 |
+
|
68 |
+
const recentlyAttemptedInvite =
|
69 |
+
this.lastInviteAttempt && now < this.lastInviteAttempt + CONVERSATION_COOLDOWN;
|
70 |
+
const doingActivity = player.activity && player.activity.until > now;
|
71 |
+
if (doingActivity && (conversation || player.pathfinding)) {
|
72 |
+
player.activity!.until = now;
|
73 |
+
}
|
74 |
+
// If we're not in a conversation, do something.
|
75 |
+
// If we aren't doing an activity or moving, do something.
|
76 |
+
// If we have been wandering but haven't thought about something to do for
|
77 |
+
// a while, do something.
|
78 |
+
if (!conversation && !doingActivity && (!player.pathfinding || !recentlyAttemptedInvite)) {
|
79 |
+
this.startOperation(game, now, 'agentDoSomething', {
|
80 |
+
worldId: game.worldId,
|
81 |
+
player: player.serialize(),
|
82 |
+
otherFreePlayers: [...game.world.players.values()]
|
83 |
+
.filter((p) => p.id !== player.id)
|
84 |
+
.filter(
|
85 |
+
(p) => ![...game.world.conversations.values()].find((c) => c.participants.has(p.id)),
|
86 |
+
)
|
87 |
+
.map((p) => p.serialize()),
|
88 |
+
agent: this.serialize(),
|
89 |
+
map: game.worldMap.serialize(),
|
90 |
+
});
|
91 |
+
return;
|
92 |
+
}
|
93 |
+
// Check to see if we have a conversation we need to remember.
|
94 |
+
if (this.toRemember) {
|
95 |
+
// Fire off the action to remember the conversation.
|
96 |
+
console.log(`Agent ${this.id} remembering conversation ${this.toRemember}`);
|
97 |
+
this.startOperation(game, now, 'agentRememberConversation', {
|
98 |
+
worldId: game.worldId,
|
99 |
+
playerId: this.playerId,
|
100 |
+
agentId: this.id,
|
101 |
+
conversationId: this.toRemember,
|
102 |
+
});
|
103 |
+
delete this.toRemember;
|
104 |
+
return;
|
105 |
+
}
|
106 |
+
if (conversation && member) {
|
107 |
+
const [otherPlayerId, otherMember] = [...conversation.participants.entries()].find(
|
108 |
+
([id]) => id !== player.id,
|
109 |
+
)!;
|
110 |
+
const otherPlayer = game.world.players.get(otherPlayerId)!;
|
111 |
+
if (member.status.kind === 'invited') {
|
112 |
+
// Accept a conversation with another agent with some probability and with
|
113 |
+
// a human unconditionally.
|
114 |
+
if (otherPlayer.human || Math.random() < INVITE_ACCEPT_PROBABILITY) {
|
115 |
+
console.log(`Agent ${player.id} accepting invite from ${otherPlayer.id}`);
|
116 |
+
conversation.acceptInvite(game, player);
|
117 |
+
// Stop moving so we can start walking towards the other player.
|
118 |
+
if (player.pathfinding) {
|
119 |
+
delete player.pathfinding;
|
120 |
+
}
|
121 |
+
} else {
|
122 |
+
console.log(`Agent ${player.id} rejecting invite from ${otherPlayer.id}`);
|
123 |
+
conversation.rejectInvite(game, now, player);
|
124 |
+
}
|
125 |
+
return;
|
126 |
+
}
|
127 |
+
if (member.status.kind === 'walkingOver') {
|
128 |
+
// Leave a conversation if we've been waiting for too long.
|
129 |
+
if (member.invited + INVITE_TIMEOUT < now) {
|
130 |
+
console.log(`Giving up on invite to ${otherPlayer.id}`);
|
131 |
+
conversation.leave(game, now, player);
|
132 |
+
return;
|
133 |
+
}
|
134 |
+
|
135 |
+
// Don't keep moving around if we're near enough.
|
136 |
+
const playerDistance = distance(player.position, otherPlayer.position);
|
137 |
+
if (playerDistance < CONVERSATION_DISTANCE) {
|
138 |
+
return;
|
139 |
+
}
|
140 |
+
|
141 |
+
// Keep moving towards the other player.
|
142 |
+
// If we're close enough to the player, just walk to them directly.
|
143 |
+
if (!player.pathfinding) {
|
144 |
+
let destination;
|
145 |
+
if (playerDistance < MIDPOINT_THRESHOLD) {
|
146 |
+
destination = {
|
147 |
+
x: Math.floor(otherPlayer.position.x),
|
148 |
+
y: Math.floor(otherPlayer.position.y),
|
149 |
+
};
|
150 |
+
} else {
|
151 |
+
destination = {
|
152 |
+
x: Math.floor((player.position.x + otherPlayer.position.x) / 2),
|
153 |
+
y: Math.floor((player.position.y + otherPlayer.position.y) / 2),
|
154 |
+
};
|
155 |
+
}
|
156 |
+
console.log(`Agent ${player.id} walking towards ${otherPlayer.id}...`, destination);
|
157 |
+
movePlayer(game, now, player, destination);
|
158 |
+
}
|
159 |
+
return;
|
160 |
+
}
|
161 |
+
if (member.status.kind === 'participating') {
|
162 |
+
const started = member.status.started;
|
163 |
+
if (conversation.isTyping && conversation.isTyping.playerId !== player.id) {
|
164 |
+
// Wait for the other player to finish typing.
|
165 |
+
return;
|
166 |
+
}
|
167 |
+
if (!conversation.lastMessage) {
|
168 |
+
const isInitiator = conversation.creator === player.id;
|
169 |
+
const awkwardDeadline = started + AWKWARD_CONVERSATION_TIMEOUT;
|
170 |
+
// Send the first message if we're the initiator or if we've been waiting for too long.
|
171 |
+
if (isInitiator || awkwardDeadline < now) {
|
172 |
+
// Grab the lock on the conversation and send a "start" message.
|
173 |
+
console.log(`${player.id} initiating conversation with ${otherPlayer.id}.`);
|
174 |
+
const messageUuid = crypto.randomUUID();
|
175 |
+
conversation.setIsTyping(now, player, messageUuid);
|
176 |
+
this.startOperation(game, now, 'agentGenerateMessage', {
|
177 |
+
worldId: game.worldId,
|
178 |
+
playerId: player.id,
|
179 |
+
agentId: this.id,
|
180 |
+
conversationId: conversation.id,
|
181 |
+
otherPlayerId: otherPlayer.id,
|
182 |
+
messageUuid,
|
183 |
+
type: 'start',
|
184 |
+
});
|
185 |
+
return;
|
186 |
+
} else {
|
187 |
+
// Wait on the other player to say something up to the awkward deadline.
|
188 |
+
return;
|
189 |
+
}
|
190 |
+
}
|
191 |
+
// See if the conversation has been going on too long and decide to leave.
|
192 |
+
const tooLongDeadline = started + MAX_CONVERSATION_DURATION;
|
193 |
+
if (tooLongDeadline < now || conversation.numMessages > MAX_CONVERSATION_MESSAGES) {
|
194 |
+
console.log(`${player.id} leaving conversation with ${otherPlayer.id}.`);
|
195 |
+
const messageUuid = crypto.randomUUID();
|
196 |
+
conversation.setIsTyping(now, player, messageUuid);
|
197 |
+
this.startOperation(game, now, 'agentGenerateMessage', {
|
198 |
+
worldId: game.worldId,
|
199 |
+
playerId: player.id,
|
200 |
+
agentId: this.id,
|
201 |
+
conversationId: conversation.id,
|
202 |
+
otherPlayerId: otherPlayer.id,
|
203 |
+
messageUuid,
|
204 |
+
type: 'leave',
|
205 |
+
});
|
206 |
+
return;
|
207 |
+
}
|
208 |
+
// Wait for the awkward deadline if we sent the last message.
|
209 |
+
if (conversation.lastMessage.author === player.id) {
|
210 |
+
const awkwardDeadline = conversation.lastMessage.timestamp + AWKWARD_CONVERSATION_TIMEOUT;
|
211 |
+
if (now < awkwardDeadline) {
|
212 |
+
return;
|
213 |
+
}
|
214 |
+
}
|
215 |
+
// Wait for a cooldown after the last message to simulate "reading" the message.
|
216 |
+
const messageCooldown = conversation.lastMessage.timestamp + MESSAGE_COOLDOWN;
|
217 |
+
if (now < messageCooldown) {
|
218 |
+
return;
|
219 |
+
}
|
220 |
+
// Grab the lock and send a message!
|
221 |
+
console.log(`${player.id} continuing conversation with ${otherPlayer.id}.`);
|
222 |
+
const messageUuid = crypto.randomUUID();
|
223 |
+
conversation.setIsTyping(now, player, messageUuid);
|
224 |
+
this.startOperation(game, now, 'agentGenerateMessage', {
|
225 |
+
worldId: game.worldId,
|
226 |
+
playerId: player.id,
|
227 |
+
agentId: this.id,
|
228 |
+
conversationId: conversation.id,
|
229 |
+
otherPlayerId: otherPlayer.id,
|
230 |
+
messageUuid,
|
231 |
+
type: 'continue',
|
232 |
+
});
|
233 |
+
return;
|
234 |
+
}
|
235 |
+
}
|
236 |
+
}
|
237 |
+
|
238 |
+
startOperation<Name extends keyof AgentOperations>(
|
239 |
+
game: Game,
|
240 |
+
now: number,
|
241 |
+
name: Name,
|
242 |
+
args: Omit<FunctionArgs<AgentOperations[Name]>, 'operationId'>,
|
243 |
+
) {
|
244 |
+
if (this.inProgressOperation) {
|
245 |
+
throw new Error(
|
246 |
+
`Agent ${this.id} already has an operation: ${JSON.stringify(this.inProgressOperation)}`,
|
247 |
+
);
|
248 |
+
}
|
249 |
+
const operationId = game.allocId('operations');
|
250 |
+
console.log(`Agent ${this.id} starting operation ${name} (${operationId})`);
|
251 |
+
game.scheduleOperation(name, { operationId, ...args } as any);
|
252 |
+
this.inProgressOperation = {
|
253 |
+
name,
|
254 |
+
operationId,
|
255 |
+
started: now,
|
256 |
+
};
|
257 |
+
}
|
258 |
+
|
259 |
+
serialize(): SerializedAgent {
|
260 |
+
return {
|
261 |
+
id: this.id,
|
262 |
+
playerId: this.playerId,
|
263 |
+
toRemember: this.toRemember,
|
264 |
+
lastConversation: this.lastConversation,
|
265 |
+
lastInviteAttempt: this.lastInviteAttempt,
|
266 |
+
inProgressOperation: this.inProgressOperation,
|
267 |
+
};
|
268 |
+
}
|
269 |
+
}
|
270 |
+
|
271 |
+
export const serializedAgent = {
|
272 |
+
id: agentId,
|
273 |
+
playerId: playerId,
|
274 |
+
toRemember: v.optional(conversationId),
|
275 |
+
lastConversation: v.optional(v.number()),
|
276 |
+
lastInviteAttempt: v.optional(v.number()),
|
277 |
+
inProgressOperation: v.optional(
|
278 |
+
v.object({
|
279 |
+
name: v.string(),
|
280 |
+
operationId: v.string(),
|
281 |
+
started: v.number(),
|
282 |
+
}),
|
283 |
+
),
|
284 |
+
};
|
285 |
+
export type SerializedAgent = ObjectType<typeof serializedAgent>;
|
286 |
+
|
287 |
+
type AgentOperations = typeof internal.aiTown.agentOperations;
|
288 |
+
|
289 |
+
export async function runAgentOperation(ctx: MutationCtx, operation: string, args: any) {
|
290 |
+
let reference;
|
291 |
+
switch (operation) {
|
292 |
+
case 'agentRememberConversation':
|
293 |
+
reference = internal.aiTown.agentOperations.agentRememberConversation;
|
294 |
+
break;
|
295 |
+
case 'agentGenerateMessage':
|
296 |
+
reference = internal.aiTown.agentOperations.agentGenerateMessage;
|
297 |
+
break;
|
298 |
+
case 'agentDoSomething':
|
299 |
+
reference = internal.aiTown.agentOperations.agentDoSomething;
|
300 |
+
break;
|
301 |
+
default:
|
302 |
+
throw new Error(`Unknown operation: ${operation}`);
|
303 |
+
}
|
304 |
+
await ctx.scheduler.runAfter(0, reference, args);
|
305 |
+
}
|
306 |
+
|
307 |
+
export const agentSendMessage = internalMutation({
|
308 |
+
args: {
|
309 |
+
worldId: v.id('worlds'),
|
310 |
+
conversationId,
|
311 |
+
agentId,
|
312 |
+
playerId,
|
313 |
+
text: v.string(),
|
314 |
+
messageUuid: v.string(),
|
315 |
+
leaveConversation: v.boolean(),
|
316 |
+
operationId: v.string(),
|
317 |
+
},
|
318 |
+
handler: async (ctx, args) => {
|
319 |
+
await ctx.db.insert('messages', {
|
320 |
+
conversationId: args.conversationId,
|
321 |
+
author: args.playerId,
|
322 |
+
text: args.text,
|
323 |
+
messageUuid: args.messageUuid,
|
324 |
+
worldId: args.worldId,
|
325 |
+
});
|
326 |
+
await insertInput(ctx, args.worldId, 'agentFinishSendingMessage', {
|
327 |
+
conversationId: args.conversationId,
|
328 |
+
agentId: args.agentId,
|
329 |
+
timestamp: Date.now(),
|
330 |
+
leaveConversation: args.leaveConversation,
|
331 |
+
operationId: args.operationId,
|
332 |
+
});
|
333 |
+
},
|
334 |
+
});
|
335 |
+
|
336 |
+
export const findConversationCandidate = internalQuery({
|
337 |
+
args: {
|
338 |
+
now: v.number(),
|
339 |
+
worldId: v.id('worlds'),
|
340 |
+
player: v.object(serializedPlayer),
|
341 |
+
otherFreePlayers: v.array(v.object(serializedPlayer)),
|
342 |
+
},
|
343 |
+
handler: async (ctx, { now, worldId, player, otherFreePlayers }) => {
|
344 |
+
const { position } = player;
|
345 |
+
const candidates = [];
|
346 |
+
|
347 |
+
for (const otherPlayer of otherFreePlayers) {
|
348 |
+
// Find the latest conversation we're both members of.
|
349 |
+
const lastMember = await ctx.db
|
350 |
+
.query('participatedTogether')
|
351 |
+
.withIndex('edge', (q) =>
|
352 |
+
q.eq('worldId', worldId).eq('player1', player.id).eq('player2', otherPlayer.id),
|
353 |
+
)
|
354 |
+
.order('desc')
|
355 |
+
.first();
|
356 |
+
if (lastMember) {
|
357 |
+
if (now < lastMember.ended + PLAYER_CONVERSATION_COOLDOWN) {
|
358 |
+
continue;
|
359 |
+
}
|
360 |
+
}
|
361 |
+
candidates.push({ id: otherPlayer.id, position });
|
362 |
+
}
|
363 |
+
|
364 |
+
// Sort by distance and take the nearest candidate.
|
365 |
+
candidates.sort((a, b) => distance(a.position, position) - distance(b.position, position));
|
366 |
+
return candidates[0]?.id;
|
367 |
+
},
|
368 |
+
});
|
patches/convex/aiTown/agentDescription.ts
ADDED
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { ObjectType, v } from 'convex/values';
|
2 |
+
import { GameId, agentId, parseGameId } from './ids';
|
3 |
+
|
4 |
+
export class AgentDescription {
|
5 |
+
agentId: GameId<'agents'>;
|
6 |
+
identity: string;
|
7 |
+
plan: string;
|
8 |
+
|
9 |
+
constructor(serialized: SerializedAgentDescription) {
|
10 |
+
const { agentId, identity, plan } = serialized;
|
11 |
+
this.agentId = parseGameId('agents', agentId);
|
12 |
+
this.identity = identity;
|
13 |
+
this.plan = plan;
|
14 |
+
}
|
15 |
+
|
16 |
+
serialize(): SerializedAgentDescription {
|
17 |
+
const { agentId, identity, plan } = this;
|
18 |
+
return { agentId, identity, plan };
|
19 |
+
}
|
20 |
+
}
|
21 |
+
|
22 |
+
export const serializedAgentDescription = {
|
23 |
+
agentId,
|
24 |
+
identity: v.string(),
|
25 |
+
plan: v.string(),
|
26 |
+
};
|
27 |
+
export type SerializedAgentDescription = ObjectType<typeof serializedAgentDescription>;
|
patches/convex/aiTown/agentInputs.ts
ADDED
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { v } from 'convex/values';
|
2 |
+
import { agentId, conversationId, parseGameId } from './ids';
|
3 |
+
import { Player, activity } from './player';
|
4 |
+
import { Conversation, conversationInputs } from './conversation';
|
5 |
+
import { movePlayer } from './movement';
|
6 |
+
import { inputHandler } from './inputHandler';
|
7 |
+
import { point } from '../util/types';
|
8 |
+
import { Descriptions } from '../../data/characters';
|
9 |
+
import { AgentDescription } from './agentDescription';
|
10 |
+
import { Agent } from './agent';
|
11 |
+
|
12 |
+
export const agentInputs = {
|
13 |
+
finishRememberConversation: inputHandler({
|
14 |
+
args: {
|
15 |
+
operationId: v.string(),
|
16 |
+
agentId,
|
17 |
+
},
|
18 |
+
handler: (game, now, args) => {
|
19 |
+
const agentId = parseGameId('agents', args.agentId);
|
20 |
+
const agent = game.world.agents.get(agentId);
|
21 |
+
if (!agent) {
|
22 |
+
throw new Error(`Couldn't find agent: ${agentId}`);
|
23 |
+
}
|
24 |
+
if (
|
25 |
+
!agent.inProgressOperation ||
|
26 |
+
agent.inProgressOperation.operationId !== args.operationId
|
27 |
+
) {
|
28 |
+
console.debug(`Agent ${agentId} isn't remembering ${args.operationId}`);
|
29 |
+
} else {
|
30 |
+
delete agent.inProgressOperation;
|
31 |
+
delete agent.toRemember;
|
32 |
+
}
|
33 |
+
return null;
|
34 |
+
},
|
35 |
+
}),
|
36 |
+
finishDoSomething: inputHandler({
|
37 |
+
args: {
|
38 |
+
operationId: v.string(),
|
39 |
+
agentId: v.id('agents'),
|
40 |
+
destination: v.optional(point),
|
41 |
+
invitee: v.optional(v.id('players')),
|
42 |
+
activity: v.optional(activity),
|
43 |
+
},
|
44 |
+
handler: (game, now, args) => {
|
45 |
+
const agentId = parseGameId('agents', args.agentId);
|
46 |
+
const agent = game.world.agents.get(agentId);
|
47 |
+
if (!agent) {
|
48 |
+
throw new Error(`Couldn't find agent: ${agentId}`);
|
49 |
+
}
|
50 |
+
if (
|
51 |
+
!agent.inProgressOperation ||
|
52 |
+
agent.inProgressOperation.operationId !== args.operationId
|
53 |
+
) {
|
54 |
+
console.debug(`Agent ${agentId} didn't have ${args.operationId} in progress`);
|
55 |
+
return null;
|
56 |
+
}
|
57 |
+
delete agent.inProgressOperation;
|
58 |
+
const player = game.world.players.get(agent.playerId)!;
|
59 |
+
if (args.invitee) {
|
60 |
+
const inviteeId = parseGameId('players', args.invitee);
|
61 |
+
const invitee = game.world.players.get(inviteeId);
|
62 |
+
if (!invitee) {
|
63 |
+
throw new Error(`Couldn't find player: ${inviteeId}`);
|
64 |
+
}
|
65 |
+
Conversation.start(game, now, player, invitee);
|
66 |
+
agent.lastInviteAttempt = now;
|
67 |
+
}
|
68 |
+
if (args.destination) {
|
69 |
+
movePlayer(game, now, player, args.destination);
|
70 |
+
}
|
71 |
+
if (args.activity) {
|
72 |
+
player.activity = args.activity;
|
73 |
+
}
|
74 |
+
return null;
|
75 |
+
},
|
76 |
+
}),
|
77 |
+
agentFinishSendingMessage: inputHandler({
|
78 |
+
args: {
|
79 |
+
agentId,
|
80 |
+
conversationId,
|
81 |
+
timestamp: v.number(),
|
82 |
+
operationId: v.string(),
|
83 |
+
leaveConversation: v.boolean(),
|
84 |
+
},
|
85 |
+
handler: (game, now, args) => {
|
86 |
+
const agentId = parseGameId('agents', args.agentId);
|
87 |
+
const agent = game.world.agents.get(agentId);
|
88 |
+
if (!agent) {
|
89 |
+
throw new Error(`Couldn't find agent: ${agentId}`);
|
90 |
+
}
|
91 |
+
const player = game.world.players.get(agent.playerId);
|
92 |
+
if (!player) {
|
93 |
+
throw new Error(`Couldn't find player: ${agent.playerId}`);
|
94 |
+
}
|
95 |
+
const conversationId = parseGameId('conversations', args.conversationId);
|
96 |
+
const conversation = game.world.conversations.get(conversationId);
|
97 |
+
if (!conversation) {
|
98 |
+
throw new Error(`Couldn't find conversation: ${conversationId}`);
|
99 |
+
}
|
100 |
+
if (
|
101 |
+
!agent.inProgressOperation ||
|
102 |
+
agent.inProgressOperation.operationId !== args.operationId
|
103 |
+
) {
|
104 |
+
console.debug(`Agent ${agentId} wasn't sending a message ${args.operationId}`);
|
105 |
+
return null;
|
106 |
+
}
|
107 |
+
delete agent.inProgressOperation;
|
108 |
+
conversationInputs.finishSendingMessage.handler(game, now, {
|
109 |
+
playerId: agent.playerId,
|
110 |
+
conversationId: args.conversationId,
|
111 |
+
timestamp: args.timestamp,
|
112 |
+
});
|
113 |
+
if (args.leaveConversation) {
|
114 |
+
conversation.leave(game, now, player);
|
115 |
+
}
|
116 |
+
return null;
|
117 |
+
},
|
118 |
+
}),
|
119 |
+
createAgent: inputHandler({
|
120 |
+
args: {
|
121 |
+
descriptionIndex: v.number(),
|
122 |
+
},
|
123 |
+
handler: (game, now, args) => {
|
124 |
+
const description = Descriptions[args.descriptionIndex];
|
125 |
+
const playerId = Player.join(
|
126 |
+
game,
|
127 |
+
now,
|
128 |
+
description.name,
|
129 |
+
description.character,
|
130 |
+
description.identity,
|
131 |
+
);
|
132 |
+
const agentId = game.allocId('agents');
|
133 |
+
game.world.agents.set(
|
134 |
+
agentId,
|
135 |
+
new Agent({
|
136 |
+
id: agentId,
|
137 |
+
playerId: playerId,
|
138 |
+
inProgressOperation: undefined,
|
139 |
+
lastConversation: undefined,
|
140 |
+
lastInviteAttempt: undefined,
|
141 |
+
toRemember: undefined,
|
142 |
+
}),
|
143 |
+
);
|
144 |
+
game.agentDescriptions.set(
|
145 |
+
agentId,
|
146 |
+
new AgentDescription({
|
147 |
+
agentId: agentId,
|
148 |
+
identity: description.identity,
|
149 |
+
plan: description.plan,
|
150 |
+
}),
|
151 |
+
);
|
152 |
+
return { agentId };
|
153 |
+
},
|
154 |
+
}),
|
155 |
+
};
|
patches/convex/aiTown/agentOperations.ts
ADDED
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
'use node';
|
2 |
+
|
3 |
+
import { v } from 'convex/values';
|
4 |
+
import { internalAction } from '../_generated/server';
|
5 |
+
import { WorldMap, serializedWorldMap } from './worldMap';
|
6 |
+
import { rememberConversation } from '../agent/memory';
|
7 |
+
import { GameId, agentId, conversationId, playerId } from './ids';
|
8 |
+
import {
|
9 |
+
continueConversationMessage,
|
10 |
+
leaveConversationMessage,
|
11 |
+
startConversationMessage,
|
12 |
+
} from '../agent/conversation';
|
13 |
+
import { assertNever } from '../util/assertNever';
|
14 |
+
import { serializedAgent } from './agent';
|
15 |
+
import { ACTIVITIES, ACTIVITY_COOLDOWN, CONVERSATION_COOLDOWN } from '../constants';
|
16 |
+
import { api, internal } from '../_generated/api';
|
17 |
+
import { sleep } from '../util/sleep';
|
18 |
+
import { serializedPlayer } from './player';
|
19 |
+
|
20 |
+
export const agentRememberConversation = internalAction({
|
21 |
+
args: {
|
22 |
+
worldId: v.id('worlds'),
|
23 |
+
playerId,
|
24 |
+
agentId,
|
25 |
+
conversationId,
|
26 |
+
operationId: v.string(),
|
27 |
+
},
|
28 |
+
handler: async (ctx, args) => {
|
29 |
+
await rememberConversation(
|
30 |
+
ctx,
|
31 |
+
args.worldId,
|
32 |
+
args.agentId as GameId<'agents'>,
|
33 |
+
args.playerId as GameId<'players'>,
|
34 |
+
args.conversationId as GameId<'conversations'>,
|
35 |
+
);
|
36 |
+
await sleep(Math.random() * 1000);
|
37 |
+
await ctx.runMutation(api.aiTown.main.sendInput, {
|
38 |
+
worldId: args.worldId,
|
39 |
+
name: 'finishRememberConversation',
|
40 |
+
args: {
|
41 |
+
agentId: args.agentId,
|
42 |
+
operationId: args.operationId,
|
43 |
+
},
|
44 |
+
});
|
45 |
+
},
|
46 |
+
});
|
47 |
+
|
48 |
+
export const agentGenerateMessage = internalAction({
|
49 |
+
args: {
|
50 |
+
worldId: v.id('worlds'),
|
51 |
+
playerId,
|
52 |
+
agentId,
|
53 |
+
conversationId,
|
54 |
+
otherPlayerId: playerId,
|
55 |
+
operationId: v.string(),
|
56 |
+
type: v.union(v.literal('start'), v.literal('continue'), v.literal('leave')),
|
57 |
+
messageUuid: v.string(),
|
58 |
+
},
|
59 |
+
handler: async (ctx, args) => {
|
60 |
+
let completionFn;
|
61 |
+
switch (args.type) {
|
62 |
+
case 'start':
|
63 |
+
completionFn = startConversationMessage;
|
64 |
+
break;
|
65 |
+
case 'continue':
|
66 |
+
completionFn = continueConversationMessage;
|
67 |
+
break;
|
68 |
+
case 'leave':
|
69 |
+
completionFn = leaveConversationMessage;
|
70 |
+
break;
|
71 |
+
default:
|
72 |
+
assertNever(args.type);
|
73 |
+
}
|
74 |
+
const completion = await completionFn(
|
75 |
+
ctx,
|
76 |
+
args.worldId,
|
77 |
+
args.conversationId as GameId<'conversations'>,
|
78 |
+
args.playerId as GameId<'players'>,
|
79 |
+
args.otherPlayerId as GameId<'players'>,
|
80 |
+
);
|
81 |
+
// TODO: stream in the text instead of reading it all at once.
|
82 |
+
const text = await completion.readAll();
|
83 |
+
|
84 |
+
await ctx.runMutation(internal.aiTown.agent.agentSendMessage, {
|
85 |
+
worldId: args.worldId,
|
86 |
+
conversationId: args.conversationId,
|
87 |
+
agentId: args.agentId,
|
88 |
+
playerId: args.playerId,
|
89 |
+
text,
|
90 |
+
messageUuid: args.messageUuid,
|
91 |
+
leaveConversation: args.type === 'leave',
|
92 |
+
operationId: args.operationId,
|
93 |
+
});
|
94 |
+
},
|
95 |
+
});
|
96 |
+
|
97 |
+
export const agentDoSomething = internalAction({
|
98 |
+
args: {
|
99 |
+
worldId: v.id('worlds'),
|
100 |
+
player: v.object(serializedPlayer),
|
101 |
+
agent: v.object(serializedAgent),
|
102 |
+
map: v.object(serializedWorldMap),
|
103 |
+
otherFreePlayers: v.array(v.object(serializedPlayer)),
|
104 |
+
operationId: v.string(),
|
105 |
+
},
|
106 |
+
handler: async (ctx, args) => {
|
107 |
+
const { player, agent } = args;
|
108 |
+
const map = new WorldMap(args.map);
|
109 |
+
const now = Date.now();
|
110 |
+
// Don't try to start a new conversation if we were just in one.
|
111 |
+
const justLeftConversation =
|
112 |
+
agent.lastConversation && now < agent.lastConversation + CONVERSATION_COOLDOWN;
|
113 |
+
// Don't try again if we recently tried to find someone to invite.
|
114 |
+
const recentlyAttemptedInvite =
|
115 |
+
agent.lastInviteAttempt && now < agent.lastInviteAttempt + CONVERSATION_COOLDOWN;
|
116 |
+
const recentActivity = player.activity && now < player.activity.until + ACTIVITY_COOLDOWN;
|
117 |
+
// Decide whether to do an activity or wander somewhere.
|
118 |
+
if (!player.pathfinding) {
|
119 |
+
if (recentActivity || justLeftConversation) {
|
120 |
+
await sleep(Math.random() * 1000);
|
121 |
+
await ctx.runMutation(api.aiTown.main.sendInput, {
|
122 |
+
worldId: args.worldId,
|
123 |
+
name: 'finishDoSomething',
|
124 |
+
args: {
|
125 |
+
operationId: args.operationId,
|
126 |
+
agentId: agent.id,
|
127 |
+
destination: wanderDestination(map),
|
128 |
+
},
|
129 |
+
});
|
130 |
+
return;
|
131 |
+
} else {
|
132 |
+
// TODO: have LLM choose the activity & emoji
|
133 |
+
const activity = ACTIVITIES[Math.floor(Math.random() * ACTIVITIES.length)];
|
134 |
+
await sleep(Math.random() * 1000);
|
135 |
+
await ctx.runMutation(api.aiTown.main.sendInput, {
|
136 |
+
worldId: args.worldId,
|
137 |
+
name: 'finishDoSomething',
|
138 |
+
args: {
|
139 |
+
operationId: args.operationId,
|
140 |
+
agentId: agent.id,
|
141 |
+
activity: {
|
142 |
+
description: activity.description,
|
143 |
+
emoji: activity.emoji,
|
144 |
+
until: Date.now() + activity.duration,
|
145 |
+
},
|
146 |
+
},
|
147 |
+
});
|
148 |
+
return;
|
149 |
+
}
|
150 |
+
}
|
151 |
+
const invitee =
|
152 |
+
justLeftConversation || recentlyAttemptedInvite
|
153 |
+
? undefined
|
154 |
+
: await ctx.runQuery(internal.aiTown.agent.findConversationCandidate, {
|
155 |
+
now,
|
156 |
+
worldId: args.worldId,
|
157 |
+
player: args.player,
|
158 |
+
otherFreePlayers: args.otherFreePlayers,
|
159 |
+
});
|
160 |
+
|
161 |
+
// TODO: We hit a lot of OCC errors on sending inputs in this file. It's
|
162 |
+
// easy for them to get scheduled at the same time and line up in time.
|
163 |
+
await sleep(Math.random() * 1000);
|
164 |
+
await ctx.runMutation(api.aiTown.main.sendInput, {
|
165 |
+
worldId: args.worldId,
|
166 |
+
name: 'finishDoSomething',
|
167 |
+
args: {
|
168 |
+
operationId: args.operationId,
|
169 |
+
agentId: args.agent.id,
|
170 |
+
invitee,
|
171 |
+
},
|
172 |
+
});
|
173 |
+
},
|
174 |
+
});
|
175 |
+
|
176 |
+
function wanderDestination(worldMap: WorldMap) {
|
177 |
+
// Wander someonewhere at least one tile away from the edge.
|
178 |
+
return {
|
179 |
+
x: 1 + Math.floor(Math.random() * (worldMap.width - 2)),
|
180 |
+
y: 1 + Math.floor(Math.random() * (worldMap.height - 2)),
|
181 |
+
};
|
182 |
+
}
|
patches/convex/aiTown/conversation.ts
ADDED
@@ -0,0 +1,395 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { ObjectType, v } from 'convex/values';
|
2 |
+
import { GameId, parseGameId } from './ids';
|
3 |
+
import { conversationId, playerId } from './ids';
|
4 |
+
import { Player } from './player';
|
5 |
+
import { inputHandler } from './inputHandler';
|
6 |
+
|
7 |
+
import { TYPING_TIMEOUT, CONVERSATION_DISTANCE } from '../constants';
|
8 |
+
import { distance, normalize, vector } from '../util/geometry';
|
9 |
+
import { Point } from '../util/types';
|
10 |
+
import { Game } from './game';
|
11 |
+
import { stopPlayer, blocked, movePlayer } from './movement';
|
12 |
+
import { ConversationMembership, serializedConversationMembership } from './conversationMembership';
|
13 |
+
import { parseMap, serializeMap } from '../util/object';
|
14 |
+
|
15 |
+
export class Conversation {
|
16 |
+
id: GameId<'conversations'>;
|
17 |
+
creator: GameId<'players'>;
|
18 |
+
created: number;
|
19 |
+
isTyping?: {
|
20 |
+
playerId: GameId<'players'>;
|
21 |
+
messageUuid: string;
|
22 |
+
since: number;
|
23 |
+
};
|
24 |
+
lastMessage?: {
|
25 |
+
author: GameId<'players'>;
|
26 |
+
timestamp: number;
|
27 |
+
};
|
28 |
+
numMessages: number;
|
29 |
+
participants: Map<GameId<'players'>, ConversationMembership>;
|
30 |
+
|
31 |
+
constructor(serialized: SerializedConversation) {
|
32 |
+
const { id, creator, created, isTyping, lastMessage, numMessages, participants } = serialized;
|
33 |
+
this.id = parseGameId('conversations', id);
|
34 |
+
this.creator = parseGameId('players', creator);
|
35 |
+
this.created = created;
|
36 |
+
this.isTyping = isTyping && {
|
37 |
+
playerId: parseGameId('players', isTyping.playerId),
|
38 |
+
messageUuid: isTyping.messageUuid,
|
39 |
+
since: isTyping.since,
|
40 |
+
};
|
41 |
+
this.lastMessage = lastMessage && {
|
42 |
+
author: parseGameId('players', lastMessage.author),
|
43 |
+
timestamp: lastMessage.timestamp,
|
44 |
+
};
|
45 |
+
this.numMessages = numMessages;
|
46 |
+
this.participants = parseMap(participants, ConversationMembership, (m) => m.playerId);
|
47 |
+
}
|
48 |
+
|
49 |
+
tick(game: Game, now: number) {
|
50 |
+
if (this.isTyping && this.isTyping.since + TYPING_TIMEOUT < now) {
|
51 |
+
delete this.isTyping;
|
52 |
+
}
|
53 |
+
if (this.participants.size !== 2) {
|
54 |
+
console.warn(`Conversation ${this.id} has ${this.participants.size} participants`);
|
55 |
+
return;
|
56 |
+
}
|
57 |
+
const [playerId1, playerId2] = [...this.participants.keys()];
|
58 |
+
const member1 = this.participants.get(playerId1)!;
|
59 |
+
const member2 = this.participants.get(playerId2)!;
|
60 |
+
|
61 |
+
const player1 = game.world.players.get(playerId1)!;
|
62 |
+
const player2 = game.world.players.get(playerId2)!;
|
63 |
+
|
64 |
+
const playerDistance = distance(player1?.position, player2?.position);
|
65 |
+
|
66 |
+
// If the players are both in the "walkingOver" state and they're sufficiently close, transition both
|
67 |
+
// of them to "participating" and stop their paths.
|
68 |
+
if (member1.status.kind === 'walkingOver' && member2.status.kind === 'walkingOver') {
|
69 |
+
if (playerDistance < CONVERSATION_DISTANCE) {
|
70 |
+
console.log(`Starting conversation between ${player1.id} and ${player2.id}`);
|
71 |
+
|
72 |
+
// First, stop the two players from moving.
|
73 |
+
stopPlayer(player1);
|
74 |
+
stopPlayer(player2);
|
75 |
+
|
76 |
+
member1.status = { kind: 'participating', started: now };
|
77 |
+
member2.status = { kind: 'participating', started: now };
|
78 |
+
|
79 |
+
// Try to move the first player to grid point nearest the other player.
|
80 |
+
const neighbors = (p: Point) => [
|
81 |
+
{ x: p.x + 1, y: p.y },
|
82 |
+
{ x: p.x - 1, y: p.y },
|
83 |
+
{ x: p.x, y: p.y + 1 },
|
84 |
+
{ x: p.x, y: p.y - 1 },
|
85 |
+
];
|
86 |
+
const floorPos1 = { x: Math.floor(player1.position.x), y: Math.floor(player1.position.y) };
|
87 |
+
const p1Candidates = neighbors(floorPos1).filter((p) => !blocked(game, now, p, player1.id));
|
88 |
+
p1Candidates.sort((a, b) => distance(a, player2.position) - distance(b, player2.position));
|
89 |
+
if (p1Candidates.length > 0) {
|
90 |
+
const p1Candidate = p1Candidates[0];
|
91 |
+
|
92 |
+
// Try to move the second player to the grid point nearest the first player's
|
93 |
+
// destination.
|
94 |
+
const p2Candidates = neighbors(p1Candidate).filter(
|
95 |
+
(p) => !blocked(game, now, p, player2.id),
|
96 |
+
);
|
97 |
+
p2Candidates.sort(
|
98 |
+
(a, b) => distance(a, player2.position) - distance(b, player2.position),
|
99 |
+
);
|
100 |
+
if (p2Candidates.length > 0) {
|
101 |
+
const p2Candidate = p2Candidates[0];
|
102 |
+
movePlayer(game, now, player1, p1Candidate, true);
|
103 |
+
movePlayer(game, now, player2, p2Candidate, true);
|
104 |
+
}
|
105 |
+
}
|
106 |
+
}
|
107 |
+
}
|
108 |
+
|
109 |
+
// Orient the two players towards each other if they're not moving.
|
110 |
+
if (member1.status.kind === 'participating' && member2.status.kind === 'participating') {
|
111 |
+
const v = normalize(vector(player1.position, player2.position));
|
112 |
+
if (!player1.pathfinding && v) {
|
113 |
+
player1.facing = v;
|
114 |
+
}
|
115 |
+
if (!player2.pathfinding && v) {
|
116 |
+
player2.facing.dx = -v.dx;
|
117 |
+
player2.facing.dy = -v.dy;
|
118 |
+
}
|
119 |
+
}
|
120 |
+
}
|
121 |
+
|
122 |
+
static start(game: Game, now: number, player: Player, invitee: Player) {
|
123 |
+
if (player.id === invitee.id) {
|
124 |
+
throw new Error(`Can't invite yourself to a conversation`);
|
125 |
+
}
|
126 |
+
// Ensure the players still exist.
|
127 |
+
if ([...game.world.conversations.values()].find((c) => c.participants.has(player.id))) {
|
128 |
+
const reason = `Player ${player.id} is already in a conversation`;
|
129 |
+
console.log(reason);
|
130 |
+
return { error: reason };
|
131 |
+
}
|
132 |
+
if ([...game.world.conversations.values()].find((c) => c.participants.has(invitee.id))) {
|
133 |
+
const reason = `Player ${player.id} is already in a conversation`;
|
134 |
+
console.log(reason);
|
135 |
+
return { error: reason };
|
136 |
+
}
|
137 |
+
const conversationId = game.allocId('conversations');
|
138 |
+
console.log(`Creating conversation ${conversationId}`);
|
139 |
+
game.world.conversations.set(
|
140 |
+
conversationId,
|
141 |
+
new Conversation({
|
142 |
+
id: conversationId,
|
143 |
+
created: now,
|
144 |
+
creator: player.id,
|
145 |
+
numMessages: 0,
|
146 |
+
participants: [
|
147 |
+
{ playerId: player.id, invited: now, status: { kind: 'walkingOver' } },
|
148 |
+
{ playerId: invitee.id, invited: now, status: { kind: 'invited' } },
|
149 |
+
],
|
150 |
+
}),
|
151 |
+
);
|
152 |
+
return { conversationId };
|
153 |
+
}
|
154 |
+
|
155 |
+
setIsTyping(now: number, player: Player, messageUuid: string) {
|
156 |
+
if (this.isTyping) {
|
157 |
+
if (this.isTyping.playerId !== player.id) {
|
158 |
+
throw new Error(`Player ${this.isTyping.playerId} is already typing in ${this.id}`);
|
159 |
+
}
|
160 |
+
return;
|
161 |
+
}
|
162 |
+
this.isTyping = { playerId: player.id, messageUuid, since: now };
|
163 |
+
}
|
164 |
+
|
165 |
+
acceptInvite(game: Game, player: Player) {
|
166 |
+
const member = this.participants.get(player.id);
|
167 |
+
if (!member) {
|
168 |
+
throw new Error(`Player ${player.id} not in conversation ${this.id}`);
|
169 |
+
}
|
170 |
+
if (member.status.kind !== 'invited') {
|
171 |
+
throw new Error(
|
172 |
+
`Invalid membership status for ${player.id}:${this.id}: ${JSON.stringify(member)}`,
|
173 |
+
);
|
174 |
+
}
|
175 |
+
member.status = { kind: 'walkingOver' };
|
176 |
+
}
|
177 |
+
|
178 |
+
rejectInvite(game: Game, now: number, player: Player) {
|
179 |
+
const member = this.participants.get(player.id);
|
180 |
+
if (!member) {
|
181 |
+
throw new Error(`Player ${player.id} not in conversation ${this.id}`);
|
182 |
+
}
|
183 |
+
if (member.status.kind !== 'invited') {
|
184 |
+
throw new Error(
|
185 |
+
`Rejecting invite in wrong membership state: ${this.id}:${player.id}: ${JSON.stringify(
|
186 |
+
member,
|
187 |
+
)}`,
|
188 |
+
);
|
189 |
+
}
|
190 |
+
this.stop(game, now);
|
191 |
+
}
|
192 |
+
|
193 |
+
stop(game: Game, now: number) {
|
194 |
+
delete this.isTyping;
|
195 |
+
for (const [playerId, member] of this.participants.entries()) {
|
196 |
+
const agent = [...game.world.agents.values()].find((a) => a.playerId === playerId);
|
197 |
+
if (agent) {
|
198 |
+
agent.lastConversation = now;
|
199 |
+
agent.toRemember = this.id;
|
200 |
+
}
|
201 |
+
}
|
202 |
+
game.world.conversations.delete(this.id);
|
203 |
+
}
|
204 |
+
|
205 |
+
leave(game: Game, now: number, player: Player) {
|
206 |
+
const member = this.participants.get(player.id);
|
207 |
+
if (!member) {
|
208 |
+
throw new Error(`Couldn't find membership for ${this.id}:${player.id}`);
|
209 |
+
}
|
210 |
+
this.stop(game, now);
|
211 |
+
}
|
212 |
+
|
213 |
+
serialize(): SerializedConversation {
|
214 |
+
const { id, creator, created, isTyping, lastMessage, numMessages } = this;
|
215 |
+
return {
|
216 |
+
id,
|
217 |
+
creator,
|
218 |
+
created,
|
219 |
+
isTyping,
|
220 |
+
lastMessage,
|
221 |
+
numMessages,
|
222 |
+
participants: serializeMap(this.participants),
|
223 |
+
};
|
224 |
+
}
|
225 |
+
}
|
226 |
+
|
227 |
+
export const serializedConversation = {
|
228 |
+
id: conversationId,
|
229 |
+
creator: playerId,
|
230 |
+
created: v.number(),
|
231 |
+
isTyping: v.optional(
|
232 |
+
v.object({
|
233 |
+
playerId,
|
234 |
+
messageUuid: v.string(),
|
235 |
+
since: v.number(),
|
236 |
+
}),
|
237 |
+
),
|
238 |
+
lastMessage: v.optional(
|
239 |
+
v.object({
|
240 |
+
author: playerId,
|
241 |
+
timestamp: v.number(),
|
242 |
+
}),
|
243 |
+
),
|
244 |
+
numMessages: v.number(),
|
245 |
+
participants: v.array(v.object(serializedConversationMembership)),
|
246 |
+
};
|
247 |
+
export type SerializedConversation = ObjectType<typeof serializedConversation>;
|
248 |
+
|
249 |
+
export const conversationInputs = {
|
250 |
+
// Start a conversation, inviting the specified player.
|
251 |
+
// Conversations can only have two participants for now,
|
252 |
+
// so we don't have a separate "invite" input.
|
253 |
+
startConversation: inputHandler({
|
254 |
+
args: {
|
255 |
+
playerId,
|
256 |
+
invitee: playerId,
|
257 |
+
},
|
258 |
+
handler: (game: Game, now: number, args): GameId<'conversations'> => {
|
259 |
+
const playerId = parseGameId('players', args.playerId);
|
260 |
+
const player = game.world.players.get(playerId);
|
261 |
+
if (!player) {
|
262 |
+
throw new Error(`Invalid player ID: ${playerId}`);
|
263 |
+
}
|
264 |
+
const inviteeId = parseGameId('players', args.invitee);
|
265 |
+
const invitee = game.world.players.get(inviteeId);
|
266 |
+
if (!invitee) {
|
267 |
+
throw new Error(`Invalid player ID: ${inviteeId}`);
|
268 |
+
}
|
269 |
+
console.log(`Starting ${playerId} ${inviteeId}...`);
|
270 |
+
const { conversationId, error } = Conversation.start(game, now, player, invitee);
|
271 |
+
if (!conversationId) {
|
272 |
+
// TODO: pass it back to the client for them to show an error.
|
273 |
+
throw new Error(error);
|
274 |
+
}
|
275 |
+
return conversationId;
|
276 |
+
},
|
277 |
+
}),
|
278 |
+
|
279 |
+
startTyping: inputHandler({
|
280 |
+
args: {
|
281 |
+
playerId,
|
282 |
+
conversationId,
|
283 |
+
messageUuid: v.string(),
|
284 |
+
},
|
285 |
+
handler: (game: Game, now: number, args): null => {
|
286 |
+
const playerId = parseGameId('players', args.playerId);
|
287 |
+
const player = game.world.players.get(playerId);
|
288 |
+
if (!player) {
|
289 |
+
throw new Error(`Invalid player ID: ${playerId}`);
|
290 |
+
}
|
291 |
+
const conversationId = parseGameId('conversations', args.conversationId);
|
292 |
+
const conversation = game.world.conversations.get(conversationId);
|
293 |
+
if (!conversation) {
|
294 |
+
throw new Error(`Invalid conversation ID: ${conversationId}`);
|
295 |
+
}
|
296 |
+
if (conversation.isTyping && conversation.isTyping.playerId !== playerId) {
|
297 |
+
throw new Error(
|
298 |
+
`Player ${conversation.isTyping.playerId} is already typing in ${conversationId}`,
|
299 |
+
);
|
300 |
+
}
|
301 |
+
conversation.isTyping = { playerId, messageUuid: args.messageUuid, since: now };
|
302 |
+
return null;
|
303 |
+
},
|
304 |
+
}),
|
305 |
+
|
306 |
+
finishSendingMessage: inputHandler({
|
307 |
+
args: {
|
308 |
+
playerId,
|
309 |
+
conversationId,
|
310 |
+
timestamp: v.number(),
|
311 |
+
},
|
312 |
+
handler: (game: Game, now: number, args): null => {
|
313 |
+
const playerId = parseGameId('players', args.playerId);
|
314 |
+
const conversationId = parseGameId('conversations', args.conversationId);
|
315 |
+
const conversation = game.world.conversations.get(conversationId);
|
316 |
+
if (!conversation) {
|
317 |
+
throw new Error(`Invalid conversation ID: ${conversationId}`);
|
318 |
+
}
|
319 |
+
if (conversation.isTyping && conversation.isTyping.playerId === playerId) {
|
320 |
+
delete conversation.isTyping;
|
321 |
+
}
|
322 |
+
conversation.lastMessage = { author: playerId, timestamp: args.timestamp };
|
323 |
+
conversation.numMessages++;
|
324 |
+
return null;
|
325 |
+
},
|
326 |
+
}),
|
327 |
+
|
328 |
+
// Accept an invite to a conversation, which puts the
|
329 |
+
// player in the "walkingOver" state until they're close
|
330 |
+
// enough to the other participant.
|
331 |
+
acceptInvite: inputHandler({
|
332 |
+
args: {
|
333 |
+
playerId,
|
334 |
+
conversationId,
|
335 |
+
},
|
336 |
+
handler: (game: Game, now: number, args): null => {
|
337 |
+
const playerId = parseGameId('players', args.playerId);
|
338 |
+
const player = game.world.players.get(playerId);
|
339 |
+
if (!player) {
|
340 |
+
throw new Error(`Invalid player ID ${playerId}`);
|
341 |
+
}
|
342 |
+
const conversationId = parseGameId('conversations', args.conversationId);
|
343 |
+
const conversation = game.world.conversations.get(conversationId);
|
344 |
+
if (!conversation) {
|
345 |
+
throw new Error(`Invalid conversation ID ${conversationId}`);
|
346 |
+
}
|
347 |
+
conversation.acceptInvite(game, player);
|
348 |
+
return null;
|
349 |
+
},
|
350 |
+
}),
|
351 |
+
|
352 |
+
// Reject the invite. Eventually we might add a message
|
353 |
+
// that explains why!
|
354 |
+
rejectInvite: inputHandler({
|
355 |
+
args: {
|
356 |
+
playerId,
|
357 |
+
conversationId,
|
358 |
+
},
|
359 |
+
handler: (game: Game, now: number, args): null => {
|
360 |
+
const playerId = parseGameId('players', args.playerId);
|
361 |
+
const player = game.world.players.get(playerId);
|
362 |
+
if (!player) {
|
363 |
+
throw new Error(`Invalid player ID ${playerId}`);
|
364 |
+
}
|
365 |
+
const conversationId = parseGameId('conversations', args.conversationId);
|
366 |
+
const conversation = game.world.conversations.get(conversationId);
|
367 |
+
if (!conversation) {
|
368 |
+
throw new Error(`Invalid conversation ID ${conversationId}`);
|
369 |
+
}
|
370 |
+
conversation.rejectInvite(game, now, player);
|
371 |
+
return null;
|
372 |
+
},
|
373 |
+
}),
|
374 |
+
// Leave a conversation.
|
375 |
+
leaveConversation: inputHandler({
|
376 |
+
args: {
|
377 |
+
playerId,
|
378 |
+
conversationId,
|
379 |
+
},
|
380 |
+
handler: (game: Game, now: number, args): null => {
|
381 |
+
const playerId = parseGameId('players', args.playerId);
|
382 |
+
const player = game.world.players.get(playerId);
|
383 |
+
if (!player) {
|
384 |
+
throw new Error(`Invalid player ID ${playerId}`);
|
385 |
+
}
|
386 |
+
const conversationId = parseGameId('conversations', args.conversationId);
|
387 |
+
const conversation = game.world.conversations.get(conversationId);
|
388 |
+
if (!conversation) {
|
389 |
+
throw new Error(`Invalid conversation ID ${conversationId}`);
|
390 |
+
}
|
391 |
+
conversation.leave(game, now, player);
|
392 |
+
return null;
|
393 |
+
},
|
394 |
+
}),
|
395 |
+
};
|
patches/convex/aiTown/conversationMembership.ts
ADDED
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { ObjectType, v } from 'convex/values';
|
2 |
+
import { GameId, parseGameId, playerId } from './ids';
|
3 |
+
|
4 |
+
export const serializedConversationMembership = {
|
5 |
+
playerId,
|
6 |
+
invited: v.number(),
|
7 |
+
status: v.union(
|
8 |
+
v.object({ kind: v.literal('invited') }),
|
9 |
+
v.object({ kind: v.literal('walkingOver') }),
|
10 |
+
v.object({ kind: v.literal('participating'), started: v.number() }),
|
11 |
+
),
|
12 |
+
};
|
13 |
+
export type SerializedConversationMembership = ObjectType<typeof serializedConversationMembership>;
|
14 |
+
|
15 |
+
export class ConversationMembership {
|
16 |
+
playerId: GameId<'players'>;
|
17 |
+
invited: number;
|
18 |
+
status:
|
19 |
+
| { kind: 'invited' }
|
20 |
+
| { kind: 'walkingOver' }
|
21 |
+
| { kind: 'participating'; started: number };
|
22 |
+
|
23 |
+
constructor(serialized: SerializedConversationMembership) {
|
24 |
+
const { playerId, invited, status } = serialized;
|
25 |
+
this.playerId = parseGameId('players', playerId);
|
26 |
+
this.invited = invited;
|
27 |
+
this.status = status;
|
28 |
+
}
|
29 |
+
|
30 |
+
serialize(): SerializedConversationMembership {
|
31 |
+
const { playerId, invited, status } = this;
|
32 |
+
return {
|
33 |
+
playerId,
|
34 |
+
invited,
|
35 |
+
status,
|
36 |
+
};
|
37 |
+
}
|
38 |
+
}
|
patches/convex/aiTown/dayNightCycle.ts
ADDED
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { v, Infer, ObjectType } from 'convex/values';
|
2 |
+
import { Game } from './game';
|
3 |
+
|
4 |
+
// Define the schema for DayNightCycle
|
5 |
+
export const dayNightCycleSchema = {
|
6 |
+
currentTime: v.number(),
|
7 |
+
isDay: v.boolean(),
|
8 |
+
dayDuration: v.number(),
|
9 |
+
nightDuration: v.number(),
|
10 |
+
};
|
11 |
+
export type SerializedDayNightCycle = ObjectType<typeof dayNightCycleSchema>;
|
12 |
+
|
13 |
+
export class DayNightCycle {
|
14 |
+
currentTime: number;
|
15 |
+
isDay: boolean;
|
16 |
+
dayDuration: number;
|
17 |
+
nightDuration: number;
|
18 |
+
|
19 |
+
constructor(serialized: SerializedDayNightCycle) {
|
20 |
+
const { currentTime, isDay, dayDuration, nightDuration } = serialized;
|
21 |
+
this.currentTime = currentTime;
|
22 |
+
this.isDay = isDay;
|
23 |
+
this.dayDuration = dayDuration;
|
24 |
+
this.nightDuration = nightDuration;
|
25 |
+
}
|
26 |
+
|
27 |
+
// Tick method to increment the counter
|
28 |
+
tick(game: Game, tickDuration: number) {
|
29 |
+
this.currentTime += tickDuration;
|
30 |
+
|
31 |
+
if (this.isDay && this.currentTime >= this.dayDuration) {
|
32 |
+
this.isDay = false;
|
33 |
+
this.currentTime = 0;
|
34 |
+
this.onNightStart(game);
|
35 |
+
} else if (!this.isDay && this.currentTime >= this.nightDuration) {
|
36 |
+
this.isDay = true;
|
37 |
+
this.currentTime = 0;
|
38 |
+
this.onDayStart(game);
|
39 |
+
}
|
40 |
+
}
|
41 |
+
|
42 |
+
onDayStart(game: Game) {
|
43 |
+
console.log("Day has started!");
|
44 |
+
for (const player of game.world.players.values()) {
|
45 |
+
// player.onDayStart(game);
|
46 |
+
}
|
47 |
+
for (const agent of game.world.agents.values()) {
|
48 |
+
// agent.onDayStart(game);
|
49 |
+
}
|
50 |
+
}
|
51 |
+
|
52 |
+
onNightStart(game: Game) {
|
53 |
+
console.log("Night has started!");
|
54 |
+
for (const player of game.world.players.values()) {
|
55 |
+
// player.onNightStart(game);
|
56 |
+
}
|
57 |
+
for (const agent of game.world.agents.values()) {
|
58 |
+
// agent.onNightStart(game);
|
59 |
+
}
|
60 |
+
}
|
61 |
+
|
62 |
+
serialize(): SerializedDayNightCycle {
|
63 |
+
const { currentTime, isDay, dayDuration, nightDuration } = this;
|
64 |
+
return {
|
65 |
+
currentTime,
|
66 |
+
isDay,
|
67 |
+
dayDuration,
|
68 |
+
nightDuration,
|
69 |
+
};
|
70 |
+
}
|
71 |
+
}
|
patches/convex/aiTown/game.ts
ADDED
@@ -0,0 +1,374 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Infer, v } from 'convex/values';
|
2 |
+
import { Doc, Id } from '../_generated/dataModel';
|
3 |
+
import {
|
4 |
+
ActionCtx,
|
5 |
+
DatabaseReader,
|
6 |
+
MutationCtx,
|
7 |
+
internalMutation,
|
8 |
+
internalQuery,
|
9 |
+
} from '../_generated/server';
|
10 |
+
import { World, serializedWorld } from './world';
|
11 |
+
import { WorldMap, serializedWorldMap } from './worldMap';
|
12 |
+
import { PlayerDescription, serializedPlayerDescription } from './playerDescription';
|
13 |
+
import { Location, locationFields, playerLocation } from './location';
|
14 |
+
import { runAgentOperation } from './agent';
|
15 |
+
import { GameId, IdTypes, allocGameId } from './ids';
|
16 |
+
import { InputArgs, InputNames, inputs } from './inputs';
|
17 |
+
import {
|
18 |
+
AbstractGame,
|
19 |
+
EngineUpdate,
|
20 |
+
applyEngineUpdate,
|
21 |
+
engineUpdate,
|
22 |
+
loadEngine,
|
23 |
+
} from '../engine/abstractGame';
|
24 |
+
import { internal } from '../_generated/api';
|
25 |
+
import { HistoricalObject } from '../engine/historicalObject';
|
26 |
+
import { AgentDescription, serializedAgentDescription } from './agentDescription';
|
27 |
+
import { parseMap, serializeMap } from '../util/object';
|
28 |
+
|
29 |
+
const gameState = v.object({
|
30 |
+
world: v.object(serializedWorld),
|
31 |
+
playerDescriptions: v.array(v.object(serializedPlayerDescription)),
|
32 |
+
agentDescriptions: v.array(v.object(serializedAgentDescription)),
|
33 |
+
worldMap: v.object(serializedWorldMap),
|
34 |
+
});
|
35 |
+
type GameState = Infer<typeof gameState>;
|
36 |
+
|
37 |
+
const gameStateDiff = v.object({
|
38 |
+
world: v.object(serializedWorld),
|
39 |
+
playerDescriptions: v.optional(v.array(v.object(serializedPlayerDescription))),
|
40 |
+
agentDescriptions: v.optional(v.array(v.object(serializedAgentDescription))),
|
41 |
+
worldMap: v.optional(v.object(serializedWorldMap)),
|
42 |
+
agentOperations: v.array(v.object({ name: v.string(), args: v.any() })),
|
43 |
+
});
|
44 |
+
type GameStateDiff = Infer<typeof gameStateDiff>;
|
45 |
+
|
46 |
+
export class Game extends AbstractGame {
|
47 |
+
tickDuration = 16;
|
48 |
+
stepDuration = 1000;
|
49 |
+
maxTicksPerStep = 600;
|
50 |
+
maxInputsPerStep = 32;
|
51 |
+
|
52 |
+
world: World;
|
53 |
+
|
54 |
+
historicalLocations: Map<GameId<'players'>, HistoricalObject<Location>>;
|
55 |
+
|
56 |
+
descriptionsModified: boolean;
|
57 |
+
worldMap: WorldMap;
|
58 |
+
playerDescriptions: Map<GameId<'players'>, PlayerDescription>;
|
59 |
+
agentDescriptions: Map<GameId<'agents'>, AgentDescription>;
|
60 |
+
|
61 |
+
pendingOperations: Array<{ name: string; args: any }> = [];
|
62 |
+
|
63 |
+
numPathfinds: number;
|
64 |
+
|
65 |
+
constructor(
|
66 |
+
engine: Doc<'engines'>,
|
67 |
+
public worldId: Id<'worlds'>,
|
68 |
+
state: GameState,
|
69 |
+
) {
|
70 |
+
super(engine);
|
71 |
+
|
72 |
+
this.world = new World(state.world);
|
73 |
+
delete this.world.historicalLocations;
|
74 |
+
|
75 |
+
this.descriptionsModified = false;
|
76 |
+
this.worldMap = new WorldMap(state.worldMap);
|
77 |
+
this.agentDescriptions = parseMap(state.agentDescriptions, AgentDescription, (a) => a.agentId);
|
78 |
+
this.playerDescriptions = parseMap(
|
79 |
+
state.playerDescriptions,
|
80 |
+
PlayerDescription,
|
81 |
+
(p) => p.playerId,
|
82 |
+
);
|
83 |
+
|
84 |
+
this.historicalLocations = new Map();
|
85 |
+
|
86 |
+
this.numPathfinds = 0;
|
87 |
+
}
|
88 |
+
|
89 |
+
static async load(
|
90 |
+
db: DatabaseReader,
|
91 |
+
worldId: Id<'worlds'>,
|
92 |
+
generationNumber: number,
|
93 |
+
): Promise<{ engine: Doc<'engines'>; gameState: GameState }> {
|
94 |
+
const worldDoc = await db.get(worldId);
|
95 |
+
if (!worldDoc) {
|
96 |
+
throw new Error(`No world found with id ${worldId}`);
|
97 |
+
}
|
98 |
+
const worldStatus = await db
|
99 |
+
.query('worldStatus')
|
100 |
+
.withIndex('worldId', (q) => q.eq('worldId', worldId))
|
101 |
+
.unique();
|
102 |
+
if (!worldStatus) {
|
103 |
+
throw new Error(`No engine found for world ${worldId}`);
|
104 |
+
}
|
105 |
+
const engine = await loadEngine(db, worldStatus.engineId, generationNumber);
|
106 |
+
const playerDescriptionsDocs = await db
|
107 |
+
.query('playerDescriptions')
|
108 |
+
.withIndex('worldId', (q) => q.eq('worldId', worldId))
|
109 |
+
.collect();
|
110 |
+
const agentDescriptionsDocs = await db
|
111 |
+
.query('agentDescriptions')
|
112 |
+
.withIndex('worldId', (q) => q.eq('worldId', worldId))
|
113 |
+
.collect();
|
114 |
+
const worldMapDoc = await db
|
115 |
+
.query('maps')
|
116 |
+
.withIndex('worldId', (q) => q.eq('worldId', worldId))
|
117 |
+
.unique();
|
118 |
+
if (!worldMapDoc) {
|
119 |
+
throw new Error(`No map found for world ${worldId}`);
|
120 |
+
}
|
121 |
+
// Discard the system fields and historicalLocations from the world state.
|
122 |
+
const { _id, _creationTime, historicalLocations: _, ...world } = worldDoc;
|
123 |
+
const playerDescriptions = playerDescriptionsDocs
|
124 |
+
// Discard player descriptions for players that no longer exist.
|
125 |
+
.filter((d) => !!world.players.find((p) => p.id === d.playerId))
|
126 |
+
.map(({ _id, _creationTime, worldId: _, ...doc }) => doc);
|
127 |
+
const agentDescriptions = agentDescriptionsDocs
|
128 |
+
.filter((a) => !!world.agents.find((p) => p.id === a.agentId))
|
129 |
+
.map(({ _id, _creationTime, worldId: _, ...doc }) => doc);
|
130 |
+
const {
|
131 |
+
_id: _mapId,
|
132 |
+
_creationTime: _mapCreationTime,
|
133 |
+
worldId: _mapWorldId,
|
134 |
+
...worldMap
|
135 |
+
} = worldMapDoc;
|
136 |
+
return {
|
137 |
+
engine,
|
138 |
+
gameState: {
|
139 |
+
world,
|
140 |
+
playerDescriptions,
|
141 |
+
agentDescriptions,
|
142 |
+
worldMap,
|
143 |
+
},
|
144 |
+
};
|
145 |
+
}
|
146 |
+
|
147 |
+
allocId<T extends IdTypes>(idType: T): GameId<T> {
|
148 |
+
const id = allocGameId(idType, this.world.nextId);
|
149 |
+
this.world.nextId += 1;
|
150 |
+
return id;
|
151 |
+
}
|
152 |
+
|
153 |
+
scheduleOperation(name: string, args: unknown) {
|
154 |
+
this.pendingOperations.push({ name, args });
|
155 |
+
}
|
156 |
+
|
157 |
+
handleInput<Name extends InputNames>(now: number, name: Name, args: InputArgs<Name>) {
|
158 |
+
const handler = inputs[name]?.handler;
|
159 |
+
if (!handler) {
|
160 |
+
throw new Error(`Invalid input: ${name}`);
|
161 |
+
}
|
162 |
+
return handler(this, now, args as any);
|
163 |
+
}
|
164 |
+
|
165 |
+
beginStep(_now: number) {
|
166 |
+
// Store the current location of all players in the history tracking buffer.
|
167 |
+
this.historicalLocations.clear();
|
168 |
+
for (const player of this.world.players.values()) {
|
169 |
+
this.historicalLocations.set(
|
170 |
+
player.id,
|
171 |
+
new HistoricalObject(locationFields, playerLocation(player)),
|
172 |
+
);
|
173 |
+
}
|
174 |
+
this.numPathfinds = 0;
|
175 |
+
}
|
176 |
+
|
177 |
+
tick(now: number) {
|
178 |
+
// update day&nigth cycle counter
|
179 |
+
this.world.dayNightCycle.tick(this, this.tickDuration);
|
180 |
+
|
181 |
+
for (const player of this.world.players.values()) {
|
182 |
+
player.tick(this, now);
|
183 |
+
}
|
184 |
+
for (const player of this.world.players.values()) {
|
185 |
+
player.tickPathfinding(this, now);
|
186 |
+
}
|
187 |
+
for (const player of this.world.players.values()) {
|
188 |
+
player.tickPosition(this, now);
|
189 |
+
}
|
190 |
+
for (const conversation of this.world.conversations.values()) {
|
191 |
+
conversation.tick(this, now);
|
192 |
+
}
|
193 |
+
for (const agent of this.world.agents.values()) {
|
194 |
+
agent.tick(this, now);
|
195 |
+
}
|
196 |
+
|
197 |
+
// Save each player's location into the history buffer at the end of
|
198 |
+
// each tick.
|
199 |
+
for (const player of this.world.players.values()) {
|
200 |
+
let historicalObject = this.historicalLocations.get(player.id);
|
201 |
+
if (!historicalObject) {
|
202 |
+
historicalObject = new HistoricalObject(locationFields, playerLocation(player));
|
203 |
+
this.historicalLocations.set(player.id, historicalObject);
|
204 |
+
}
|
205 |
+
historicalObject.update(now, playerLocation(player));
|
206 |
+
}
|
207 |
+
}
|
208 |
+
|
209 |
+
async saveStep(ctx: ActionCtx, engineUpdate: EngineUpdate): Promise<void> {
|
210 |
+
const diff = this.takeDiff();
|
211 |
+
await ctx.runMutation(internal.aiTown.game.saveWorld, {
|
212 |
+
engineId: this.engine._id,
|
213 |
+
engineUpdate,
|
214 |
+
worldId: this.worldId,
|
215 |
+
worldDiff: diff,
|
216 |
+
});
|
217 |
+
}
|
218 |
+
|
219 |
+
takeDiff(): GameStateDiff {
|
220 |
+
const historicalLocations = [];
|
221 |
+
let bufferSize = 0;
|
222 |
+
for (const [id, historicalObject] of this.historicalLocations.entries()) {
|
223 |
+
const buffer = historicalObject.pack();
|
224 |
+
if (!buffer) {
|
225 |
+
continue;
|
226 |
+
}
|
227 |
+
historicalLocations.push({ playerId: id, location: buffer });
|
228 |
+
bufferSize += buffer.byteLength;
|
229 |
+
}
|
230 |
+
if (bufferSize > 0) {
|
231 |
+
console.debug(
|
232 |
+
`Packed ${Object.entries(historicalLocations).length} history buffers in ${(
|
233 |
+
bufferSize / 1024
|
234 |
+
).toFixed(2)}KiB.`,
|
235 |
+
);
|
236 |
+
}
|
237 |
+
this.historicalLocations.clear();
|
238 |
+
|
239 |
+
const result: GameStateDiff = {
|
240 |
+
world: { ...this.world.serialize(), historicalLocations },
|
241 |
+
agentOperations: this.pendingOperations,
|
242 |
+
};
|
243 |
+
this.pendingOperations = [];
|
244 |
+
if (this.descriptionsModified) {
|
245 |
+
result.playerDescriptions = serializeMap(this.playerDescriptions);
|
246 |
+
result.agentDescriptions = serializeMap(this.agentDescriptions);
|
247 |
+
result.worldMap = this.worldMap.serialize();
|
248 |
+
this.descriptionsModified = false;
|
249 |
+
}
|
250 |
+
return result;
|
251 |
+
}
|
252 |
+
|
253 |
+
static async saveDiff(ctx: MutationCtx, worldId: Id<'worlds'>, diff: GameStateDiff) {
|
254 |
+
const existingWorld = await ctx.db.get(worldId);
|
255 |
+
if (!existingWorld) {
|
256 |
+
throw new Error(`No world found with id ${worldId}`);
|
257 |
+
}
|
258 |
+
const newWorld = diff.world;
|
259 |
+
// Archive newly deleted players, conversations, and agents.
|
260 |
+
for (const player of existingWorld.players) {
|
261 |
+
if (!newWorld.players.some((p) => p.id === player.id)) {
|
262 |
+
await ctx.db.insert('archivedPlayers', { worldId, ...player });
|
263 |
+
}
|
264 |
+
}
|
265 |
+
for (const conversation of existingWorld.conversations) {
|
266 |
+
if (!newWorld.conversations.some((c) => c.id === conversation.id)) {
|
267 |
+
const participants = conversation.participants.map((p) => p.playerId);
|
268 |
+
const archivedConversation = {
|
269 |
+
worldId,
|
270 |
+
id: conversation.id,
|
271 |
+
created: conversation.created,
|
272 |
+
creator: conversation.creator,
|
273 |
+
ended: Date.now(),
|
274 |
+
lastMessage: conversation.lastMessage,
|
275 |
+
numMessages: conversation.numMessages,
|
276 |
+
participants,
|
277 |
+
};
|
278 |
+
await ctx.db.insert('archivedConversations', archivedConversation);
|
279 |
+
for (let i = 0; i < participants.length; i++) {
|
280 |
+
for (let j = 0; j < participants.length; j++) {
|
281 |
+
if (i == j) {
|
282 |
+
continue;
|
283 |
+
}
|
284 |
+
const player1 = participants[i];
|
285 |
+
const player2 = participants[j];
|
286 |
+
await ctx.db.insert('participatedTogether', {
|
287 |
+
worldId,
|
288 |
+
conversationId: conversation.id,
|
289 |
+
player1,
|
290 |
+
player2,
|
291 |
+
ended: Date.now(),
|
292 |
+
});
|
293 |
+
}
|
294 |
+
}
|
295 |
+
}
|
296 |
+
}
|
297 |
+
for (const conversation of existingWorld.agents) {
|
298 |
+
if (!newWorld.agents.some((a) => a.id === conversation.id)) {
|
299 |
+
await ctx.db.insert('archivedAgents', { worldId, ...conversation });
|
300 |
+
}
|
301 |
+
}
|
302 |
+
// Update the world state.
|
303 |
+
await ctx.db.replace(worldId, newWorld);
|
304 |
+
|
305 |
+
// Update the larger description tables if they changed.
|
306 |
+
const { playerDescriptions, agentDescriptions, worldMap } = diff;
|
307 |
+
if (playerDescriptions) {
|
308 |
+
for (const description of playerDescriptions) {
|
309 |
+
const existing = await ctx.db
|
310 |
+
.query('playerDescriptions')
|
311 |
+
.withIndex('worldId', (q) =>
|
312 |
+
q.eq('worldId', worldId).eq('playerId', description.playerId),
|
313 |
+
)
|
314 |
+
.unique();
|
315 |
+
if (existing) {
|
316 |
+
await ctx.db.replace(existing._id, { worldId, ...description });
|
317 |
+
} else {
|
318 |
+
await ctx.db.insert('playerDescriptions', { worldId, ...description });
|
319 |
+
}
|
320 |
+
}
|
321 |
+
}
|
322 |
+
if (agentDescriptions) {
|
323 |
+
for (const description of agentDescriptions) {
|
324 |
+
const existing = await ctx.db
|
325 |
+
.query('agentDescriptions')
|
326 |
+
.withIndex('worldId', (q) => q.eq('worldId', worldId).eq('agentId', description.agentId))
|
327 |
+
.unique();
|
328 |
+
if (existing) {
|
329 |
+
await ctx.db.replace(existing._id, { worldId, ...description });
|
330 |
+
} else {
|
331 |
+
await ctx.db.insert('agentDescriptions', { worldId, ...description });
|
332 |
+
}
|
333 |
+
}
|
334 |
+
}
|
335 |
+
if (worldMap) {
|
336 |
+
const existing = await ctx.db
|
337 |
+
.query('maps')
|
338 |
+
.withIndex('worldId', (q) => q.eq('worldId', worldId))
|
339 |
+
.unique();
|
340 |
+
if (existing) {
|
341 |
+
await ctx.db.replace(existing._id, { worldId, ...worldMap });
|
342 |
+
} else {
|
343 |
+
await ctx.db.insert('maps', { worldId, ...worldMap });
|
344 |
+
}
|
345 |
+
}
|
346 |
+
// Start the desired agent operations.
|
347 |
+
for (const operation of diff.agentOperations) {
|
348 |
+
await runAgentOperation(ctx, operation.name, operation.args);
|
349 |
+
}
|
350 |
+
}
|
351 |
+
}
|
352 |
+
|
353 |
+
export const loadWorld = internalQuery({
|
354 |
+
args: {
|
355 |
+
worldId: v.id('worlds'),
|
356 |
+
generationNumber: v.number(),
|
357 |
+
},
|
358 |
+
handler: async (ctx, args) => {
|
359 |
+
return await Game.load(ctx.db, args.worldId, args.generationNumber);
|
360 |
+
},
|
361 |
+
});
|
362 |
+
|
363 |
+
export const saveWorld = internalMutation({
|
364 |
+
args: {
|
365 |
+
engineId: v.id('engines'),
|
366 |
+
engineUpdate,
|
367 |
+
worldId: v.id('worlds'),
|
368 |
+
worldDiff: gameStateDiff,
|
369 |
+
},
|
370 |
+
handler: async (ctx, args) => {
|
371 |
+
await applyEngineUpdate(ctx, args.engineId, args.engineUpdate);
|
372 |
+
await Game.saveDiff(ctx, args.worldId, args.worldDiff);
|
373 |
+
},
|
374 |
+
});
|
patches/convex/aiTown/ids.ts
ADDED
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { v } from 'convex/values';
|
2 |
+
|
3 |
+
const IdShortCodes = { agents: 'a', conversations: 'c', players: 'p', operations: 'o' };
|
4 |
+
export type IdTypes = keyof typeof IdShortCodes;
|
5 |
+
|
6 |
+
export type GameId<T extends IdTypes> = string & { __type: T };
|
7 |
+
|
8 |
+
export function parseGameId<T extends IdTypes>(idType: T, gameId: string): GameId<T> {
|
9 |
+
const type = gameId[0];
|
10 |
+
const match = Object.entries(IdShortCodes).find(([_, value]) => value === type);
|
11 |
+
if (!match || match[0] !== idType) {
|
12 |
+
throw new Error(`Invalid game ID type: ${type}`);
|
13 |
+
}
|
14 |
+
const number = parseInt(gameId.slice(2), 10);
|
15 |
+
if (isNaN(number) || !Number.isInteger(number) || number < 0) {
|
16 |
+
throw new Error(`Invalid game ID number: ${gameId}`);
|
17 |
+
}
|
18 |
+
return gameId as GameId<T>;
|
19 |
+
}
|
20 |
+
|
21 |
+
export function allocGameId<T extends IdTypes>(idType: T, idNumber: number): GameId<T> {
|
22 |
+
const type = IdShortCodes[idType];
|
23 |
+
if (!type) {
|
24 |
+
throw new Error(`Invalid game ID type: ${idType}`);
|
25 |
+
}
|
26 |
+
return `${type}:${idNumber}` as GameId<T>;
|
27 |
+
}
|
28 |
+
|
29 |
+
export const conversationId = v.string();
|
30 |
+
export const playerId = v.string();
|
31 |
+
export const agentId = v.string();
|
32 |
+
export const operationId = v.string();
|
patches/convex/aiTown/inputHandler.ts
ADDED
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { ObjectType, PropertyValidators, Value } from 'convex/values';
|
2 |
+
import type { Game } from './game';
|
3 |
+
|
4 |
+
export function inputHandler<ArgsValidator extends PropertyValidators, Return extends Value>(def: {
|
5 |
+
args: ArgsValidator;
|
6 |
+
handler: (game: Game, now: number, args: ObjectType<ArgsValidator>) => Return;
|
7 |
+
}) {
|
8 |
+
return def;
|
9 |
+
}
|
patches/convex/aiTown/inputs.ts
ADDED
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { ObjectType } from 'convex/values';
|
2 |
+
import { playerInputs } from './player';
|
3 |
+
import { conversationInputs } from './conversation';
|
4 |
+
import { agentInputs } from './agentInputs';
|
5 |
+
|
6 |
+
// It's easy to hit circular dependencies with these imports,
|
7 |
+
// so assert at module scope so we hit errors when analyzing.
|
8 |
+
if (playerInputs === undefined || conversationInputs === undefined || agentInputs === undefined) {
|
9 |
+
throw new Error("Input map is undefined, check if there's a circular import.");
|
10 |
+
}
|
11 |
+
export const inputs = {
|
12 |
+
...playerInputs,
|
13 |
+
// Inputs for the messaging layer.
|
14 |
+
...conversationInputs,
|
15 |
+
// Inputs for the agent layer.
|
16 |
+
...agentInputs,
|
17 |
+
};
|
18 |
+
export type Inputs = typeof inputs;
|
19 |
+
export type InputNames = keyof Inputs;
|
20 |
+
export type InputArgs<Name extends InputNames> = ObjectType<Inputs[Name]['args']>;
|
21 |
+
export type InputReturnValue<Name extends InputNames> = ReturnType<
|
22 |
+
Inputs[Name]['handler']
|
23 |
+
> extends Promise<infer T>
|
24 |
+
? T
|
25 |
+
: never;
|
patches/convex/aiTown/insertInput.ts
ADDED
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { MutationCtx } from '../_generated/server';
|
2 |
+
import { Id } from '../_generated/dataModel';
|
3 |
+
import { engineInsertInput } from '../engine/abstractGame';
|
4 |
+
import { InputNames, InputArgs } from './inputs';
|
5 |
+
|
6 |
+
export async function insertInput<Name extends InputNames>(
|
7 |
+
ctx: MutationCtx,
|
8 |
+
worldId: Id<'worlds'>,
|
9 |
+
name: Name,
|
10 |
+
args: InputArgs<Name>,
|
11 |
+
): Promise<Id<'inputs'>> {
|
12 |
+
const worldStatus = await ctx.db
|
13 |
+
.query('worldStatus')
|
14 |
+
.withIndex('worldId', (q) => q.eq('worldId', worldId))
|
15 |
+
.unique();
|
16 |
+
if (!worldStatus) {
|
17 |
+
throw new Error(`World for engine ${worldId} not found`);
|
18 |
+
}
|
19 |
+
return await engineInsertInput(ctx, worldStatus.engineId, name, args);
|
20 |
+
}
|
patches/convex/aiTown/location.ts
ADDED
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { FieldConfig } from '../engine/historicalObject';
|
2 |
+
import { Player } from './player';
|
3 |
+
|
4 |
+
export type Location = {
|
5 |
+
// Unpacked player position.
|
6 |
+
x: number;
|
7 |
+
y: number;
|
8 |
+
|
9 |
+
// Normalized facing vector.
|
10 |
+
dx: number;
|
11 |
+
dy: number;
|
12 |
+
|
13 |
+
speed: number;
|
14 |
+
};
|
15 |
+
|
16 |
+
export const locationFields: FieldConfig = [
|
17 |
+
{ name: 'x', precision: 8 },
|
18 |
+
{ name: 'y', precision: 8 },
|
19 |
+
{ name: 'dx', precision: 8 },
|
20 |
+
{ name: 'dy', precision: 8 },
|
21 |
+
{ name: 'speed', precision: 16 },
|
22 |
+
];
|
23 |
+
|
24 |
+
export function playerLocation(player: Player): Location {
|
25 |
+
return {
|
26 |
+
x: player.position.x,
|
27 |
+
y: player.position.y,
|
28 |
+
dx: player.facing.dx,
|
29 |
+
dy: player.facing.dy,
|
30 |
+
speed: player.speed,
|
31 |
+
};
|
32 |
+
}
|
patches/convex/aiTown/main.ts
ADDED
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { ConvexError, v } from 'convex/values';
|
2 |
+
import { DatabaseReader, MutationCtx, internalAction, mutation, query } from '../_generated/server';
|
3 |
+
import { insertInput } from './insertInput';
|
4 |
+
import { Game } from './game';
|
5 |
+
import { internal } from '../_generated/api';
|
6 |
+
import { sleep } from '../util/sleep';
|
7 |
+
import { Id } from '../_generated/dataModel';
|
8 |
+
import { ENGINE_ACTION_DURATION } from '../constants';
|
9 |
+
|
10 |
+
export async function createEngine(ctx: MutationCtx) {
|
11 |
+
const now = Date.now();
|
12 |
+
const engineId = await ctx.db.insert('engines', {
|
13 |
+
currentTime: now,
|
14 |
+
generationNumber: 0,
|
15 |
+
running: true,
|
16 |
+
});
|
17 |
+
return engineId;
|
18 |
+
}
|
19 |
+
|
20 |
+
async function loadWorldStatus(db: DatabaseReader, worldId: Id<'worlds'>) {
|
21 |
+
const worldStatus = await db
|
22 |
+
.query('worldStatus')
|
23 |
+
.withIndex('worldId', (q) => q.eq('worldId', worldId))
|
24 |
+
.unique();
|
25 |
+
if (!worldStatus) {
|
26 |
+
throw new Error(`No engine found for world ${worldId}`);
|
27 |
+
}
|
28 |
+
return worldStatus;
|
29 |
+
}
|
30 |
+
|
31 |
+
export async function startEngine(ctx: MutationCtx, worldId: Id<'worlds'>) {
|
32 |
+
const { engineId } = await loadWorldStatus(ctx.db, worldId);
|
33 |
+
const engine = await ctx.db.get(engineId);
|
34 |
+
if (!engine) {
|
35 |
+
throw new Error(`Invalid engine ID: ${engineId}`);
|
36 |
+
}
|
37 |
+
if (engine.running) {
|
38 |
+
throw new Error(`Engine ${engineId} isn't currently stopped`);
|
39 |
+
}
|
40 |
+
const now = Date.now();
|
41 |
+
const generationNumber = engine.generationNumber + 1;
|
42 |
+
await ctx.db.patch(engineId, {
|
43 |
+
// Forcibly advance time to the present. This does mean we'll skip
|
44 |
+
// simulating the time the engine was stopped, but we don't want
|
45 |
+
// to have to simulate a potentially large stopped window and send
|
46 |
+
// it down to clients.
|
47 |
+
lastStepTs: engine.currentTime,
|
48 |
+
currentTime: now,
|
49 |
+
running: true,
|
50 |
+
generationNumber,
|
51 |
+
});
|
52 |
+
await ctx.scheduler.runAfter(0, internal.aiTown.main.runStep, {
|
53 |
+
worldId: worldId,
|
54 |
+
generationNumber,
|
55 |
+
maxDuration: ENGINE_ACTION_DURATION,
|
56 |
+
});
|
57 |
+
}
|
58 |
+
|
59 |
+
export async function kickEngine(ctx: MutationCtx, worldId: Id<'worlds'>) {
|
60 |
+
const { engineId } = await loadWorldStatus(ctx.db, worldId);
|
61 |
+
const engine = await ctx.db.get(engineId);
|
62 |
+
if (!engine) {
|
63 |
+
throw new Error(`Invalid engine ID: ${engineId}`);
|
64 |
+
}
|
65 |
+
if (!engine.running) {
|
66 |
+
throw new Error(`Engine ${engineId} isn't currently running`);
|
67 |
+
}
|
68 |
+
const generationNumber = engine.generationNumber + 1;
|
69 |
+
await ctx.db.patch(engineId, { generationNumber });
|
70 |
+
await ctx.scheduler.runAfter(0, internal.aiTown.main.runStep, {
|
71 |
+
worldId: worldId,
|
72 |
+
generationNumber,
|
73 |
+
maxDuration: ENGINE_ACTION_DURATION,
|
74 |
+
});
|
75 |
+
}
|
76 |
+
|
77 |
+
export async function stopEngine(ctx: MutationCtx, worldId: Id<'worlds'>) {
|
78 |
+
const { engineId } = await loadWorldStatus(ctx.db, worldId);
|
79 |
+
const engine = await ctx.db.get(engineId);
|
80 |
+
if (!engine) {
|
81 |
+
throw new Error(`Invalid engine ID: ${engineId}`);
|
82 |
+
}
|
83 |
+
if (!engine.running) {
|
84 |
+
throw new Error(`Engine ${engineId} isn't currently running`);
|
85 |
+
}
|
86 |
+
await ctx.db.patch(engineId, { running: false });
|
87 |
+
}
|
88 |
+
|
89 |
+
export const runStep = internalAction({
|
90 |
+
args: {
|
91 |
+
worldId: v.id('worlds'),
|
92 |
+
generationNumber: v.number(),
|
93 |
+
maxDuration: v.number(),
|
94 |
+
},
|
95 |
+
handler: async (ctx, args) => {
|
96 |
+
try {
|
97 |
+
const { engine, gameState } = await ctx.runQuery(internal.aiTown.game.loadWorld, {
|
98 |
+
worldId: args.worldId,
|
99 |
+
generationNumber: args.generationNumber,
|
100 |
+
});
|
101 |
+
const game = new Game(engine, args.worldId, gameState);
|
102 |
+
|
103 |
+
let now = Date.now();
|
104 |
+
const deadline = now + args.maxDuration;
|
105 |
+
while (now < deadline) {
|
106 |
+
await game.runStep(ctx, now);
|
107 |
+
const sleepUntil = Math.min(now + game.stepDuration, deadline);
|
108 |
+
await sleep(sleepUntil - now);
|
109 |
+
now = Date.now();
|
110 |
+
}
|
111 |
+
await ctx.scheduler.runAfter(0, internal.aiTown.main.runStep, {
|
112 |
+
worldId: args.worldId,
|
113 |
+
generationNumber: game.engine.generationNumber,
|
114 |
+
maxDuration: args.maxDuration,
|
115 |
+
});
|
116 |
+
} catch (e: unknown) {
|
117 |
+
if (e instanceof ConvexError) {
|
118 |
+
if (e.data.kind === 'engineNotRunning') {
|
119 |
+
console.debug(`Engine is not running: ${e.message}`);
|
120 |
+
return;
|
121 |
+
}
|
122 |
+
if (e.data.kind === 'generationNumber') {
|
123 |
+
console.debug(`Generation number mismatch: ${e.message}`);
|
124 |
+
return;
|
125 |
+
}
|
126 |
+
}
|
127 |
+
throw e;
|
128 |
+
}
|
129 |
+
},
|
130 |
+
});
|
131 |
+
|
132 |
+
export const sendInput = mutation({
|
133 |
+
args: {
|
134 |
+
worldId: v.id('worlds'),
|
135 |
+
name: v.string(),
|
136 |
+
args: v.any(),
|
137 |
+
},
|
138 |
+
handler: async (ctx, args) => {
|
139 |
+
return await insertInput(ctx, args.worldId, args.name as any, args.args);
|
140 |
+
},
|
141 |
+
});
|
142 |
+
|
143 |
+
export const inputStatus = query({
|
144 |
+
args: {
|
145 |
+
inputId: v.id('inputs'),
|
146 |
+
},
|
147 |
+
handler: async (ctx, args) => {
|
148 |
+
const input = await ctx.db.get(args.inputId);
|
149 |
+
if (!input) {
|
150 |
+
throw new Error(`Invalid input ID: ${args.inputId}`);
|
151 |
+
}
|
152 |
+
return input.returnValue ?? null;
|
153 |
+
},
|
154 |
+
});
|
patches/convex/aiTown/movement.ts
ADDED
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { movementSpeed } from '../../data/characters';
|
2 |
+
import { COLLISION_THRESHOLD } from '../constants';
|
3 |
+
import { compressPath, distance, manhattanDistance, pointsEqual } from '../util/geometry';
|
4 |
+
import { MinHeap } from '../util/minheap';
|
5 |
+
import { Point, Vector } from '../util/types';
|
6 |
+
import { Game } from './game';
|
7 |
+
import { GameId } from './ids';
|
8 |
+
import { Player } from './player';
|
9 |
+
import { WorldMap } from './worldMap';
|
10 |
+
|
11 |
+
type PathCandidate = {
|
12 |
+
position: Point;
|
13 |
+
facing?: Vector;
|
14 |
+
t: number;
|
15 |
+
length: number;
|
16 |
+
cost: number;
|
17 |
+
prev?: PathCandidate;
|
18 |
+
};
|
19 |
+
|
20 |
+
export function stopPlayer(player: Player) {
|
21 |
+
delete player.pathfinding;
|
22 |
+
player.speed = 0;
|
23 |
+
}
|
24 |
+
|
25 |
+
export function movePlayer(
|
26 |
+
game: Game,
|
27 |
+
now: number,
|
28 |
+
player: Player,
|
29 |
+
destination: Point,
|
30 |
+
allowInConversation?: boolean,
|
31 |
+
) {
|
32 |
+
if (Math.floor(destination.x) !== destination.x || Math.floor(destination.y) !== destination.y) {
|
33 |
+
throw new Error(`Non-integral destination: ${JSON.stringify(destination)}`);
|
34 |
+
}
|
35 |
+
const { position } = player;
|
36 |
+
// Close enough to current position or destination => no-op.
|
37 |
+
if (pointsEqual(position, destination)) {
|
38 |
+
return;
|
39 |
+
}
|
40 |
+
// Don't allow players in a conversation to move.
|
41 |
+
const inConversation = [...game.world.conversations.values()].some(
|
42 |
+
(c) => c.participants.get(player.id)?.status.kind === 'participating',
|
43 |
+
);
|
44 |
+
if (inConversation && !allowInConversation) {
|
45 |
+
throw new Error(`Can't move when in a conversation. Leave the conversation first!`);
|
46 |
+
}
|
47 |
+
player.pathfinding = {
|
48 |
+
destination: destination,
|
49 |
+
started: now,
|
50 |
+
state: {
|
51 |
+
kind: 'needsPath',
|
52 |
+
},
|
53 |
+
};
|
54 |
+
return;
|
55 |
+
}
|
56 |
+
|
57 |
+
export function findRoute(game: Game, now: number, player: Player, destination: Point) {
|
58 |
+
const minDistances: PathCandidate[][] = [];
|
59 |
+
const explore = (current: PathCandidate): Array<PathCandidate> => {
|
60 |
+
const { x, y } = current.position;
|
61 |
+
const neighbors = [];
|
62 |
+
|
63 |
+
// If we're not on a grid point, first try to move horizontally
|
64 |
+
// or vertically to a grid point. Note that this can create very small
|
65 |
+
// deltas between the current position and the nearest grid point so
|
66 |
+
// be careful to preserve the `facing` vectors rather than trying to
|
67 |
+
// derive them anew.
|
68 |
+
if (x !== Math.floor(x)) {
|
69 |
+
neighbors.push(
|
70 |
+
{ position: { x: Math.floor(x), y }, facing: { dx: -1, dy: 0 } },
|
71 |
+
{ position: { x: Math.floor(x) + 1, y }, facing: { dx: 1, dy: 0 } },
|
72 |
+
);
|
73 |
+
}
|
74 |
+
if (y !== Math.floor(y)) {
|
75 |
+
neighbors.push(
|
76 |
+
{ position: { x, y: Math.floor(y) }, facing: { dx: 0, dy: -1 } },
|
77 |
+
{ position: { x, y: Math.floor(y) + 1 }, facing: { dx: 0, dy: 1 } },
|
78 |
+
);
|
79 |
+
}
|
80 |
+
// Otherwise, just move to adjacent grid points.
|
81 |
+
if (x == Math.floor(x) && y == Math.floor(y)) {
|
82 |
+
neighbors.push(
|
83 |
+
{ position: { x: x + 1, y }, facing: { dx: 1, dy: 0 } },
|
84 |
+
{ position: { x: x - 1, y }, facing: { dx: -1, dy: 0 } },
|
85 |
+
{ position: { x, y: y + 1 }, facing: { dx: 0, dy: 1 } },
|
86 |
+
{ position: { x, y: y - 1 }, facing: { dx: 0, dy: -1 } },
|
87 |
+
);
|
88 |
+
}
|
89 |
+
const next = [];
|
90 |
+
for (const { position, facing } of neighbors) {
|
91 |
+
const segmentLength = distance(current.position, position);
|
92 |
+
const length = current.length + segmentLength;
|
93 |
+
if (blocked(game, now, position, player.id)) {
|
94 |
+
continue;
|
95 |
+
}
|
96 |
+
const remaining = manhattanDistance(position, destination);
|
97 |
+
const path = {
|
98 |
+
position,
|
99 |
+
facing,
|
100 |
+
// Movement speed is in tiles per second.
|
101 |
+
t: current.t + (segmentLength / movementSpeed) * 1000,
|
102 |
+
length,
|
103 |
+
cost: length + remaining,
|
104 |
+
prev: current,
|
105 |
+
};
|
106 |
+
const existingMin = minDistances[position.y]?.[position.x];
|
107 |
+
if (existingMin && existingMin.cost <= path.cost) {
|
108 |
+
continue;
|
109 |
+
}
|
110 |
+
minDistances[position.y] ??= [];
|
111 |
+
minDistances[position.y][position.x] = path;
|
112 |
+
next.push(path);
|
113 |
+
}
|
114 |
+
return next;
|
115 |
+
};
|
116 |
+
|
117 |
+
const startingLocation = player.position;
|
118 |
+
const startingPosition = { x: startingLocation.x, y: startingLocation.y };
|
119 |
+
let current: PathCandidate | undefined = {
|
120 |
+
position: startingPosition,
|
121 |
+
facing: player.facing,
|
122 |
+
t: now,
|
123 |
+
length: 0,
|
124 |
+
cost: manhattanDistance(startingPosition, destination),
|
125 |
+
prev: undefined,
|
126 |
+
};
|
127 |
+
let bestCandidate = current;
|
128 |
+
const minheap = MinHeap<PathCandidate>((p0, p1) => p0.cost > p1.cost);
|
129 |
+
while (current) {
|
130 |
+
if (pointsEqual(current.position, destination)) {
|
131 |
+
break;
|
132 |
+
}
|
133 |
+
if (
|
134 |
+
manhattanDistance(current.position, destination) <
|
135 |
+
manhattanDistance(bestCandidate.position, destination)
|
136 |
+
) {
|
137 |
+
bestCandidate = current;
|
138 |
+
}
|
139 |
+
for (const candidate of explore(current)) {
|
140 |
+
minheap.push(candidate);
|
141 |
+
}
|
142 |
+
current = minheap.pop();
|
143 |
+
}
|
144 |
+
let newDestination = null;
|
145 |
+
if (!current) {
|
146 |
+
if (bestCandidate.length === 0) {
|
147 |
+
return null;
|
148 |
+
}
|
149 |
+
current = bestCandidate;
|
150 |
+
newDestination = current.position;
|
151 |
+
}
|
152 |
+
const densePath = [];
|
153 |
+
let facing = current.facing!;
|
154 |
+
while (current) {
|
155 |
+
densePath.push({ position: current.position, t: current.t, facing });
|
156 |
+
facing = current.facing!;
|
157 |
+
current = current.prev;
|
158 |
+
}
|
159 |
+
densePath.reverse();
|
160 |
+
|
161 |
+
return { path: compressPath(densePath), newDestination };
|
162 |
+
}
|
163 |
+
|
164 |
+
export function blocked(game: Game, now: number, pos: Point, playerId?: GameId<'players'>) {
|
165 |
+
const otherPositions = [...game.world.players.values()]
|
166 |
+
.filter((p) => p.id !== playerId)
|
167 |
+
.map((p) => p.position);
|
168 |
+
return blockedWithPositions(pos, otherPositions, game.worldMap);
|
169 |
+
}
|
170 |
+
|
171 |
+
export function blockedWithPositions(position: Point, otherPositions: Point[], map: WorldMap) {
|
172 |
+
if (isNaN(position.x) || isNaN(position.y)) {
|
173 |
+
throw new Error(`NaN position in ${JSON.stringify(position)}`);
|
174 |
+
}
|
175 |
+
if (position.x < 0 || position.y < 0 || position.x >= map.width || position.y >= map.height) {
|
176 |
+
return 'out of bounds';
|
177 |
+
}
|
178 |
+
for (const layer of map.objectTiles) {
|
179 |
+
if (layer[Math.floor(position.x)][Math.floor(position.y)] !== -1) {
|
180 |
+
return 'world blocked';
|
181 |
+
}
|
182 |
+
}
|
183 |
+
for (const otherPosition of otherPositions) {
|
184 |
+
if (distance(otherPosition, position) < COLLISION_THRESHOLD) {
|
185 |
+
return 'player';
|
186 |
+
}
|
187 |
+
}
|
188 |
+
return null;
|
189 |
+
}
|
patches/convex/aiTown/player.ts
ADDED
@@ -0,0 +1,310 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Infer, ObjectType, v } from 'convex/values';
|
2 |
+
import { Point, Vector, path, point, vector } from '../util/types';
|
3 |
+
import { GameId, parseGameId } from './ids';
|
4 |
+
import { playerId } from './ids';
|
5 |
+
import {
|
6 |
+
PATHFINDING_TIMEOUT,
|
7 |
+
PATHFINDING_BACKOFF,
|
8 |
+
HUMAN_IDLE_TOO_LONG,
|
9 |
+
MAX_HUMAN_PLAYERS,
|
10 |
+
MAX_PATHFINDS_PER_STEP,
|
11 |
+
} from '../constants';
|
12 |
+
import { pointsEqual, pathPosition } from '../util/geometry';
|
13 |
+
import { Game } from './game';
|
14 |
+
import { stopPlayer, findRoute, blocked, movePlayer } from './movement';
|
15 |
+
import { inputHandler } from './inputHandler';
|
16 |
+
import { characters } from '../../data/characters';
|
17 |
+
import { PlayerDescription } from './playerDescription';
|
18 |
+
|
19 |
+
const pathfinding = v.object({
|
20 |
+
destination: point,
|
21 |
+
started: v.number(),
|
22 |
+
state: v.union(
|
23 |
+
v.object({
|
24 |
+
kind: v.literal('needsPath'),
|
25 |
+
}),
|
26 |
+
v.object({
|
27 |
+
kind: v.literal('waiting'),
|
28 |
+
until: v.number(),
|
29 |
+
}),
|
30 |
+
v.object({
|
31 |
+
kind: v.literal('moving'),
|
32 |
+
path,
|
33 |
+
}),
|
34 |
+
),
|
35 |
+
});
|
36 |
+
export type Pathfinding = Infer<typeof pathfinding>;
|
37 |
+
|
38 |
+
export const activity = v.object({
|
39 |
+
description: v.string(),
|
40 |
+
emoji: v.optional(v.string()),
|
41 |
+
until: v.number(),
|
42 |
+
});
|
43 |
+
export type Activity = Infer<typeof activity>;
|
44 |
+
|
45 |
+
export const serializedPlayer = {
|
46 |
+
id: playerId,
|
47 |
+
human: v.optional(v.string()),
|
48 |
+
pathfinding: v.optional(pathfinding),
|
49 |
+
activity: v.optional(activity),
|
50 |
+
|
51 |
+
// The last time they did something.
|
52 |
+
lastInput: v.number(),
|
53 |
+
|
54 |
+
position: point,
|
55 |
+
facing: vector,
|
56 |
+
speed: v.number(),
|
57 |
+
};
|
58 |
+
export type SerializedPlayer = ObjectType<typeof serializedPlayer>;
|
59 |
+
|
60 |
+
export class Player {
|
61 |
+
id: GameId<'players'>;
|
62 |
+
human?: string;
|
63 |
+
pathfinding?: Pathfinding;
|
64 |
+
activity?: Activity;
|
65 |
+
|
66 |
+
lastInput: number;
|
67 |
+
|
68 |
+
position: Point;
|
69 |
+
facing: Vector;
|
70 |
+
speed: number;
|
71 |
+
|
72 |
+
constructor(serialized: SerializedPlayer) {
|
73 |
+
const { id, human, pathfinding, activity, lastInput, position, facing, speed } = serialized;
|
74 |
+
this.id = parseGameId('players', id);
|
75 |
+
this.human = human;
|
76 |
+
this.pathfinding = pathfinding;
|
77 |
+
this.activity = activity;
|
78 |
+
this.lastInput = lastInput;
|
79 |
+
this.position = position;
|
80 |
+
this.facing = facing;
|
81 |
+
this.speed = speed;
|
82 |
+
}
|
83 |
+
|
84 |
+
tick(game: Game, now: number) {
|
85 |
+
if (this.human && this.lastInput < now - HUMAN_IDLE_TOO_LONG) {
|
86 |
+
this.leave(game, now);
|
87 |
+
}
|
88 |
+
}
|
89 |
+
|
90 |
+
tickPathfinding(game: Game, now: number) {
|
91 |
+
// There's nothing to do if we're not moving.
|
92 |
+
const { pathfinding, position } = this;
|
93 |
+
if (!pathfinding) {
|
94 |
+
return;
|
95 |
+
}
|
96 |
+
|
97 |
+
// Stop pathfinding if we've reached our destination.
|
98 |
+
if (pathfinding.state.kind === 'moving' && pointsEqual(pathfinding.destination, position)) {
|
99 |
+
stopPlayer(this);
|
100 |
+
}
|
101 |
+
|
102 |
+
// Stop pathfinding if we've timed out.
|
103 |
+
if (pathfinding.started + PATHFINDING_TIMEOUT < now) {
|
104 |
+
console.warn(`Timing out pathfinding for ${this.id}`);
|
105 |
+
stopPlayer(this);
|
106 |
+
}
|
107 |
+
|
108 |
+
// Transition from "waiting" to "needsPath" if we're past the deadline.
|
109 |
+
if (pathfinding.state.kind === 'waiting' && pathfinding.state.until < now) {
|
110 |
+
pathfinding.state = { kind: 'needsPath' };
|
111 |
+
}
|
112 |
+
|
113 |
+
// Perform pathfinding if needed.
|
114 |
+
if (pathfinding.state.kind === 'needsPath' && game.numPathfinds < MAX_PATHFINDS_PER_STEP) {
|
115 |
+
game.numPathfinds++;
|
116 |
+
if (game.numPathfinds === MAX_PATHFINDS_PER_STEP) {
|
117 |
+
console.warn(`Reached max pathfinds for this step`);
|
118 |
+
}
|
119 |
+
const route = findRoute(game, now, this, pathfinding.destination);
|
120 |
+
if (route === null) {
|
121 |
+
console.log(`Failed to route to ${JSON.stringify(pathfinding.destination)}`);
|
122 |
+
stopPlayer(this);
|
123 |
+
} else {
|
124 |
+
if (route.newDestination) {
|
125 |
+
console.warn(
|
126 |
+
`Updating destination from ${JSON.stringify(
|
127 |
+
pathfinding.destination,
|
128 |
+
)} to ${JSON.stringify(route.newDestination)}`,
|
129 |
+
);
|
130 |
+
pathfinding.destination = route.newDestination;
|
131 |
+
}
|
132 |
+
pathfinding.state = { kind: 'moving', path: route.path };
|
133 |
+
}
|
134 |
+
}
|
135 |
+
}
|
136 |
+
|
137 |
+
tickPosition(game: Game, now: number) {
|
138 |
+
// There's nothing to do if we're not moving.
|
139 |
+
if (!this.pathfinding || this.pathfinding.state.kind !== 'moving') {
|
140 |
+
this.speed = 0;
|
141 |
+
return;
|
142 |
+
}
|
143 |
+
|
144 |
+
// Compute a candidate new position and check if it collides
|
145 |
+
// with anything.
|
146 |
+
const candidate = pathPosition(this.pathfinding.state.path as any, now);
|
147 |
+
if (!candidate) {
|
148 |
+
console.warn(`Path out of range of ${now} for ${this.id}`);
|
149 |
+
return;
|
150 |
+
}
|
151 |
+
const { position, facing, velocity } = candidate;
|
152 |
+
const collisionReason = blocked(game, now, position, this.id);
|
153 |
+
if (collisionReason !== null) {
|
154 |
+
const backoff = Math.random() * PATHFINDING_BACKOFF;
|
155 |
+
console.warn(`Stopping path for ${this.id}, waiting for ${backoff}ms: ${collisionReason}`);
|
156 |
+
this.pathfinding.state = {
|
157 |
+
kind: 'waiting',
|
158 |
+
until: now + backoff,
|
159 |
+
};
|
160 |
+
return;
|
161 |
+
}
|
162 |
+
// Update the player's location.
|
163 |
+
this.position = position;
|
164 |
+
this.facing = facing;
|
165 |
+
this.speed = velocity;
|
166 |
+
}
|
167 |
+
|
168 |
+
static join(
|
169 |
+
game: Game,
|
170 |
+
now: number,
|
171 |
+
name: string,
|
172 |
+
character: string,
|
173 |
+
description: string,
|
174 |
+
tokenIdentifier?: string,
|
175 |
+
) {
|
176 |
+
if (tokenIdentifier) {
|
177 |
+
let numHumans = 0;
|
178 |
+
for (const player of game.world.players.values()) {
|
179 |
+
if (player.human) {
|
180 |
+
numHumans++;
|
181 |
+
}
|
182 |
+
if (player.human === tokenIdentifier) {
|
183 |
+
throw new Error(`You are already in this game!`);
|
184 |
+
}
|
185 |
+
}
|
186 |
+
if (numHumans >= MAX_HUMAN_PLAYERS) {
|
187 |
+
throw new Error(`Only ${MAX_HUMAN_PLAYERS} human players allowed at once.`);
|
188 |
+
}
|
189 |
+
}
|
190 |
+
let position;
|
191 |
+
for (let attempt = 0; attempt < 10; attempt++) {
|
192 |
+
const candidate = {
|
193 |
+
x: Math.floor(Math.random() * game.worldMap.width),
|
194 |
+
y: Math.floor(Math.random() * game.worldMap.height),
|
195 |
+
};
|
196 |
+
if (blocked(game, now, candidate)) {
|
197 |
+
continue;
|
198 |
+
}
|
199 |
+
position = candidate;
|
200 |
+
break;
|
201 |
+
}
|
202 |
+
if (!position) {
|
203 |
+
throw new Error(`Failed to find a free position!`);
|
204 |
+
}
|
205 |
+
const facingOptions = [
|
206 |
+
{ dx: 1, dy: 0 },
|
207 |
+
{ dx: -1, dy: 0 },
|
208 |
+
{ dx: 0, dy: 1 },
|
209 |
+
{ dx: 0, dy: -1 },
|
210 |
+
];
|
211 |
+
const facing = facingOptions[Math.floor(Math.random() * facingOptions.length)];
|
212 |
+
if (!characters.find((c) => c.name === character)) {
|
213 |
+
throw new Error(`Invalid character: ${character}`);
|
214 |
+
}
|
215 |
+
const playerId = game.allocId('players');
|
216 |
+
game.world.players.set(
|
217 |
+
playerId,
|
218 |
+
new Player({
|
219 |
+
id: playerId,
|
220 |
+
human: tokenIdentifier,
|
221 |
+
lastInput: now,
|
222 |
+
position,
|
223 |
+
facing,
|
224 |
+
speed: 0,
|
225 |
+
}),
|
226 |
+
);
|
227 |
+
game.playerDescriptions.set(
|
228 |
+
playerId,
|
229 |
+
new PlayerDescription({
|
230 |
+
playerId,
|
231 |
+
character,
|
232 |
+
description,
|
233 |
+
name,
|
234 |
+
}),
|
235 |
+
);
|
236 |
+
game.descriptionsModified = true;
|
237 |
+
return playerId;
|
238 |
+
}
|
239 |
+
|
240 |
+
leave(game: Game, now: number) {
|
241 |
+
// Stop our conversation if we're leaving the game.
|
242 |
+
const conversation = [...game.world.conversations.values()].find((c) =>
|
243 |
+
c.participants.has(this.id),
|
244 |
+
);
|
245 |
+
if (conversation) {
|
246 |
+
conversation.stop(game, now);
|
247 |
+
}
|
248 |
+
game.world.players.delete(this.id);
|
249 |
+
}
|
250 |
+
|
251 |
+
serialize(): SerializedPlayer {
|
252 |
+
const { id, human, pathfinding, activity, lastInput, position, facing, speed } = this;
|
253 |
+
return {
|
254 |
+
id,
|
255 |
+
human,
|
256 |
+
pathfinding,
|
257 |
+
activity,
|
258 |
+
lastInput,
|
259 |
+
position,
|
260 |
+
facing,
|
261 |
+
speed,
|
262 |
+
};
|
263 |
+
}
|
264 |
+
}
|
265 |
+
|
266 |
+
export const playerInputs = {
|
267 |
+
join: inputHandler({
|
268 |
+
args: {
|
269 |
+
name: v.string(),
|
270 |
+
character: v.string(),
|
271 |
+
description: v.string(),
|
272 |
+
tokenIdentifier: v.optional(v.string()),
|
273 |
+
},
|
274 |
+
handler: (game, now, args) => {
|
275 |
+
Player.join(game, now, args.name, args.character, args.description, args.tokenIdentifier);
|
276 |
+
return null;
|
277 |
+
},
|
278 |
+
}),
|
279 |
+
leave: inputHandler({
|
280 |
+
args: { playerId },
|
281 |
+
handler: (game, now, args) => {
|
282 |
+
const playerId = parseGameId('players', args.playerId);
|
283 |
+
const player = game.world.players.get(playerId);
|
284 |
+
if (!player) {
|
285 |
+
throw new Error(`Invalid player ID ${playerId}`);
|
286 |
+
}
|
287 |
+
player.leave(game, now);
|
288 |
+
return null;
|
289 |
+
},
|
290 |
+
}),
|
291 |
+
moveTo: inputHandler({
|
292 |
+
args: {
|
293 |
+
playerId,
|
294 |
+
destination: v.union(point, v.null()),
|
295 |
+
},
|
296 |
+
handler: (game, now, args) => {
|
297 |
+
const playerId = parseGameId('players', args.playerId);
|
298 |
+
const player = game.world.players.get(playerId);
|
299 |
+
if (!player) {
|
300 |
+
throw new Error(`Invalid player ID ${playerId}`);
|
301 |
+
}
|
302 |
+
if (args.destination) {
|
303 |
+
movePlayer(game, now, player, args.destination);
|
304 |
+
} else {
|
305 |
+
stopPlayer(player);
|
306 |
+
}
|
307 |
+
return null;
|
308 |
+
},
|
309 |
+
}),
|
310 |
+
};
|
patches/convex/aiTown/playerDescription.ts
ADDED
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { ObjectType, v } from 'convex/values';
|
2 |
+
import { GameId, parseGameId, playerId } from './ids';
|
3 |
+
|
4 |
+
export const serializedPlayerDescription = {
|
5 |
+
playerId,
|
6 |
+
name: v.string(),
|
7 |
+
description: v.string(),
|
8 |
+
character: v.string(),
|
9 |
+
};
|
10 |
+
export type SerializedPlayerDescription = ObjectType<typeof serializedPlayerDescription>;
|
11 |
+
|
12 |
+
export class PlayerDescription {
|
13 |
+
playerId: GameId<'players'>;
|
14 |
+
name: string;
|
15 |
+
description: string;
|
16 |
+
character: string;
|
17 |
+
|
18 |
+
constructor(serialized: SerializedPlayerDescription) {
|
19 |
+
const { playerId, name, description, character } = serialized;
|
20 |
+
this.playerId = parseGameId('players', playerId);
|
21 |
+
this.name = name;
|
22 |
+
this.description = description;
|
23 |
+
this.character = character;
|
24 |
+
}
|
25 |
+
|
26 |
+
serialize(): SerializedPlayerDescription {
|
27 |
+
const { playerId, name, description, character } = this;
|
28 |
+
return {
|
29 |
+
playerId,
|
30 |
+
name,
|
31 |
+
description,
|
32 |
+
character,
|
33 |
+
};
|
34 |
+
}
|
35 |
+
}
|
patches/convex/aiTown/schema.ts
ADDED
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { v } from 'convex/values';
|
2 |
+
import { defineTable } from 'convex/server';
|
3 |
+
import { serializedPlayer } from './player';
|
4 |
+
import { serializedPlayerDescription } from './playerDescription';
|
5 |
+
import { serializedAgent } from './agent';
|
6 |
+
import { serializedAgentDescription } from './agentDescription';
|
7 |
+
import { serializedWorld } from './world';
|
8 |
+
import { serializedWorldMap } from './worldMap';
|
9 |
+
import { serializedConversation } from './conversation';
|
10 |
+
import { conversationId, playerId } from './ids';
|
11 |
+
|
12 |
+
export const aiTownTables = {
|
13 |
+
// This table has a single document that stores all players, conversations, and agents. This
|
14 |
+
// data is small and changes regularly over time.
|
15 |
+
worlds: defineTable({ ...serializedWorld }),
|
16 |
+
|
17 |
+
// Worlds can be started or stopped by the developer or paused for inactivity, and this
|
18 |
+
// infrequently changing document tracks this world state.
|
19 |
+
worldStatus: defineTable({
|
20 |
+
worldId: v.id('worlds'),
|
21 |
+
isDefault: v.boolean(),
|
22 |
+
engineId: v.id('engines'),
|
23 |
+
lastViewed: v.number(),
|
24 |
+
status: v.union(v.literal('running'), v.literal('stoppedByDeveloper'), v.literal('inactive')),
|
25 |
+
}).index('worldId', ['worldId']),
|
26 |
+
|
27 |
+
// This table contains the map data for a given world. Since it's a bit larger than the player
|
28 |
+
// state and infrequently changes, we store it in a separate table.
|
29 |
+
maps: defineTable({
|
30 |
+
worldId: v.id('worlds'),
|
31 |
+
...serializedWorldMap,
|
32 |
+
}).index('worldId', ['worldId']),
|
33 |
+
|
34 |
+
// Human readable text describing players and agents that's stored in separate tables, just like `maps`.
|
35 |
+
playerDescriptions: defineTable({
|
36 |
+
worldId: v.id('worlds'),
|
37 |
+
...serializedPlayerDescription,
|
38 |
+
}).index('worldId', ['worldId', 'playerId']),
|
39 |
+
agentDescriptions: defineTable({
|
40 |
+
worldId: v.id('worlds'),
|
41 |
+
...serializedAgentDescription,
|
42 |
+
}).index('worldId', ['worldId', 'agentId']),
|
43 |
+
|
44 |
+
//The game engine doesn't want to track players that have left or conversations that are over, since
|
45 |
+
// it wants to keep its managed state small. However, we may want to look at old conversations in the
|
46 |
+
// UI or from the agent code. So, whenever we delete an entry from within the world's document, we
|
47 |
+
// "archive" it within these tables.
|
48 |
+
archivedPlayers: defineTable({ worldId: v.id('worlds'), ...serializedPlayer }).index('worldId', [
|
49 |
+
'worldId',
|
50 |
+
'id',
|
51 |
+
]),
|
52 |
+
archivedConversations: defineTable({
|
53 |
+
worldId: v.id('worlds'),
|
54 |
+
id: conversationId,
|
55 |
+
creator: playerId,
|
56 |
+
created: v.number(),
|
57 |
+
ended: v.number(),
|
58 |
+
lastMessage: serializedConversation.lastMessage,
|
59 |
+
numMessages: serializedConversation.numMessages,
|
60 |
+
participants: v.array(playerId),
|
61 |
+
}).index('worldId', ['worldId', 'id']),
|
62 |
+
archivedAgents: defineTable({ worldId: v.id('worlds'), ...serializedAgent }).index('worldId', [
|
63 |
+
'worldId',
|
64 |
+
'id',
|
65 |
+
]),
|
66 |
+
|
67 |
+
// The agent layer wants to know what the last (completed) conversation was between two players,
|
68 |
+
// so this table represents a labelled graph indicating which players have talked to each other.
|
69 |
+
participatedTogether: defineTable({
|
70 |
+
worldId: v.id('worlds'),
|
71 |
+
conversationId,
|
72 |
+
player1: playerId,
|
73 |
+
player2: playerId,
|
74 |
+
ended: v.number(),
|
75 |
+
})
|
76 |
+
.index('edge', ['worldId', 'player1', 'player2', 'ended'])
|
77 |
+
.index('conversation', ['worldId', 'player1', 'conversationId'])
|
78 |
+
.index('playerHistory', ['worldId', 'player1', 'ended']),
|
79 |
+
};
|
patches/convex/aiTown/world.ts
ADDED
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { ObjectType, v } from 'convex/values';
|
2 |
+
import { Conversation, serializedConversation } from './conversation';
|
3 |
+
import { Player, serializedPlayer } from './player';
|
4 |
+
import { Agent, serializedAgent } from './agent';
|
5 |
+
import { GameId, parseGameId, playerId } from './ids';
|
6 |
+
import { parseMap } from '../util/object';
|
7 |
+
import { DayNightCycle, SerializedDayNightCycle, dayNightCycleSchema } from './dayNightCycle';
|
8 |
+
|
9 |
+
export const historicalLocations = v.array(
|
10 |
+
v.object({
|
11 |
+
playerId,
|
12 |
+
location: v.bytes(),
|
13 |
+
}),
|
14 |
+
);
|
15 |
+
|
16 |
+
export const serializedWorld = {
|
17 |
+
nextId: v.number(),
|
18 |
+
conversations: v.array(v.object(serializedConversation)),
|
19 |
+
players: v.array(v.object(serializedPlayer)),
|
20 |
+
agents: v.array(v.object(serializedAgent)),
|
21 |
+
historicalLocations: v.optional(historicalLocations),
|
22 |
+
dayNightCycle: v.object(dayNightCycleSchema),
|
23 |
+
};
|
24 |
+
export type SerializedWorld = ObjectType<typeof serializedWorld>;
|
25 |
+
|
26 |
+
export class World {
|
27 |
+
nextId: number;
|
28 |
+
conversations: Map<GameId<'conversations'>, Conversation>;
|
29 |
+
players: Map<GameId<'players'>, Player>;
|
30 |
+
agents: Map<GameId<'agents'>, Agent>;
|
31 |
+
historicalLocations?: Map<GameId<'players'>, ArrayBuffer>;
|
32 |
+
dayNightCycle: DayNightCycle;
|
33 |
+
|
34 |
+
constructor(serialized: SerializedWorld) {
|
35 |
+
const { nextId, historicalLocations } = serialized;
|
36 |
+
|
37 |
+
this.nextId = nextId;
|
38 |
+
this.conversations = parseMap(serialized.conversations, Conversation, (c) => c.id);
|
39 |
+
this.players = parseMap(serialized.players, Player, (p) => p.id);
|
40 |
+
this.agents = parseMap(serialized.agents, Agent, (a) => a.id);
|
41 |
+
this.dayNightCycle = new DayNightCycle(serialized.dayNightCycle);
|
42 |
+
|
43 |
+
if (historicalLocations) {
|
44 |
+
this.historicalLocations = new Map();
|
45 |
+
for (const { playerId, location } of historicalLocations) {
|
46 |
+
this.historicalLocations.set(parseGameId('players', playerId), location);
|
47 |
+
}
|
48 |
+
}
|
49 |
+
}
|
50 |
+
|
51 |
+
playerConversation(player: Player): Conversation | undefined {
|
52 |
+
return [...this.conversations.values()].find((c) => c.participants.has(player.id));
|
53 |
+
}
|
54 |
+
|
55 |
+
serialize(): SerializedWorld {
|
56 |
+
return {
|
57 |
+
nextId: this.nextId,
|
58 |
+
conversations: [...this.conversations.values()].map((c) => c.serialize()),
|
59 |
+
players: [...this.players.values()].map((p) => p.serialize()),
|
60 |
+
agents: [...this.agents.values()].map((a) => a.serialize()),
|
61 |
+
historicalLocations:
|
62 |
+
this.historicalLocations &&
|
63 |
+
[...this.historicalLocations.entries()].map(([playerId, location]) => ({
|
64 |
+
playerId,
|
65 |
+
location,
|
66 |
+
})),
|
67 |
+
dayNightCycle: this.dayNightCycle.serialize(),
|
68 |
+
};
|
69 |
+
}
|
70 |
+
}
|
patches/convex/aiTown/worldMap.ts
ADDED
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Infer, ObjectType, v } from 'convex/values';
|
2 |
+
|
3 |
+
// `layer[position.x][position.y]` is the tileIndex or -1 if empty.
|
4 |
+
const tileLayer = v.array(v.array(v.number()));
|
5 |
+
export type TileLayer = Infer<typeof tileLayer>;
|
6 |
+
|
7 |
+
const animatedSprite = {
|
8 |
+
x: v.number(),
|
9 |
+
y: v.number(),
|
10 |
+
w: v.number(),
|
11 |
+
h: v.number(),
|
12 |
+
layer: v.number(),
|
13 |
+
sheet: v.string(),
|
14 |
+
animation: v.string(),
|
15 |
+
};
|
16 |
+
export type AnimatedSprite = ObjectType<typeof animatedSprite>;
|
17 |
+
|
18 |
+
export const serializedWorldMap = {
|
19 |
+
width: v.number(),
|
20 |
+
height: v.number(),
|
21 |
+
|
22 |
+
tileSetUrl: v.string(),
|
23 |
+
tileSetAlternateUrl: v.string(),
|
24 |
+
// Width & height of tileset image, px.
|
25 |
+
tileSetDimX: v.number(),
|
26 |
+
tileSetDimY: v.number(),
|
27 |
+
|
28 |
+
// Tile size in pixels (assume square)
|
29 |
+
tileDim: v.number(),
|
30 |
+
bgTiles: v.array(v.array(v.array(v.number()))),
|
31 |
+
decorTiles: v.array(v.array(v.array(v.number()))),
|
32 |
+
objectTiles: v.array(tileLayer),
|
33 |
+
bgTilesN: v.array(v.array(v.array(v.number()))),
|
34 |
+
decorTilesN: v.array(v.array(v.array(v.number()))),
|
35 |
+
objectTilesN: v.array(tileLayer),
|
36 |
+
animatedSprites: v.array(v.object(animatedSprite)),
|
37 |
+
};
|
38 |
+
export type SerializedWorldMap = ObjectType<typeof serializedWorldMap>;
|
39 |
+
|
40 |
+
export class WorldMap {
|
41 |
+
width: number;
|
42 |
+
height: number;
|
43 |
+
|
44 |
+
tileSetUrl: string;
|
45 |
+
tileSetAlternateUrl: string;
|
46 |
+
tileSetDimX: number;
|
47 |
+
tileSetDimY: number;
|
48 |
+
|
49 |
+
tileDim: number;
|
50 |
+
|
51 |
+
bgTiles: TileLayer[];
|
52 |
+
decorTiles: TileLayer[];
|
53 |
+
objectTiles: TileLayer[];
|
54 |
+
bgTilesN: TileLayer[];
|
55 |
+
decorTilesN: TileLayer[];
|
56 |
+
objectTilesN: TileLayer[];
|
57 |
+
animatedSprites: AnimatedSprite[];
|
58 |
+
|
59 |
+
constructor(serialized: SerializedWorldMap) {
|
60 |
+
this.width = serialized.width;
|
61 |
+
this.height = serialized.height;
|
62 |
+
this.tileSetUrl = serialized.tileSetUrl;
|
63 |
+
this.tileSetAlternateUrl = serialized.tileSetAlternateUrl;
|
64 |
+
this.tileSetDimX = serialized.tileSetDimX;
|
65 |
+
this.tileSetDimY = serialized.tileSetDimY;
|
66 |
+
this.tileDim = serialized.tileDim;
|
67 |
+
this.bgTiles = serialized.bgTiles;
|
68 |
+
this.decorTiles = serialized.decorTiles;
|
69 |
+
this.objectTiles = serialized.objectTiles;
|
70 |
+
this.bgTilesN = serialized.bgTilesN;
|
71 |
+
this.decorTilesN = serialized.decorTilesN;
|
72 |
+
this.objectTilesN = serialized.objectTilesN;
|
73 |
+
this.animatedSprites = serialized.animatedSprites;
|
74 |
+
}
|
75 |
+
|
76 |
+
serialize(): SerializedWorldMap {
|
77 |
+
return {
|
78 |
+
width: this.width,
|
79 |
+
height: this.height,
|
80 |
+
tileSetUrl: this.tileSetUrl,
|
81 |
+
tileSetAlternateUrl: this.tileSetAlternateUrl,
|
82 |
+
tileSetDimX: this.tileSetDimX,
|
83 |
+
tileSetDimY: this.tileSetDimY,
|
84 |
+
tileDim: this.tileDim,
|
85 |
+
bgTiles: this.bgTiles,
|
86 |
+
objectTiles: this.objectTiles,
|
87 |
+
decorTiles:this.decorTiles,
|
88 |
+
bgTilesN: this.bgTilesN,
|
89 |
+
objectTilesN: this.objectTilesN,
|
90 |
+
decorTilesN:this.decorTilesN,
|
91 |
+
animatedSprites: this.animatedSprites,
|
92 |
+
};
|
93 |
+
}
|
94 |
+
}
|
patches/{constants.ts → convex/constants.ts}
RENAMED
@@ -1,78 +1,81 @@
|
|
1 |
-
// export const ACTION_TIMEOUT = 120_000; // more time for local dev
|
2 |
-
export const ACTION_TIMEOUT = 60_000; // normally fine
|
3 |
-
|
4 |
-
export const IDLE_WORLD_TIMEOUT = 5 * 60 * 1000;
|
5 |
-
export const WORLD_HEARTBEAT_INTERVAL = 60 * 1000;
|
6 |
-
|
7 |
-
export const MAX_STEP = 10 * 60 * 1000;
|
8 |
-
export const TICK = 16;
|
9 |
-
export const STEP_INTERVAL = 1000;
|
10 |
-
|
11 |
-
export const PATHFINDING_TIMEOUT = 60 * 1000;
|
12 |
-
export const PATHFINDING_BACKOFF = 1000;
|
13 |
-
export const CONVERSATION_DISTANCE = 1.3;
|
14 |
-
export const MIDPOINT_THRESHOLD = 4;
|
15 |
-
export const TYPING_TIMEOUT = 15 * 1000;
|
16 |
-
export const COLLISION_THRESHOLD = 0.75;
|
17 |
-
|
18 |
-
// How many human players can be in a world at once.
|
19 |
-
export const MAX_HUMAN_PLAYERS = 8;
|
20 |
-
|
21 |
-
// Don't talk to anyone for 15s after having a conversation.
|
22 |
-
export const CONVERSATION_COOLDOWN = 15000;
|
23 |
-
|
24 |
-
// Don't do another activity for 10s after doing one.
|
25 |
-
export const ACTIVITY_COOLDOWN = 10_000;
|
26 |
-
|
27 |
-
// Don't talk to a player within 60s of talking to them.
|
28 |
-
export const PLAYER_CONVERSATION_COOLDOWN = 60000;
|
29 |
-
|
30 |
-
// Invite 80% of invites that come from other agents.
|
31 |
-
export const INVITE_ACCEPT_PROBABILITY = 0.8;
|
32 |
-
|
33 |
-
// Wait for 1m for invites to be accepted.
|
34 |
-
export const INVITE_TIMEOUT = 60000;
|
35 |
-
|
36 |
-
// Wait for another player to say something before jumping in.
|
37 |
-
export const AWKWARD_CONVERSATION_TIMEOUT = 60_000; // more time locally
|
38 |
-
// export const AWKWARD_CONVERSATION_TIMEOUT = 20_000;
|
39 |
-
|
40 |
-
// Leave a conversation after participating too long.
|
41 |
-
export const MAX_CONVERSATION_DURATION = 10 * 60_000; // more time locally
|
42 |
-
// export const MAX_CONVERSATION_DURATION = 2 * 60_000;
|
43 |
-
|
44 |
-
// Leave a conversation if it has more than 8 messages;
|
45 |
-
export const MAX_CONVERSATION_MESSAGES = 8;
|
46 |
-
|
47 |
-
// Wait for 1s after sending an input to the engine. We can remove this
|
48 |
-
// once we can await on an input being processed.
|
49 |
-
export const INPUT_DELAY = 1000;
|
50 |
-
|
51 |
-
// How many memories to get from the agent's memory.
|
52 |
-
// This is over-fetched by 10x so we can prioritize memories by more than relevance.
|
53 |
-
export const NUM_MEMORIES_TO_SEARCH = 1;
|
54 |
-
|
55 |
-
// Wait for at least two seconds before sending another message.
|
56 |
-
export const MESSAGE_COOLDOWN = 2000;
|
57 |
-
|
58 |
-
// Don't run a turn of the agent more than once a second.
|
59 |
-
export const AGENT_WAKEUP_THRESHOLD = 1000;
|
60 |
-
|
61 |
-
// How old we let memories be before we vacuum them
|
62 |
-
export const VACUUM_MAX_AGE = 2 * 7 * 24 * 60 * 60 * 1000;
|
63 |
-
export const DELETE_BATCH_SIZE = 64;
|
64 |
-
|
65 |
-
export const HUMAN_IDLE_TOO_LONG = 5 * 60 * 1000;
|
66 |
-
|
67 |
-
export const ACTIVITIES = [
|
68 |
-
{ description: 'reading a book', emoji: '📖', duration: 60_000 },
|
69 |
-
{ description: 'daydreaming', emoji: '🤔', duration: 60_000 },
|
70 |
-
{ description: 'gardening', emoji: '🥕', duration: 60_000 },
|
71 |
-
];
|
72 |
-
|
73 |
-
export const ENGINE_ACTION_DURATION = 30000;
|
74 |
-
|
75 |
-
|
76 |
-
export const
|
77 |
-
|
78 |
-
|
|
|
|
|
|
|
|
1 |
+
// export const ACTION_TIMEOUT = 120_000; // more time for local dev
|
2 |
+
export const ACTION_TIMEOUT = 60_000; // normally fine
|
3 |
+
|
4 |
+
export const IDLE_WORLD_TIMEOUT = 5 * 60 * 1000;
|
5 |
+
export const WORLD_HEARTBEAT_INTERVAL = 60 * 1000;
|
6 |
+
|
7 |
+
export const MAX_STEP = 10 * 60 * 1000;
|
8 |
+
export const TICK = 16;
|
9 |
+
export const STEP_INTERVAL = 1000;
|
10 |
+
|
11 |
+
export const PATHFINDING_TIMEOUT = 60 * 1000;
|
12 |
+
export const PATHFINDING_BACKOFF = 1000;
|
13 |
+
export const CONVERSATION_DISTANCE = 1.3;
|
14 |
+
export const MIDPOINT_THRESHOLD = 4;
|
15 |
+
export const TYPING_TIMEOUT = 15 * 1000;
|
16 |
+
export const COLLISION_THRESHOLD = 0.75;
|
17 |
+
|
18 |
+
// How many human players can be in a world at once.
|
19 |
+
export const MAX_HUMAN_PLAYERS = 8;
|
20 |
+
|
21 |
+
// Don't talk to anyone for 15s after having a conversation.
|
22 |
+
export const CONVERSATION_COOLDOWN = 15000;
|
23 |
+
|
24 |
+
// Don't do another activity for 10s after doing one.
|
25 |
+
export const ACTIVITY_COOLDOWN = 10_000;
|
26 |
+
|
27 |
+
// Don't talk to a player within 60s of talking to them.
|
28 |
+
export const PLAYER_CONVERSATION_COOLDOWN = 60000;
|
29 |
+
|
30 |
+
// Invite 80% of invites that come from other agents.
|
31 |
+
export const INVITE_ACCEPT_PROBABILITY = 0.8;
|
32 |
+
|
33 |
+
// Wait for 1m for invites to be accepted.
|
34 |
+
export const INVITE_TIMEOUT = 60000;
|
35 |
+
|
36 |
+
// Wait for another player to say something before jumping in.
|
37 |
+
export const AWKWARD_CONVERSATION_TIMEOUT = 60_000; // more time locally
|
38 |
+
// export const AWKWARD_CONVERSATION_TIMEOUT = 20_000;
|
39 |
+
|
40 |
+
// Leave a conversation after participating too long.
|
41 |
+
export const MAX_CONVERSATION_DURATION = 10 * 60_000; // more time locally
|
42 |
+
// export const MAX_CONVERSATION_DURATION = 2 * 60_000;
|
43 |
+
|
44 |
+
// Leave a conversation if it has more than 8 messages;
|
45 |
+
export const MAX_CONVERSATION_MESSAGES = 8;
|
46 |
+
|
47 |
+
// Wait for 1s after sending an input to the engine. We can remove this
|
48 |
+
// once we can await on an input being processed.
|
49 |
+
export const INPUT_DELAY = 1000;
|
50 |
+
|
51 |
+
// How many memories to get from the agent's memory.
|
52 |
+
// This is over-fetched by 10x so we can prioritize memories by more than relevance.
|
53 |
+
export const NUM_MEMORIES_TO_SEARCH = 1;
|
54 |
+
|
55 |
+
// Wait for at least two seconds before sending another message.
|
56 |
+
export const MESSAGE_COOLDOWN = 2000;
|
57 |
+
|
58 |
+
// Don't run a turn of the agent more than once a second.
|
59 |
+
export const AGENT_WAKEUP_THRESHOLD = 1000;
|
60 |
+
|
61 |
+
// How old we let memories be before we vacuum them
|
62 |
+
export const VACUUM_MAX_AGE = 2 * 7 * 24 * 60 * 60 * 1000;
|
63 |
+
export const DELETE_BATCH_SIZE = 64;
|
64 |
+
|
65 |
+
export const HUMAN_IDLE_TOO_LONG = 5 * 60 * 1000;
|
66 |
+
|
67 |
+
export const ACTIVITIES = [
|
68 |
+
{ description: 'reading a book', emoji: '📖', duration: 60_000 },
|
69 |
+
{ description: 'daydreaming', emoji: '🤔', duration: 60_000 },
|
70 |
+
{ description: 'gardening', emoji: '🥕', duration: 60_000 },
|
71 |
+
];
|
72 |
+
|
73 |
+
export const ENGINE_ACTION_DURATION = 30000;
|
74 |
+
|
75 |
+
export const DAY_DURATION = 120000;
|
76 |
+
export const NIGHT_DURATION = 30000;
|
77 |
+
|
78 |
+
// Bound the number of pathfinding searches we do per game step.
|
79 |
+
export const MAX_PATHFINDS_PER_STEP = 16;
|
80 |
+
|
81 |
+
export const DEFAULT_NAME = 'Me';
|
patches/convex/crons.ts
ADDED
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { cronJobs } from 'convex/server';
|
2 |
+
import { DELETE_BATCH_SIZE, IDLE_WORLD_TIMEOUT, VACUUM_MAX_AGE } from './constants';
|
3 |
+
import { internal } from './_generated/api';
|
4 |
+
import { internalMutation } from './_generated/server';
|
5 |
+
import { TableNames } from './_generated/dataModel';
|
6 |
+
import { v } from 'convex/values';
|
7 |
+
|
8 |
+
const crons = cronJobs();
|
9 |
+
|
10 |
+
crons.interval(
|
11 |
+
'stop inactive worlds',
|
12 |
+
{ seconds: IDLE_WORLD_TIMEOUT / 1000 },
|
13 |
+
internal.world.stopInactiveWorlds,
|
14 |
+
);
|
15 |
+
|
16 |
+
crons.interval('restart dead worlds', { seconds: 60 }, internal.world.restartDeadWorlds);
|
17 |
+
|
18 |
+
crons.daily('vacuum old entries', { hourUTC: 4, minuteUTC: 20 }, internal.crons.vacuumOldEntries);
|
19 |
+
|
20 |
+
export default crons;
|
21 |
+
|
22 |
+
const TablesToVacuum: TableNames[] = [
|
23 |
+
// Un-comment this to also clean out old conversations.
|
24 |
+
// 'conversationMembers', 'conversations', 'messages',
|
25 |
+
|
26 |
+
// Inputs aren't useful unless you're trying to replay history.
|
27 |
+
// If you want to support that, you should add a snapshot table, so you can
|
28 |
+
// replay from a certain time period. Or stop vacuuming inputs and replay from
|
29 |
+
// the beginning of time
|
30 |
+
'inputs',
|
31 |
+
|
32 |
+
// We can keep memories without their embeddings for inspection, but we won't
|
33 |
+
// retrieve them when searching memories via vector search.
|
34 |
+
'memories',
|
35 |
+
// We can vacuum fewer tables without serious consequences, but the only
|
36 |
+
// one that will cause issues over time is having >>100k vectors.
|
37 |
+
'memoryEmbeddings',
|
38 |
+
];
|
39 |
+
|
40 |
+
export const vacuumOldEntries = internalMutation({
|
41 |
+
args: {},
|
42 |
+
handler: async (ctx, args) => {
|
43 |
+
const before = Date.now() - VACUUM_MAX_AGE;
|
44 |
+
for (const tableName of TablesToVacuum) {
|
45 |
+
console.log(`Checking ${tableName}...`);
|
46 |
+
const exists = await ctx.db
|
47 |
+
.query(tableName)
|
48 |
+
.withIndex('by_creation_time', (q) => q.lt('_creationTime', before))
|
49 |
+
.first();
|
50 |
+
if (exists) {
|
51 |
+
console.log(`Vacuuming ${tableName}...`);
|
52 |
+
await ctx.scheduler.runAfter(0, internal.crons.vacuumTable, {
|
53 |
+
tableName,
|
54 |
+
before,
|
55 |
+
cursor: null,
|
56 |
+
soFar: 0,
|
57 |
+
});
|
58 |
+
}
|
59 |
+
}
|
60 |
+
},
|
61 |
+
});
|
62 |
+
|
63 |
+
export const vacuumTable = internalMutation({
|
64 |
+
args: {
|
65 |
+
tableName: v.string(),
|
66 |
+
before: v.number(),
|
67 |
+
cursor: v.union(v.string(), v.null()),
|
68 |
+
soFar: v.number(),
|
69 |
+
},
|
70 |
+
handler: async (ctx, { tableName, before, cursor, soFar }) => {
|
71 |
+
const results = await ctx.db
|
72 |
+
.query(tableName as TableNames)
|
73 |
+
.withIndex('by_creation_time', (q) => q.lt('_creationTime', before))
|
74 |
+
.paginate({ cursor, numItems: DELETE_BATCH_SIZE });
|
75 |
+
for (const row of results.page) {
|
76 |
+
await ctx.db.delete(row._id);
|
77 |
+
}
|
78 |
+
if (!results.isDone) {
|
79 |
+
await ctx.scheduler.runAfter(0, internal.crons.vacuumTable, {
|
80 |
+
tableName,
|
81 |
+
before,
|
82 |
+
soFar: results.page.length + soFar,
|
83 |
+
cursor: results.continueCursor,
|
84 |
+
});
|
85 |
+
} else {
|
86 |
+
console.log(`Vacuumed ${soFar + results.page.length} entries from ${tableName}`);
|
87 |
+
}
|
88 |
+
},
|
89 |
+
});
|
patches/convex/engine/abstractGame.ts
ADDED
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { ConvexError, Infer, Value, v } from 'convex/values';
|
2 |
+
import { Doc, Id } from '../_generated/dataModel';
|
3 |
+
import { ActionCtx, DatabaseReader, MutationCtx, internalQuery } from '../_generated/server';
|
4 |
+
import { engine } from '../engine/schema';
|
5 |
+
import { internal } from '../_generated/api';
|
6 |
+
|
7 |
+
export abstract class AbstractGame {
|
8 |
+
abstract tickDuration: number;
|
9 |
+
abstract stepDuration: number;
|
10 |
+
abstract maxTicksPerStep: number;
|
11 |
+
abstract maxInputsPerStep: number;
|
12 |
+
|
13 |
+
constructor(public engine: Doc<'engines'>) {}
|
14 |
+
|
15 |
+
abstract handleInput(now: number, name: string, args: object): Value;
|
16 |
+
abstract tick(now: number): void;
|
17 |
+
|
18 |
+
// Optional callback at the beginning of each step.
|
19 |
+
beginStep(now: number) {}
|
20 |
+
abstract saveStep(ctx: ActionCtx, engineUpdate: EngineUpdate): Promise<void>;
|
21 |
+
|
22 |
+
async runStep(ctx: ActionCtx, now: number) {
|
23 |
+
const inputs = await ctx.runQuery(internal.engine.abstractGame.loadInputs, {
|
24 |
+
engineId: this.engine._id,
|
25 |
+
processedInputNumber: this.engine.processedInputNumber,
|
26 |
+
max: this.maxInputsPerStep,
|
27 |
+
});
|
28 |
+
|
29 |
+
const lastStepTs = this.engine.currentTime;
|
30 |
+
const startTs = lastStepTs ? lastStepTs + this.tickDuration : now;
|
31 |
+
let currentTs = startTs;
|
32 |
+
let inputIndex = 0;
|
33 |
+
let numTicks = 0;
|
34 |
+
let processedInputNumber = this.engine.processedInputNumber;
|
35 |
+
const completedInputs = [];
|
36 |
+
|
37 |
+
this.beginStep(currentTs);
|
38 |
+
|
39 |
+
while (numTicks < this.maxTicksPerStep) {
|
40 |
+
numTicks += 1;
|
41 |
+
|
42 |
+
// Collect all of the inputs for this tick.
|
43 |
+
const tickInputs = [];
|
44 |
+
while (inputIndex < inputs.length) {
|
45 |
+
const input = inputs[inputIndex];
|
46 |
+
if (input.received > currentTs) {
|
47 |
+
break;
|
48 |
+
}
|
49 |
+
inputIndex += 1;
|
50 |
+
processedInputNumber = input.number;
|
51 |
+
tickInputs.push(input);
|
52 |
+
}
|
53 |
+
|
54 |
+
// Feed the inputs to the game.
|
55 |
+
for (const input of tickInputs) {
|
56 |
+
let returnValue;
|
57 |
+
try {
|
58 |
+
const value = this.handleInput(currentTs, input.name, input.args);
|
59 |
+
returnValue = { kind: 'ok' as const, value };
|
60 |
+
} catch (e: any) {
|
61 |
+
console.error(`Input ${input._id} failed: ${e.message}`);
|
62 |
+
returnValue = { kind: 'error' as const, message: e.message };
|
63 |
+
}
|
64 |
+
completedInputs.push({ inputId: input._id, returnValue });
|
65 |
+
}
|
66 |
+
|
67 |
+
// Simulate the game forward one tick.
|
68 |
+
this.tick(currentTs);
|
69 |
+
|
70 |
+
const candidateTs = currentTs + this.tickDuration;
|
71 |
+
if (now < candidateTs) {
|
72 |
+
break;
|
73 |
+
}
|
74 |
+
currentTs = candidateTs;
|
75 |
+
}
|
76 |
+
|
77 |
+
// Commit the step by moving time forward, consuming our inputs, and saving the game's state.
|
78 |
+
const expectedGenerationNumber = this.engine.generationNumber;
|
79 |
+
this.engine.currentTime = currentTs;
|
80 |
+
this.engine.lastStepTs = lastStepTs;
|
81 |
+
this.engine.generationNumber += 1;
|
82 |
+
this.engine.processedInputNumber = processedInputNumber;
|
83 |
+
const { _id, _creationTime, ...engine } = this.engine;
|
84 |
+
const engineUpdate = { engine, completedInputs, expectedGenerationNumber };
|
85 |
+
await this.saveStep(ctx, engineUpdate);
|
86 |
+
|
87 |
+
console.debug(`Simulated from ${startTs} to ${currentTs} (${currentTs - startTs}ms)`);
|
88 |
+
}
|
89 |
+
}
|
90 |
+
|
91 |
+
const completedInput = v.object({
|
92 |
+
inputId: v.id('inputs'),
|
93 |
+
returnValue: v.union(
|
94 |
+
v.object({
|
95 |
+
kind: v.literal('ok'),
|
96 |
+
value: v.any(),
|
97 |
+
}),
|
98 |
+
v.object({
|
99 |
+
kind: v.literal('error'),
|
100 |
+
message: v.string(),
|
101 |
+
}),
|
102 |
+
),
|
103 |
+
});
|
104 |
+
|
105 |
+
export const engineUpdate = v.object({
|
106 |
+
engine,
|
107 |
+
expectedGenerationNumber: v.number(),
|
108 |
+
completedInputs: v.array(completedInput),
|
109 |
+
});
|
110 |
+
export type EngineUpdate = Infer<typeof engineUpdate>;
|
111 |
+
|
112 |
+
export async function loadEngine(
|
113 |
+
db: DatabaseReader,
|
114 |
+
engineId: Id<'engines'>,
|
115 |
+
generationNumber: number,
|
116 |
+
) {
|
117 |
+
const engine = await db.get(engineId);
|
118 |
+
if (!engine) {
|
119 |
+
throw new Error(`No engine found with id ${engineId}`);
|
120 |
+
}
|
121 |
+
if (!engine.running) {
|
122 |
+
throw new ConvexError({
|
123 |
+
kind: 'engineNotRunning',
|
124 |
+
message: `Engine ${engineId} is not running`,
|
125 |
+
});
|
126 |
+
}
|
127 |
+
if (engine.generationNumber !== generationNumber) {
|
128 |
+
throw new ConvexError({ kind: 'generationNumber', message: 'Generation number mismatch' });
|
129 |
+
}
|
130 |
+
return engine;
|
131 |
+
}
|
132 |
+
|
133 |
+
export async function engineInsertInput(
|
134 |
+
ctx: MutationCtx,
|
135 |
+
engineId: Id<'engines'>,
|
136 |
+
name: string,
|
137 |
+
args: any,
|
138 |
+
): Promise<Id<'inputs'>> {
|
139 |
+
const now = Date.now();
|
140 |
+
const prevInput = await ctx.db
|
141 |
+
.query('inputs')
|
142 |
+
.withIndex('byInputNumber', (q) => q.eq('engineId', engineId))
|
143 |
+
.order('desc')
|
144 |
+
.first();
|
145 |
+
const number = prevInput ? prevInput.number + 1 : 0;
|
146 |
+
const inputId = await ctx.db.insert('inputs', {
|
147 |
+
engineId,
|
148 |
+
number,
|
149 |
+
name,
|
150 |
+
args,
|
151 |
+
received: now,
|
152 |
+
});
|
153 |
+
return inputId;
|
154 |
+
}
|
155 |
+
|
156 |
+
export const loadInputs = internalQuery({
|
157 |
+
args: {
|
158 |
+
engineId: v.id('engines'),
|
159 |
+
processedInputNumber: v.optional(v.number()),
|
160 |
+
max: v.number(),
|
161 |
+
},
|
162 |
+
handler: async (ctx, args) => {
|
163 |
+
return await ctx.db
|
164 |
+
.query('inputs')
|
165 |
+
.withIndex('byInputNumber', (q) =>
|
166 |
+
q.eq('engineId', args.engineId).gt('number', args.processedInputNumber ?? -1),
|
167 |
+
)
|
168 |
+
.order('asc')
|
169 |
+
.take(args.max);
|
170 |
+
},
|
171 |
+
});
|
172 |
+
|
173 |
+
export async function applyEngineUpdate(
|
174 |
+
ctx: MutationCtx,
|
175 |
+
engineId: Id<'engines'>,
|
176 |
+
update: EngineUpdate,
|
177 |
+
) {
|
178 |
+
const engine = await loadEngine(ctx.db, engineId, update.expectedGenerationNumber);
|
179 |
+
if (
|
180 |
+
engine.currentTime &&
|
181 |
+
update.engine.currentTime &&
|
182 |
+
update.engine.currentTime < engine.currentTime
|
183 |
+
) {
|
184 |
+
throw new Error('Time moving backwards');
|
185 |
+
}
|
186 |
+
await ctx.db.replace(engine._id, update.engine);
|
187 |
+
|
188 |
+
for (const completedInput of update.completedInputs) {
|
189 |
+
const input = await ctx.db.get(completedInput.inputId);
|
190 |
+
if (!input) {
|
191 |
+
throw new Error(`Input ${completedInput.inputId} not found`);
|
192 |
+
}
|
193 |
+
if (input.returnValue) {
|
194 |
+
throw new Error(`Input ${completedInput.inputId} already completed`);
|
195 |
+
}
|
196 |
+
input.returnValue = completedInput.returnValue;
|
197 |
+
await ctx.db.replace(input._id, input);
|
198 |
+
}
|
199 |
+
}
|
patches/convex/engine/historicalObject.test.ts
ADDED
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { History, packSampleRecord, unpackSampleRecord } from './historicalObject';
|
2 |
+
|
3 |
+
describe('HistoricalObject', () => {
|
4 |
+
test('pack sample record roundtrips', () => {
|
5 |
+
let data: Record<string, History> = {
|
6 |
+
x: {
|
7 |
+
initialValue: 0,
|
8 |
+
samples: [
|
9 |
+
{ time: 1696021246740, value: 1 },
|
10 |
+
{ time: 1696021246756, value: 2 },
|
11 |
+
{ time: 1696021246772, value: 3 },
|
12 |
+
{ time: 1696021246788, value: 4 },
|
13 |
+
],
|
14 |
+
},
|
15 |
+
y: {
|
16 |
+
initialValue: 140.2,
|
17 |
+
samples: [
|
18 |
+
{ time: 1696021246740, value: 169.7 },
|
19 |
+
{ time: 1696021246756, value: 237.59 },
|
20 |
+
{ time: 1696021246772, value: 344.44 },
|
21 |
+
{ time: 1696021246788, value: 489.13 },
|
22 |
+
],
|
23 |
+
},
|
24 |
+
};
|
25 |
+
const fields = [
|
26 |
+
{ name: 'x', precision: 4 },
|
27 |
+
{ name: 'y', precision: 4 },
|
28 |
+
];
|
29 |
+
const packed = packSampleRecord(fields, data);
|
30 |
+
const unpacked = unpackSampleRecord(fields, packed);
|
31 |
+
const maxError = Math.max(1 / (1 << 4), 1e-8);
|
32 |
+
|
33 |
+
expect(Object.keys(data)).toEqual(Object.keys(unpacked));
|
34 |
+
for (const key of Object.keys(data)) {
|
35 |
+
const { initialValue, samples } = data[key];
|
36 |
+
const { initialValue: unpackedInitialValue, samples: unpackedSamples } = unpacked[key];
|
37 |
+
expect(Math.abs(initialValue - unpackedInitialValue)).toBeLessThanOrEqual(maxError);
|
38 |
+
expect(samples.length).toEqual(unpackedSamples.length);
|
39 |
+
for (let i = 0; i < samples.length; i++) {
|
40 |
+
const sample = samples[i];
|
41 |
+
const unpackedSample = unpackedSamples[i];
|
42 |
+
expect(sample.time).toEqual(unpackedSample.time);
|
43 |
+
expect(Math.abs(sample.value - unpackedSample.value)).toBeLessThanOrEqual(maxError);
|
44 |
+
}
|
45 |
+
}
|
46 |
+
});
|
47 |
+
});
|
patches/convex/engine/historicalObject.ts
ADDED
@@ -0,0 +1,355 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { xxHash32 } from '../util/xxhash';
|
2 |
+
import { compressSigned, uncompressSigned } from '../util/FastIntegerCompression';
|
3 |
+
import {
|
4 |
+
runLengthEncode,
|
5 |
+
deltaEncode,
|
6 |
+
quantize,
|
7 |
+
deltaDecode,
|
8 |
+
runLengthDecode,
|
9 |
+
unquantize,
|
10 |
+
} from '../util/compression';
|
11 |
+
|
12 |
+
// `HistoricalObject`s require the developer to pass in the
|
13 |
+
// field names that'll be tracked and sent down to the client.
|
14 |
+
//
|
15 |
+
// By default, the historical tracking will round each floating point
|
16 |
+
// value to an integer. The developer can specify more or less precision
|
17 |
+
// via the `precision` parameter: the table's quantization will maintain
|
18 |
+
// less than `1 / 2^precision` error. Note that higher precision values
|
19 |
+
// imply less error.
|
20 |
+
export type FieldConfig = Array<string | { name: string; precision: number }>;
|
21 |
+
|
22 |
+
// `HistoricalObject`s support at most 16 fields.
|
23 |
+
const MAX_FIELDS = 16;
|
24 |
+
|
25 |
+
const PACKED_VERSION = 1;
|
26 |
+
|
27 |
+
type NormalizedFieldConfig = Array<{
|
28 |
+
name: string;
|
29 |
+
precision: number;
|
30 |
+
}>;
|
31 |
+
|
32 |
+
// The `History` structure represents the history of a continuous
|
33 |
+
// value over all bounded time. Each sample represents a line
|
34 |
+
// segment that's extends to the previous sample's time inclusively
|
35 |
+
// and to the sample's time non-inclusively. We track an `initialValue`
|
36 |
+
// that goes to `-\infty` up until the first sample, and the final
|
37 |
+
// sample persists out to `+\infty`.
|
38 |
+
// ```
|
39 |
+
// ^
|
40 |
+
// position
|
41 |
+
// |
|
42 |
+
// samples[0].value - | x---------------o
|
43 |
+
// |
|
44 |
+
// samples[1].value - | x-------->
|
45 |
+
// |
|
46 |
+
// initialValue - <---------o
|
47 |
+
// |
|
48 |
+
// ------------------------------> time
|
49 |
+
// | |
|
50 |
+
// samples[0].time samples[1].time
|
51 |
+
// ```
|
52 |
+
export type History = {
|
53 |
+
initialValue: number;
|
54 |
+
samples: Sample[];
|
55 |
+
};
|
56 |
+
|
57 |
+
export type Sample = {
|
58 |
+
time: number;
|
59 |
+
value: number;
|
60 |
+
};
|
61 |
+
|
62 |
+
// `HistoricalObject` tracks a set of numeric fields over time and
|
63 |
+
// supports compressing the fields' histories into a binary buffer.
|
64 |
+
// This can be useful for continuous properties like position, where
|
65 |
+
// we'd want to smoothly replay their tick-by-tick progress at a high
|
66 |
+
// frame rate on the client.
|
67 |
+
//
|
68 |
+
// `HistoricalObject`s have a few limitations:
|
69 |
+
// - Documents in a historical can only have up to 16 fields.
|
70 |
+
// - The historical tracking only applies to a specified list of fields,
|
71 |
+
// and these fields must match between the client and server.
|
72 |
+
export class HistoricalObject<T extends Record<string, number>> {
|
73 |
+
startTs?: number;
|
74 |
+
|
75 |
+
fieldConfig: NormalizedFieldConfig;
|
76 |
+
|
77 |
+
data: T;
|
78 |
+
history: Record<string, History> = {};
|
79 |
+
|
80 |
+
constructor(fields: FieldConfig, initialValue: T) {
|
81 |
+
if (fields.length >= MAX_FIELDS) {
|
82 |
+
throw new Error(`HistoricalObject can have at most ${MAX_FIELDS} fields.`);
|
83 |
+
}
|
84 |
+
this.fieldConfig = normalizeFieldConfig(fields);
|
85 |
+
this.checkShape(initialValue);
|
86 |
+
this.data = initialValue;
|
87 |
+
}
|
88 |
+
|
89 |
+
historyLength() {
|
90 |
+
return Object.values(this.history)
|
91 |
+
.map((h) => h.samples.length)
|
92 |
+
.reduce((a, b) => a + b, 0);
|
93 |
+
}
|
94 |
+
|
95 |
+
checkShape(data: any) {
|
96 |
+
for (const [key, value] of Object.entries(data)) {
|
97 |
+
if (!this.fieldConfig.find((f) => f.name === key)) {
|
98 |
+
throw new Error(`Cannot set undeclared field '${key}'`);
|
99 |
+
}
|
100 |
+
if (typeof value !== 'number') {
|
101 |
+
throw new Error(
|
102 |
+
`HistoricalObject only supports numeric values, found: ${JSON.stringify(value)}`,
|
103 |
+
);
|
104 |
+
}
|
105 |
+
}
|
106 |
+
}
|
107 |
+
|
108 |
+
update(now: number, data: T) {
|
109 |
+
this.checkShape(data);
|
110 |
+
for (const [key, value] of Object.entries(data)) {
|
111 |
+
const currentValue = this.data[key];
|
112 |
+
if (currentValue !== value) {
|
113 |
+
let history = this.history[key];
|
114 |
+
if (!history) {
|
115 |
+
this.history[key] = history = { initialValue: currentValue, samples: [] };
|
116 |
+
}
|
117 |
+
const { samples } = history;
|
118 |
+
let inserted = false;
|
119 |
+
if (samples.length > 0) {
|
120 |
+
const last = samples[samples.length - 1];
|
121 |
+
if (now < last.time) {
|
122 |
+
throw new Error(`Server time moving backwards: ${now} < ${last.time}`);
|
123 |
+
}
|
124 |
+
if (now === last.time) {
|
125 |
+
last.value = value;
|
126 |
+
inserted = true;
|
127 |
+
}
|
128 |
+
}
|
129 |
+
if (!inserted) {
|
130 |
+
samples.push({ time: now, value });
|
131 |
+
}
|
132 |
+
}
|
133 |
+
}
|
134 |
+
this.data = data;
|
135 |
+
}
|
136 |
+
|
137 |
+
pack(): ArrayBuffer | null {
|
138 |
+
if (this.historyLength() === 0) {
|
139 |
+
return null;
|
140 |
+
}
|
141 |
+
return packSampleRecord(this.fieldConfig, this.history);
|
142 |
+
}
|
143 |
+
}
|
144 |
+
|
145 |
+
// Pack (normalized) field configuration into a binary buffer.
|
146 |
+
//
|
147 |
+
// Format:
|
148 |
+
// ```
|
149 |
+
// [ u8 version ]
|
150 |
+
// for each field config:
|
151 |
+
// [ u8 field name length ]
|
152 |
+
// [ UTF8 encoded field name ]
|
153 |
+
// [ u8 precision ]
|
154 |
+
// ```
|
155 |
+
function packFieldConfig(fields: NormalizedFieldConfig) {
|
156 |
+
const out = new ArrayBuffer(1024);
|
157 |
+
const outView = new DataView(out);
|
158 |
+
let pos = 0;
|
159 |
+
|
160 |
+
outView.setUint8(pos, PACKED_VERSION);
|
161 |
+
pos += 1;
|
162 |
+
|
163 |
+
const encoder = new TextEncoder();
|
164 |
+
for (const fieldConfig of fields) {
|
165 |
+
const name = encoder.encode(fieldConfig.name);
|
166 |
+
|
167 |
+
outView.setUint8(pos, name.length);
|
168 |
+
pos += 1;
|
169 |
+
|
170 |
+
new Uint8Array(out, pos, name.length).set(name);
|
171 |
+
pos += name.length;
|
172 |
+
|
173 |
+
outView.setUint8(pos, fieldConfig.precision);
|
174 |
+
pos += 1;
|
175 |
+
}
|
176 |
+
return out.slice(0, pos);
|
177 |
+
}
|
178 |
+
|
179 |
+
// Pack a document's sample record into a binary buffer.
|
180 |
+
//
|
181 |
+
// We encode each field's history with a few layered forms of
|
182 |
+
// compression:
|
183 |
+
// 1. Quantization: Turn each floating point number into an integer
|
184 |
+
// by multiplying by 2^precision and then `Math.floor()`.
|
185 |
+
// 2. Delta encoding: Assume that values are continuous and don't
|
186 |
+
// abruptly change over time, so their differences will be small.
|
187 |
+
// This step turns the large integers from (1) into small ones.
|
188 |
+
// 3. Run length encoding (optional): Assume that some quantities
|
189 |
+
// in the system will have constant velocity, so encode `k`
|
190 |
+
// repetitions of `n` as `[k, n]`. If run length encoding doesn't
|
191 |
+
// make (2) smaller, we skip it.
|
192 |
+
// 4. Varint encoding: Using FastIntegerCompression.js, we use a
|
193 |
+
// variable length integer encoding that uses fewer bytes for
|
194 |
+
// smaller numbers.
|
195 |
+
//
|
196 |
+
// Format:
|
197 |
+
// ```
|
198 |
+
// [ 4 byte xxhash of packed field config ]
|
199 |
+
//
|
200 |
+
// for each set field:
|
201 |
+
// [ 0 0 0 useRLE? ]
|
202 |
+
// [ u4 field number ]
|
203 |
+
//
|
204 |
+
// Sample timestamps:
|
205 |
+
// [ u64le initial timestamp ]
|
206 |
+
// [ u16le timestamp buffer length ]
|
207 |
+
// [ vint(RLE(delta(remaining timestamps)))]
|
208 |
+
//
|
209 |
+
// Sample values:
|
210 |
+
// [ u16le value buffer length ]
|
211 |
+
// [ vint(RLE?(delta([initialValue, ...values])))]
|
212 |
+
// ```
|
213 |
+
export function packSampleRecord(
|
214 |
+
fields: NormalizedFieldConfig,
|
215 |
+
sampleRecord: Record<string, History>,
|
216 |
+
): ArrayBuffer {
|
217 |
+
const out = new ArrayBuffer(65536);
|
218 |
+
const outView = new DataView(out);
|
219 |
+
let pos = 0;
|
220 |
+
|
221 |
+
const configHash = xxHash32(new Uint8Array(packFieldConfig(fields)));
|
222 |
+
outView.setUint32(pos, configHash, true);
|
223 |
+
pos += 4;
|
224 |
+
|
225 |
+
for (let fieldNumber = 0; fieldNumber < fields.length; fieldNumber += 1) {
|
226 |
+
const { name, precision } = fields[fieldNumber];
|
227 |
+
const history = sampleRecord[name];
|
228 |
+
if (!history || history.samples.length === 0) {
|
229 |
+
continue;
|
230 |
+
}
|
231 |
+
|
232 |
+
const timestamps = history.samples.map((s) => Math.floor(s.time));
|
233 |
+
const initialTimestamp = timestamps[0];
|
234 |
+
const encodedTimestamps = runLengthEncode(deltaEncode(timestamps.slice(1), initialTimestamp));
|
235 |
+
const compressedTimestamps = compressSigned(encodedTimestamps);
|
236 |
+
if (compressedTimestamps.byteLength >= 1 << 16) {
|
237 |
+
throw new Error(`Compressed buffer too long: ${compressedTimestamps.byteLength}`);
|
238 |
+
}
|
239 |
+
|
240 |
+
const values = [history.initialValue, ...history.samples.map((s) => s.value)];
|
241 |
+
const quantized = quantize(values, precision);
|
242 |
+
const deltaEncoded = deltaEncode(quantized);
|
243 |
+
const runLengthEncoded = runLengthEncode(deltaEncoded);
|
244 |
+
|
245 |
+
// Decide if we're going to run length encode the values based on whether
|
246 |
+
// it actually made the encoded buffer smaller.
|
247 |
+
const useRLE = runLengthEncoded.length < deltaEncoded.length;
|
248 |
+
let fieldHeader = fieldNumber;
|
249 |
+
if (useRLE) {
|
250 |
+
fieldHeader |= 1 << 4;
|
251 |
+
}
|
252 |
+
|
253 |
+
const encoded = useRLE ? runLengthEncoded : deltaEncoded;
|
254 |
+
const compressed = compressSigned(encoded);
|
255 |
+
if (compressed.byteLength >= 1 << 16) {
|
256 |
+
throw new Error(`Compressed buffer too long: ${compressed.byteLength}`);
|
257 |
+
}
|
258 |
+
|
259 |
+
outView.setUint8(pos, fieldHeader);
|
260 |
+
pos += 1;
|
261 |
+
|
262 |
+
outView.setBigUint64(pos, BigInt(initialTimestamp), true);
|
263 |
+
pos += 8;
|
264 |
+
|
265 |
+
outView.setUint16(pos, compressedTimestamps.byteLength, true);
|
266 |
+
pos += 2;
|
267 |
+
|
268 |
+
new Uint8Array(out, pos, compressedTimestamps.byteLength).set(
|
269 |
+
new Uint8Array(compressedTimestamps),
|
270 |
+
);
|
271 |
+
pos += compressedTimestamps.byteLength;
|
272 |
+
|
273 |
+
outView.setUint16(pos, compressed.byteLength, true);
|
274 |
+
pos += 2;
|
275 |
+
|
276 |
+
new Uint8Array(out, pos, compressed.byteLength).set(new Uint8Array(compressed));
|
277 |
+
pos += compressed.byteLength;
|
278 |
+
}
|
279 |
+
|
280 |
+
return out.slice(0, pos);
|
281 |
+
}
|
282 |
+
|
283 |
+
export function unpackSampleRecord(fields: FieldConfig, buffer: ArrayBuffer) {
|
284 |
+
const view = new DataView(buffer);
|
285 |
+
let pos = 0;
|
286 |
+
|
287 |
+
const normalizedFields = normalizeFieldConfig(fields);
|
288 |
+
const expectedConfigHash = xxHash32(new Uint8Array(packFieldConfig(normalizedFields)));
|
289 |
+
|
290 |
+
const configHash = view.getUint32(pos, true);
|
291 |
+
pos += 4;
|
292 |
+
|
293 |
+
if (configHash !== expectedConfigHash) {
|
294 |
+
throw new Error(`Config hash mismatch: ${configHash} !== ${expectedConfigHash}`);
|
295 |
+
}
|
296 |
+
|
297 |
+
const out = {} as Record<string, History>;
|
298 |
+
while (pos < buffer.byteLength) {
|
299 |
+
const fieldHeader = view.getUint8(pos);
|
300 |
+
pos += 1;
|
301 |
+
|
302 |
+
const fieldNumber = fieldHeader & 0b00001111;
|
303 |
+
const useRLE = (fieldHeader & (1 << 4)) !== 0;
|
304 |
+
const fieldConfig = normalizedFields[fieldNumber];
|
305 |
+
if (!fieldConfig) {
|
306 |
+
throw new Error(`Invalid field number: ${fieldNumber}`);
|
307 |
+
}
|
308 |
+
|
309 |
+
const initialTimestamp = Number(view.getBigUint64(pos, true));
|
310 |
+
pos += 8;
|
311 |
+
|
312 |
+
const compressedTimestampLength = view.getUint16(pos, true);
|
313 |
+
pos += 2;
|
314 |
+
|
315 |
+
const compressedTimestampBuffer = buffer.slice(pos, pos + compressedTimestampLength);
|
316 |
+
pos += compressedTimestampLength;
|
317 |
+
|
318 |
+
const timestamps = [
|
319 |
+
initialTimestamp,
|
320 |
+
...deltaDecode(
|
321 |
+
runLengthDecode(uncompressSigned(compressedTimestampBuffer)),
|
322 |
+
initialTimestamp,
|
323 |
+
),
|
324 |
+
];
|
325 |
+
|
326 |
+
const compressedLength = view.getUint16(pos, true);
|
327 |
+
pos += 2;
|
328 |
+
|
329 |
+
const compressedBuffer = buffer.slice(pos, pos + compressedLength);
|
330 |
+
pos += compressedLength;
|
331 |
+
|
332 |
+
const encoded = uncompressSigned(compressedBuffer);
|
333 |
+
const deltaEncoded = useRLE ? runLengthDecode(encoded) : encoded;
|
334 |
+
const quantized = deltaDecode(deltaEncoded);
|
335 |
+
const values = unquantize(quantized, fieldConfig.precision);
|
336 |
+
|
337 |
+
if (timestamps.length + 1 !== values.length) {
|
338 |
+
throw new Error(`Invalid sample record: ${timestamps.length} + 1 !== ${values.length}`);
|
339 |
+
}
|
340 |
+
const initialValue = values[0];
|
341 |
+
const samples = [];
|
342 |
+
for (let i = 0; i < timestamps.length; i++) {
|
343 |
+
const time = timestamps[i];
|
344 |
+
const value = values[i + 1];
|
345 |
+
samples.push({ value, time });
|
346 |
+
}
|
347 |
+
const history = { initialValue, samples };
|
348 |
+
out[fieldConfig.name] = history;
|
349 |
+
}
|
350 |
+
return out;
|
351 |
+
}
|
352 |
+
|
353 |
+
function normalizeFieldConfig(fields: FieldConfig): NormalizedFieldConfig {
|
354 |
+
return fields.map((f) => (typeof f === 'string' ? { name: f, precision: 0 } : f));
|
355 |
+
}
|
patches/convex/engine/schema.ts
ADDED
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { defineTable } from 'convex/server';
|
2 |
+
import { Infer, v } from 'convex/values';
|
3 |
+
|
4 |
+
const input = v.object({
|
5 |
+
// Inputs are scoped to a single engine.
|
6 |
+
engineId: v.id('engines'),
|
7 |
+
|
8 |
+
// Monotonically increasing input number within a world starting at 0.
|
9 |
+
number: v.number(),
|
10 |
+
|
11 |
+
// Name of the input handler to run.
|
12 |
+
name: v.string(),
|
13 |
+
// Dynamically typed arguments and return value for the input handler. We'll
|
14 |
+
// provide type safety at a higher layer.
|
15 |
+
args: v.any(),
|
16 |
+
returnValue: v.optional(
|
17 |
+
v.union(
|
18 |
+
v.object({
|
19 |
+
kind: v.literal('ok'),
|
20 |
+
value: v.any(),
|
21 |
+
}),
|
22 |
+
v.object({
|
23 |
+
kind: v.literal('error'),
|
24 |
+
message: v.string(),
|
25 |
+
}),
|
26 |
+
),
|
27 |
+
),
|
28 |
+
|
29 |
+
// Timestamp when the server received the input. This timestamp is best-effort,
|
30 |
+
// since we don't guarantee strict monotonicity here. So, an input may not get
|
31 |
+
// assigned to the engine step whose time interval contains this timestamp.
|
32 |
+
received: v.number(),
|
33 |
+
});
|
34 |
+
|
35 |
+
export const engine = v.object({
|
36 |
+
// What is the current simulation time for the engine? Monotonically increasing.
|
37 |
+
currentTime: v.optional(v.number()),
|
38 |
+
// What was `currentTime` for the preceding step of the engine?
|
39 |
+
lastStepTs: v.optional(v.number()),
|
40 |
+
|
41 |
+
// How far has the engine processed in the input queue?
|
42 |
+
processedInputNumber: v.optional(v.number()),
|
43 |
+
|
44 |
+
running: v.boolean(),
|
45 |
+
|
46 |
+
// Monotonically increasing counter that serializes all engine runs. If we ever
|
47 |
+
// end up with two steps overlapping in time, this counter will force them to
|
48 |
+
// conflict.
|
49 |
+
generationNumber: v.number(),
|
50 |
+
});
|
51 |
+
export type Engine = Infer<typeof engine>;
|
52 |
+
|
53 |
+
export const engineTables = {
|
54 |
+
inputs: defineTable(input).index('byInputNumber', ['engineId', 'number']),
|
55 |
+
engines: defineTable(engine),
|
56 |
+
};
|
patches/convex/http.ts
ADDED
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { httpRouter } from 'convex/server';
|
2 |
+
import { handleReplicateWebhook } from './music';
|
3 |
+
|
4 |
+
const http = httpRouter();
|
5 |
+
http.route({
|
6 |
+
path: '/replicate_webhook',
|
7 |
+
method: 'POST',
|
8 |
+
handler: handleReplicateWebhook,
|
9 |
+
});
|
10 |
+
export default http;
|
patches/convex/init.ts
ADDED
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { v } from 'convex/values';
|
2 |
+
import { internal } from './_generated/api';
|
3 |
+
import { DatabaseReader, MutationCtx, mutation } from './_generated/server';
|
4 |
+
import { Descriptions } from '../data/characters';
|
5 |
+
import * as map from '../data/gentle';
|
6 |
+
import { insertInput } from './aiTown/insertInput';
|
7 |
+
import { Id } from './_generated/dataModel';
|
8 |
+
import { createEngine } from './aiTown/main';
|
9 |
+
import { ENGINE_ACTION_DURATION, DAY_DURATION, NIGHT_DURATION } from './constants';
|
10 |
+
import { assertApiKey } from './util/llm';
|
11 |
+
|
12 |
+
const init = mutation({
|
13 |
+
args: {
|
14 |
+
numAgents: v.optional(v.number()),
|
15 |
+
},
|
16 |
+
handler: async (ctx, args) => {
|
17 |
+
assertApiKey();
|
18 |
+
const { worldStatus, engine } = await getOrCreateDefaultWorld(ctx);
|
19 |
+
if (worldStatus.status !== 'running') {
|
20 |
+
console.warn(
|
21 |
+
`Engine ${engine._id} is not active! Run "npx convex run testing:resume" to restart it.`,
|
22 |
+
);
|
23 |
+
return;
|
24 |
+
}
|
25 |
+
const shouldCreate = await shouldCreateAgents(
|
26 |
+
ctx.db,
|
27 |
+
worldStatus.worldId,
|
28 |
+
worldStatus.engineId,
|
29 |
+
);
|
30 |
+
if (shouldCreate) {
|
31 |
+
const toCreate = args.numAgents !== undefined ? args.numAgents : Descriptions.length;
|
32 |
+
for (let i = 0; i < toCreate; i++) {
|
33 |
+
await insertInput(ctx, worldStatus.worldId, 'createAgent', {
|
34 |
+
descriptionIndex: i % Descriptions.length,
|
35 |
+
});
|
36 |
+
}
|
37 |
+
}
|
38 |
+
},
|
39 |
+
});
|
40 |
+
export default init;
|
41 |
+
|
42 |
+
async function getOrCreateDefaultWorld(ctx: MutationCtx) {
|
43 |
+
const now = Date.now();
|
44 |
+
|
45 |
+
let worldStatus = await ctx.db
|
46 |
+
.query('worldStatus')
|
47 |
+
.filter((q) => q.eq(q.field('isDefault'), true))
|
48 |
+
.unique();
|
49 |
+
if (worldStatus) {
|
50 |
+
const engine = (await ctx.db.get(worldStatus.engineId))!;
|
51 |
+
return { worldStatus, engine };
|
52 |
+
}
|
53 |
+
|
54 |
+
const engineId = await createEngine(ctx);
|
55 |
+
const engine = (await ctx.db.get(engineId))!;
|
56 |
+
const worldId = await ctx.db.insert('worlds', {
|
57 |
+
nextId: 0,
|
58 |
+
agents: [],
|
59 |
+
conversations: [],
|
60 |
+
players: [],
|
61 |
+
// initialize day & night cycle counter
|
62 |
+
dayNightCycle: {
|
63 |
+
currentTime: 0,
|
64 |
+
isDay: true,
|
65 |
+
dayDuration: DAY_DURATION,
|
66 |
+
nightDuration: NIGHT_DURATION,
|
67 |
+
},
|
68 |
+
});
|
69 |
+
const worldStatusId = await ctx.db.insert('worldStatus', {
|
70 |
+
engineId: engineId,
|
71 |
+
isDefault: true,
|
72 |
+
lastViewed: now,
|
73 |
+
status: 'running',
|
74 |
+
worldId: worldId,
|
75 |
+
});
|
76 |
+
worldStatus = (await ctx.db.get(worldStatusId))!;
|
77 |
+
await ctx.db.insert('maps', {
|
78 |
+
worldId,
|
79 |
+
width: map.mapwidth,
|
80 |
+
height: map.mapheight,
|
81 |
+
tileSetUrl: map.tilesetpath,
|
82 |
+
tileSetAlternateUrl: map.tilesetalternatepath,
|
83 |
+
tileSetDimX: map.tilesetpxw,
|
84 |
+
tileSetDimY: map.tilesetpxh,
|
85 |
+
tileDim: map.tiledim,
|
86 |
+
bgTiles: map.bgtiles,
|
87 |
+
objectTiles: map.objmap,
|
88 |
+
decorTiles: map.decors,
|
89 |
+
bgTilesN: map.bgtilesN,
|
90 |
+
objectTilesN: map.objmapN,
|
91 |
+
decorTilesN: map.decorsN,
|
92 |
+
animatedSprites: map.animatedsprites,
|
93 |
+
});
|
94 |
+
await ctx.scheduler.runAfter(0, internal.aiTown.main.runStep, {
|
95 |
+
worldId,
|
96 |
+
generationNumber: engine.generationNumber,
|
97 |
+
maxDuration: ENGINE_ACTION_DURATION,
|
98 |
+
});
|
99 |
+
return { worldStatus, engine };
|
100 |
+
}
|
101 |
+
|
102 |
+
async function shouldCreateAgents(
|
103 |
+
db: DatabaseReader,
|
104 |
+
worldId: Id<'worlds'>,
|
105 |
+
engineId: Id<'engines'>,
|
106 |
+
) {
|
107 |
+
const world = await db.get(worldId);
|
108 |
+
if (!world) {
|
109 |
+
throw new Error(`Invalid world ID: ${worldId}`);
|
110 |
+
}
|
111 |
+
if (world.agents.length > 0) {
|
112 |
+
return false;
|
113 |
+
}
|
114 |
+
const unactionedJoinInputs = await db
|
115 |
+
.query('inputs')
|
116 |
+
.withIndex('byInputNumber', (q) => q.eq('engineId', engineId))
|
117 |
+
.order('asc')
|
118 |
+
.filter((q) => q.eq(q.field('name'), 'createAgent'))
|
119 |
+
.filter((q) => q.eq(q.field('returnValue'), undefined))
|
120 |
+
.collect();
|
121 |
+
if (unactionedJoinInputs.length > 0) {
|
122 |
+
return false;
|
123 |
+
}
|
124 |
+
return true;
|
125 |
+
}
|
patches/convex/messages.ts
ADDED
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { v } from 'convex/values';
|
2 |
+
import { mutation, query } from './_generated/server';
|
3 |
+
import { insertInput } from './aiTown/insertInput';
|
4 |
+
import { conversationId, playerId } from './aiTown/ids';
|
5 |
+
|
6 |
+
export const listMessages = query({
|
7 |
+
args: {
|
8 |
+
worldId: v.id('worlds'),
|
9 |
+
conversationId,
|
10 |
+
},
|
11 |
+
handler: async (ctx, args) => {
|
12 |
+
const messages = await ctx.db
|
13 |
+
.query('messages')
|
14 |
+
.withIndex('conversationId', (q) => q.eq('worldId', args.worldId).eq('conversationId', args.conversationId))
|
15 |
+
.collect();
|
16 |
+
const out = [];
|
17 |
+
for (const message of messages) {
|
18 |
+
const playerDescription = await ctx.db
|
19 |
+
.query('playerDescriptions')
|
20 |
+
.withIndex('worldId', (q) => q.eq('worldId', args.worldId).eq('playerId', message.author))
|
21 |
+
.first();
|
22 |
+
if (!playerDescription) {
|
23 |
+
throw new Error(`Invalid author ID: ${message.author}`);
|
24 |
+
}
|
25 |
+
out.push({ ...message, authorName: playerDescription.name });
|
26 |
+
}
|
27 |
+
return out;
|
28 |
+
},
|
29 |
+
});
|
30 |
+
|
31 |
+
export const writeMessage = mutation({
|
32 |
+
args: {
|
33 |
+
worldId: v.id('worlds'),
|
34 |
+
conversationId,
|
35 |
+
messageUuid: v.string(),
|
36 |
+
playerId,
|
37 |
+
text: v.string(),
|
38 |
+
},
|
39 |
+
handler: async (ctx, args) => {
|
40 |
+
await ctx.db.insert('messages', {
|
41 |
+
conversationId: args.conversationId,
|
42 |
+
author: args.playerId,
|
43 |
+
messageUuid: args.messageUuid,
|
44 |
+
text: args.text,
|
45 |
+
worldId: args.worldId,
|
46 |
+
});
|
47 |
+
await insertInput(ctx, args.worldId, 'finishSendingMessage', {
|
48 |
+
conversationId: args.conversationId,
|
49 |
+
playerId: args.playerId,
|
50 |
+
timestamp: Date.now(),
|
51 |
+
});
|
52 |
+
},
|
53 |
+
});
|
patches/{music.ts → convex/music.ts}
RENAMED
@@ -1,135 +1,135 @@
|
|
1 |
-
import { v } from 'convex/values';
|
2 |
-
import { query, internalMutation } from './_generated/server';
|
3 |
-
import Replicate, { WebhookEventType } from 'replicate';
|
4 |
-
import { httpAction, internalAction } from './_generated/server';
|
5 |
-
import { internal, api } from './_generated/api';
|
6 |
-
|
7 |
-
function client(): Replicate {
|
8 |
-
const replicate = new Replicate({
|
9 |
-
auth: process.env.REPLICATE_API_TOKEN || '',
|
10 |
-
});
|
11 |
-
return replicate;
|
12 |
-
}
|
13 |
-
|
14 |
-
function replicateAvailable(): boolean {
|
15 |
-
return !!process.env.REPLICATE_API_TOKEN;
|
16 |
-
}
|
17 |
-
|
18 |
-
export const insertMusic = internalMutation({
|
19 |
-
args: { storageId: v.string(), type: v.union(v.literal('background'), v.literal('player')) },
|
20 |
-
handler: async (ctx, args) => {
|
21 |
-
await ctx.db.insert('music', {
|
22 |
-
storageId: args.storageId,
|
23 |
-
type: args.type,
|
24 |
-
});
|
25 |
-
},
|
26 |
-
});
|
27 |
-
|
28 |
-
export const getBackgroundMusic = query({
|
29 |
-
handler: async (ctx) => {
|
30 |
-
const music = await ctx.db
|
31 |
-
.query('music')
|
32 |
-
.filter((entry) => entry.eq(entry.field('type'), 'background'))
|
33 |
-
.order('desc')
|
34 |
-
.first();
|
35 |
-
if (!music) {
|
36 |
-
return '/assets/background.mp3';
|
37 |
-
}
|
38 |
-
const url = await ctx.storage.getUrl(music.storageId);
|
39 |
-
if (!url) {
|
40 |
-
throw new Error(`Invalid storage ID: ${music.storageId}`);
|
41 |
-
}
|
42 |
-
return url;
|
43 |
-
},
|
44 |
-
});
|
45 |
-
|
46 |
-
export const enqueueBackgroundMusicGeneration = internalAction({
|
47 |
-
handler: async (ctx): Promise<void> => {
|
48 |
-
if (!replicateAvailable()) {
|
49 |
-
return;
|
50 |
-
}
|
51 |
-
const worldStatus = await ctx.runQuery(api.world.defaultWorldStatus);
|
52 |
-
if (!worldStatus) {
|
53 |
-
console.log('No active default world, returning.');
|
54 |
-
return;
|
55 |
-
}
|
56 |
-
// TODO: MusicGen-Large on Replicate only allows 30 seconds. Use MusicGen-Small for longer?
|
57 |
-
await generateMusic('16-bit RPG adventure game with wholesome vibe', 30);
|
58 |
-
},
|
59 |
-
});
|
60 |
-
|
61 |
-
export const handleReplicateWebhook = httpAction(async (ctx, request) => {
|
62 |
-
const req = await request.json();
|
63 |
-
if (req.id) {
|
64 |
-
const prediction = await client().predictions.get(req.id);
|
65 |
-
const response = await fetch(prediction.output);
|
66 |
-
const music = await response.blob();
|
67 |
-
const storageId = await ctx.storage.store(music);
|
68 |
-
await ctx.runMutation(internal.music.insertMusic, { type: 'background', storageId });
|
69 |
-
}
|
70 |
-
return new Response();
|
71 |
-
});
|
72 |
-
|
73 |
-
enum MusicGenNormStrategy {
|
74 |
-
Clip = 'clip',
|
75 |
-
Loudness = 'loudness',
|
76 |
-
Peak = 'peak',
|
77 |
-
Rms = 'rms',
|
78 |
-
}
|
79 |
-
|
80 |
-
enum MusicGenFormat {
|
81 |
-
wav = 'wav',
|
82 |
-
mp3 = 'mp3',
|
83 |
-
}
|
84 |
-
|
85 |
-
/**
|
86 |
-
*
|
87 |
-
* @param prompt A description of the music you want to generate.
|
88 |
-
* @param duration Duration of the generated audio in seconds.
|
89 |
-
* @param webhook webhook URL for Replicate to call when @param webhook_events_filter is triggered
|
90 |
-
* @param webhook_events_filter Array of event names to filter the webhook. See https://replicate.com/docs/reference/http#predictions.create--webhook_events_filter
|
91 |
-
* @param normalization_strategy Strategy for normalizing audio.
|
92 |
-
* @param top_k Reduces sampling to the k most likely tokens.
|
93 |
-
* @param top_p Reduces sampling to tokens with cumulative probability of p. When set to `0` (default), top_k sampling is used.
|
94 |
-
* @param temperature Controls the 'conservativeness' of the sampling process. Higher temperature means more diversity.
|
95 |
-
* @param classifer_free_gudance Increases the influence of inputs on the output. Higher values produce lower-varience outputs that adhere more closely to inputs.
|
96 |
-
* @param output_format Output format for generated audio. See @
|
97 |
-
* @param seed Seed for random number generator. If None or -1, a random seed will be used.
|
98 |
-
* @returns object containing metadata of the prediction with ID to fetch once result is completed
|
99 |
-
*/
|
100 |
-
export async function generateMusic(
|
101 |
-
prompt: string,
|
102 |
-
duration: number,
|
103 |
-
webhook: string = process.env.CONVEX_SITE_URL + '/replicate_webhook' || '',
|
104 |
-
webhook_events_filter: [WebhookEventType] = ['completed'],
|
105 |
-
normalization_strategy: MusicGenNormStrategy = MusicGenNormStrategy.Peak,
|
106 |
-
output_format: MusicGenFormat = MusicGenFormat.mp3,
|
107 |
-
top_k = 250,
|
108 |
-
top_p = 0,
|
109 |
-
temperature = 1,
|
110 |
-
classifer_free_gudance = 3,
|
111 |
-
seed = -1,
|
112 |
-
model_version = 'large',
|
113 |
-
) {
|
114 |
-
if (!replicateAvailable()) {
|
115 |
-
throw new Error('Replicate API token not set');
|
116 |
-
}
|
117 |
-
return await client().predictions.create({
|
118 |
-
// https://replicate.com/facebookresearch/musicgen/versions/7a76a8258b23fae65c5a22debb8841d1d7e816b75c2f24218cd2bd8573787906
|
119 |
-
version: '7a76a8258b23fae65c5a22debb8841d1d7e816b75c2f24218cd2bd8573787906',
|
120 |
-
input: {
|
121 |
-
model_version,
|
122 |
-
prompt,
|
123 |
-
duration,
|
124 |
-
normalization_strategy,
|
125 |
-
top_k,
|
126 |
-
top_p,
|
127 |
-
temperature,
|
128 |
-
classifer_free_gudance,
|
129 |
-
output_format,
|
130 |
-
seed,
|
131 |
-
},
|
132 |
-
webhook,
|
133 |
-
webhook_events_filter,
|
134 |
-
});
|
135 |
-
}
|
|
|
1 |
+
import { v } from 'convex/values';
|
2 |
+
import { query, internalMutation } from './_generated/server';
|
3 |
+
import Replicate, { WebhookEventType } from 'replicate';
|
4 |
+
import { httpAction, internalAction } from './_generated/server';
|
5 |
+
import { internal, api } from './_generated/api';
|
6 |
+
|
7 |
+
function client(): Replicate {
|
8 |
+
const replicate = new Replicate({
|
9 |
+
auth: process.env.REPLICATE_API_TOKEN || '',
|
10 |
+
});
|
11 |
+
return replicate;
|
12 |
+
}
|
13 |
+
|
14 |
+
function replicateAvailable(): boolean {
|
15 |
+
return !!process.env.REPLICATE_API_TOKEN;
|
16 |
+
}
|
17 |
+
|
18 |
+
export const insertMusic = internalMutation({
|
19 |
+
args: { storageId: v.string(), type: v.union(v.literal('background'), v.literal('player')) },
|
20 |
+
handler: async (ctx, args) => {
|
21 |
+
await ctx.db.insert('music', {
|
22 |
+
storageId: args.storageId,
|
23 |
+
type: args.type,
|
24 |
+
});
|
25 |
+
},
|
26 |
+
});
|
27 |
+
|
28 |
+
export const getBackgroundMusic = query({
|
29 |
+
handler: async (ctx) => {
|
30 |
+
const music = await ctx.db
|
31 |
+
.query('music')
|
32 |
+
.filter((entry) => entry.eq(entry.field('type'), 'background'))
|
33 |
+
.order('desc')
|
34 |
+
.first();
|
35 |
+
if (!music) {
|
36 |
+
return '/assets/background.mp3';
|
37 |
+
}
|
38 |
+
const url = await ctx.storage.getUrl(music.storageId);
|
39 |
+
if (!url) {
|
40 |
+
throw new Error(`Invalid storage ID: ${music.storageId}`);
|
41 |
+
}
|
42 |
+
return url;
|
43 |
+
},
|
44 |
+
});
|
45 |
+
|
46 |
+
export const enqueueBackgroundMusicGeneration = internalAction({
|
47 |
+
handler: async (ctx): Promise<void> => {
|
48 |
+
if (!replicateAvailable()) {
|
49 |
+
return;
|
50 |
+
}
|
51 |
+
const worldStatus = await ctx.runQuery(api.world.defaultWorldStatus);
|
52 |
+
if (!worldStatus) {
|
53 |
+
console.log('No active default world, returning.');
|
54 |
+
return;
|
55 |
+
}
|
56 |
+
// TODO: MusicGen-Large on Replicate only allows 30 seconds. Use MusicGen-Small for longer?
|
57 |
+
await generateMusic('16-bit RPG adventure game with wholesome vibe', 30);
|
58 |
+
},
|
59 |
+
});
|
60 |
+
|
61 |
+
export const handleReplicateWebhook = httpAction(async (ctx, request) => {
|
62 |
+
const req = await request.json();
|
63 |
+
if (req.id) {
|
64 |
+
const prediction = await client().predictions.get(req.id);
|
65 |
+
const response = await fetch(prediction.output);
|
66 |
+
const music = await response.blob();
|
67 |
+
const storageId = await ctx.storage.store(music);
|
68 |
+
await ctx.runMutation(internal.music.insertMusic, { type: 'background', storageId });
|
69 |
+
}
|
70 |
+
return new Response();
|
71 |
+
});
|
72 |
+
|
73 |
+
enum MusicGenNormStrategy {
|
74 |
+
Clip = 'clip',
|
75 |
+
Loudness = 'loudness',
|
76 |
+
Peak = 'peak',
|
77 |
+
Rms = 'rms',
|
78 |
+
}
|
79 |
+
|
80 |
+
enum MusicGenFormat {
|
81 |
+
wav = 'wav',
|
82 |
+
mp3 = 'mp3',
|
83 |
+
}
|
84 |
+
|
85 |
+
/**
|
86 |
+
*
|
87 |
+
* @param prompt A description of the music you want to generate.
|
88 |
+
* @param duration Duration of the generated audio in seconds.
|
89 |
+
* @param webhook webhook URL for Replicate to call when @param webhook_events_filter is triggered
|
90 |
+
* @param webhook_events_filter Array of event names to filter the webhook. See https://replicate.com/docs/reference/http#predictions.create--webhook_events_filter
|
91 |
+
* @param normalization_strategy Strategy for normalizing audio.
|
92 |
+
* @param top_k Reduces sampling to the k most likely tokens.
|
93 |
+
* @param top_p Reduces sampling to tokens with cumulative probability of p. When set to `0` (default), top_k sampling is used.
|
94 |
+
* @param temperature Controls the 'conservativeness' of the sampling process. Higher temperature means more diversity.
|
95 |
+
* @param classifer_free_gudance Increases the influence of inputs on the output. Higher values produce lower-varience outputs that adhere more closely to inputs.
|
96 |
+
* @param output_format Output format for generated audio. See @
|
97 |
+
* @param seed Seed for random number generator. If None or -1, a random seed will be used.
|
98 |
+
* @returns object containing metadata of the prediction with ID to fetch once result is completed
|
99 |
+
*/
|
100 |
+
export async function generateMusic(
|
101 |
+
prompt: string,
|
102 |
+
duration: number,
|
103 |
+
webhook: string = process.env.CONVEX_SITE_URL + '/replicate_webhook' || '',
|
104 |
+
webhook_events_filter: [WebhookEventType] = ['completed'],
|
105 |
+
normalization_strategy: MusicGenNormStrategy = MusicGenNormStrategy.Peak,
|
106 |
+
output_format: MusicGenFormat = MusicGenFormat.mp3,
|
107 |
+
top_k = 250,
|
108 |
+
top_p = 0,
|
109 |
+
temperature = 1,
|
110 |
+
classifer_free_gudance = 3,
|
111 |
+
seed = -1,
|
112 |
+
model_version = 'large',
|
113 |
+
) {
|
114 |
+
if (!replicateAvailable()) {
|
115 |
+
throw new Error('Replicate API token not set');
|
116 |
+
}
|
117 |
+
return await client().predictions.create({
|
118 |
+
// https://replicate.com/facebookresearch/musicgen/versions/7a76a8258b23fae65c5a22debb8841d1d7e816b75c2f24218cd2bd8573787906
|
119 |
+
version: '7a76a8258b23fae65c5a22debb8841d1d7e816b75c2f24218cd2bd8573787906',
|
120 |
+
input: {
|
121 |
+
model_version,
|
122 |
+
prompt,
|
123 |
+
duration,
|
124 |
+
normalization_strategy,
|
125 |
+
top_k,
|
126 |
+
top_p,
|
127 |
+
temperature,
|
128 |
+
classifer_free_gudance,
|
129 |
+
output_format,
|
130 |
+
seed,
|
131 |
+
},
|
132 |
+
webhook,
|
133 |
+
webhook_events_filter,
|
134 |
+
});
|
135 |
+
}
|
patches/convex/schema.ts
ADDED
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { defineSchema, defineTable } from 'convex/server';
|
2 |
+
import { v } from 'convex/values';
|
3 |
+
import { agentTables } from './agent/schema';
|
4 |
+
import { aiTownTables } from './aiTown/schema';
|
5 |
+
import { conversationId, playerId } from './aiTown/ids';
|
6 |
+
import { engineTables } from './engine/schema';
|
7 |
+
|
8 |
+
export default defineSchema({
|
9 |
+
music: defineTable({
|
10 |
+
storageId: v.string(),
|
11 |
+
type: v.union(v.literal('background'), v.literal('player')),
|
12 |
+
}),
|
13 |
+
|
14 |
+
messages: defineTable({
|
15 |
+
conversationId,
|
16 |
+
messageUuid: v.string(),
|
17 |
+
author: playerId,
|
18 |
+
text: v.string(),
|
19 |
+
worldId: v.optional(v.id('worlds')),
|
20 |
+
})
|
21 |
+
.index('conversationId', ['worldId', 'conversationId'])
|
22 |
+
.index('messageUuid', ['conversationId', 'messageUuid']),
|
23 |
+
|
24 |
+
...agentTables,
|
25 |
+
...aiTownTables,
|
26 |
+
...engineTables,
|
27 |
+
});
|
patches/convex/testing.ts
ADDED
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Id, TableNames } from './_generated/dataModel';
|
2 |
+
import { internal } from './_generated/api';
|
3 |
+
import {
|
4 |
+
DatabaseReader,
|
5 |
+
internalAction,
|
6 |
+
internalMutation,
|
7 |
+
mutation,
|
8 |
+
query,
|
9 |
+
} from './_generated/server';
|
10 |
+
import { v } from 'convex/values';
|
11 |
+
import schema from './schema';
|
12 |
+
import { DELETE_BATCH_SIZE } from './constants';
|
13 |
+
import { kickEngine, startEngine, stopEngine } from './aiTown/main';
|
14 |
+
import { insertInput } from './aiTown/insertInput';
|
15 |
+
import { fetchEmbedding, LLM_CONFIG } from './util/llm';
|
16 |
+
import { chatCompletion } from './util/llm';
|
17 |
+
import { startConversationMessage } from './agent/conversation';
|
18 |
+
import { GameId } from './aiTown/ids';
|
19 |
+
|
20 |
+
// Clear all of the tables except for the embeddings cache.
|
21 |
+
const excludedTables: Array<TableNames> = ['embeddingsCache'];
|
22 |
+
|
23 |
+
export const wipeAllTables = internalMutation({
|
24 |
+
handler: async (ctx) => {
|
25 |
+
for (const tableName of Object.keys(schema.tables)) {
|
26 |
+
if (excludedTables.includes(tableName as TableNames)) {
|
27 |
+
continue;
|
28 |
+
}
|
29 |
+
await ctx.scheduler.runAfter(0, internal.testing.deletePage, { tableName, cursor: null });
|
30 |
+
}
|
31 |
+
},
|
32 |
+
});
|
33 |
+
|
34 |
+
export const deletePage = internalMutation({
|
35 |
+
args: {
|
36 |
+
tableName: v.string(),
|
37 |
+
cursor: v.union(v.string(), v.null()),
|
38 |
+
},
|
39 |
+
handler: async (ctx, args) => {
|
40 |
+
const results = await ctx.db
|
41 |
+
.query(args.tableName as TableNames)
|
42 |
+
.paginate({ cursor: args.cursor, numItems: DELETE_BATCH_SIZE });
|
43 |
+
for (const row of results.page) {
|
44 |
+
await ctx.db.delete(row._id);
|
45 |
+
}
|
46 |
+
if (!results.isDone) {
|
47 |
+
await ctx.scheduler.runAfter(0, internal.testing.deletePage, {
|
48 |
+
tableName: args.tableName,
|
49 |
+
cursor: results.continueCursor,
|
50 |
+
});
|
51 |
+
}
|
52 |
+
},
|
53 |
+
});
|
54 |
+
|
55 |
+
export const kick = internalMutation({
|
56 |
+
handler: async (ctx) => {
|
57 |
+
const { worldStatus } = await getDefaultWorld(ctx.db);
|
58 |
+
await kickEngine(ctx, worldStatus.worldId);
|
59 |
+
},
|
60 |
+
});
|
61 |
+
|
62 |
+
export const stopAllowed = query({
|
63 |
+
handler: async () => {
|
64 |
+
return !process.env.STOP_NOT_ALLOWED;
|
65 |
+
},
|
66 |
+
});
|
67 |
+
|
68 |
+
export const stop = mutation({
|
69 |
+
handler: async (ctx) => {
|
70 |
+
if (process.env.STOP_NOT_ALLOWED) throw new Error('Stop not allowed');
|
71 |
+
const { worldStatus, engine } = await getDefaultWorld(ctx.db);
|
72 |
+
if (worldStatus.status === 'inactive' || worldStatus.status === 'stoppedByDeveloper') {
|
73 |
+
if (engine.running) {
|
74 |
+
throw new Error(`Engine ${engine._id} isn't stopped?`);
|
75 |
+
}
|
76 |
+
console.debug(`World ${worldStatus.worldId} is already inactive`);
|
77 |
+
return;
|
78 |
+
}
|
79 |
+
console.log(`Stopping engine ${engine._id}...`);
|
80 |
+
await ctx.db.patch(worldStatus._id, { status: 'stoppedByDeveloper' });
|
81 |
+
await stopEngine(ctx, worldStatus.worldId);
|
82 |
+
},
|
83 |
+
});
|
84 |
+
|
85 |
+
export const resume = mutation({
|
86 |
+
handler: async (ctx) => {
|
87 |
+
const { worldStatus, engine } = await getDefaultWorld(ctx.db);
|
88 |
+
if (worldStatus.status === 'running') {
|
89 |
+
if (!engine.running) {
|
90 |
+
throw new Error(`Engine ${engine._id} isn't running?`);
|
91 |
+
}
|
92 |
+
console.debug(`World ${worldStatus.worldId} is already running`);
|
93 |
+
return;
|
94 |
+
}
|
95 |
+
console.log(
|
96 |
+
`Resuming engine ${engine._id} for world ${worldStatus.worldId} (state: ${worldStatus.status})...`,
|
97 |
+
);
|
98 |
+
await ctx.db.patch(worldStatus._id, { status: 'running' });
|
99 |
+
await startEngine(ctx, worldStatus.worldId);
|
100 |
+
},
|
101 |
+
});
|
102 |
+
|
103 |
+
export const archive = internalMutation({
|
104 |
+
handler: async (ctx) => {
|
105 |
+
const { worldStatus, engine } = await getDefaultWorld(ctx.db);
|
106 |
+
if (engine.running) {
|
107 |
+
throw new Error(`Engine ${engine._id} is still running!`);
|
108 |
+
}
|
109 |
+
console.log(`Archiving world ${worldStatus.worldId}...`);
|
110 |
+
await ctx.db.patch(worldStatus._id, { isDefault: false });
|
111 |
+
},
|
112 |
+
});
|
113 |
+
|
114 |
+
async function getDefaultWorld(db: DatabaseReader) {
|
115 |
+
const worldStatus = await db
|
116 |
+
.query('worldStatus')
|
117 |
+
.filter((q) => q.eq(q.field('isDefault'), true))
|
118 |
+
.first();
|
119 |
+
if (!worldStatus) {
|
120 |
+
throw new Error('No default world found');
|
121 |
+
}
|
122 |
+
const engine = await db.get(worldStatus.engineId);
|
123 |
+
if (!engine) {
|
124 |
+
throw new Error(`Engine ${worldStatus.engineId} not found`);
|
125 |
+
}
|
126 |
+
return { worldStatus, engine };
|
127 |
+
}
|
128 |
+
|
129 |
+
export const debugCreatePlayers = internalMutation({
|
130 |
+
args: {
|
131 |
+
numPlayers: v.number(),
|
132 |
+
},
|
133 |
+
handler: async (ctx, args) => {
|
134 |
+
const { worldStatus } = await getDefaultWorld(ctx.db);
|
135 |
+
for (let i = 0; i < args.numPlayers; i++) {
|
136 |
+
const inputId = await insertInput(ctx, worldStatus.worldId, 'join', {
|
137 |
+
name: `Robot${i}`,
|
138 |
+
description: `This player is a robot.`,
|
139 |
+
character: `f${1 + (i % 8)}`,
|
140 |
+
});
|
141 |
+
}
|
142 |
+
},
|
143 |
+
});
|
144 |
+
|
145 |
+
export const randomPositions = internalMutation({
|
146 |
+
handler: async (ctx) => {
|
147 |
+
const { worldStatus } = await getDefaultWorld(ctx.db);
|
148 |
+
const map = await ctx.db
|
149 |
+
.query('maps')
|
150 |
+
.withIndex('worldId', (q) => q.eq('worldId', worldStatus.worldId))
|
151 |
+
.unique();
|
152 |
+
if (!map) {
|
153 |
+
throw new Error(`No map for world ${worldStatus.worldId}`);
|
154 |
+
}
|
155 |
+
const world = await ctx.db.get(worldStatus.worldId);
|
156 |
+
if (!world) {
|
157 |
+
throw new Error(`No world for world ${worldStatus.worldId}`);
|
158 |
+
}
|
159 |
+
for (const player of world.players) {
|
160 |
+
await insertInput(ctx, world._id, 'moveTo', {
|
161 |
+
playerId: player.id,
|
162 |
+
destination: {
|
163 |
+
x: 1 + Math.floor(Math.random() * (map.width - 2)),
|
164 |
+
y: 1 + Math.floor(Math.random() * (map.height - 2)),
|
165 |
+
},
|
166 |
+
});
|
167 |
+
}
|
168 |
+
},
|
169 |
+
});
|
170 |
+
|
171 |
+
export const testEmbedding = internalAction({
|
172 |
+
args: { input: v.string() },
|
173 |
+
handler: async (_ctx, args) => {
|
174 |
+
return await fetchEmbedding(args.input);
|
175 |
+
},
|
176 |
+
});
|
177 |
+
|
178 |
+
export const testCompletion = internalAction({
|
179 |
+
args: {},
|
180 |
+
handler: async (ctx, args) => {
|
181 |
+
return await chatCompletion({
|
182 |
+
messages: [
|
183 |
+
{ content: 'You are helpful', role: 'system' },
|
184 |
+
{ content: 'Where is pizza?', role: 'user' },
|
185 |
+
],
|
186 |
+
});
|
187 |
+
},
|
188 |
+
});
|
189 |
+
|
190 |
+
export const testConvo = internalAction({
|
191 |
+
args: {},
|
192 |
+
handler: async (ctx, args) => {
|
193 |
+
const a: any = (await startConversationMessage(
|
194 |
+
ctx,
|
195 |
+
'm1707m46wmefpejw1k50rqz7856qw3ew' as Id<'worlds'>,
|
196 |
+
'c:115' as GameId<'conversations'>,
|
197 |
+
'p:0' as GameId<'players'>,
|
198 |
+
'p:6' as GameId<'players'>,
|
199 |
+
)) as any;
|
200 |
+
return await a.readAll();
|
201 |
+
},
|
202 |
+
});
|
patches/convex/util/FastIntegerCompression.ts
ADDED
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* FastIntegerCompression.js : a fast integer compression library in JavaScript.
|
3 |
+
* From https://github.com/lemire/FastIntegerCompression.js/
|
4 |
+
* (c) the authors
|
5 |
+
* Licensed under the Apache License, Version 2.0.
|
6 |
+
*
|
7 |
+
*FastIntegerCompression
|
8 |
+
* Simple usage :
|
9 |
+
* // var FastIntegerCompression = require("fastintcompression");// if you use node
|
10 |
+
* var array = [10,100000,65999,10,10,0,1,1,2000];
|
11 |
+
* var buf = FastIntegerCompression.compress(array);
|
12 |
+
* var back = FastIntegerCompression.uncompress(buf); // gets back [10,100000,65999,10,10,0,1,1,2000]
|
13 |
+
*
|
14 |
+
*
|
15 |
+
* You can install the library under node with the command line
|
16 |
+
* npm install fastintcompression
|
17 |
+
*/
|
18 |
+
|
19 |
+
function bytelog(val: number) {
|
20 |
+
if (val < 1 << 7) {
|
21 |
+
return 1;
|
22 |
+
} else if (val < 1 << 14) {
|
23 |
+
return 2;
|
24 |
+
} else if (val < 1 << 21) {
|
25 |
+
return 3;
|
26 |
+
} else if (val < 1 << 28) {
|
27 |
+
return 4;
|
28 |
+
}
|
29 |
+
return 5;
|
30 |
+
}
|
31 |
+
|
32 |
+
function zigzag_encode(val: number) {
|
33 |
+
return (val + val) ^ (val >> 31);
|
34 |
+
}
|
35 |
+
|
36 |
+
function zigzag_decode(val: number) {
|
37 |
+
return (val >> 1) ^ -(val & 1);
|
38 |
+
}
|
39 |
+
|
40 |
+
// Compute how many bytes an array of integers would use once compressed.
|
41 |
+
// The input is expected to be an array of non-negative integers.
|
42 |
+
export function computeCompressedSizeInBytes(input: number[]) {
|
43 |
+
var c = input.length;
|
44 |
+
var answer = 0;
|
45 |
+
for (var i = 0; i < c; i++) {
|
46 |
+
answer += bytelog(input[i]);
|
47 |
+
}
|
48 |
+
return answer;
|
49 |
+
}
|
50 |
+
|
51 |
+
// Compute how many bytes an array of integers would use once compressed.
|
52 |
+
// The input is expected to be an array of integers, some of them can be negative.
|
53 |
+
export function computeCompressedSizeInBytesSigned(input: number[]) {
|
54 |
+
var c = input.length;
|
55 |
+
var answer = 0;
|
56 |
+
for (var i = 0; i < c; i++) {
|
57 |
+
answer += bytelog(zigzag_encode(input[i]));
|
58 |
+
}
|
59 |
+
return answer;
|
60 |
+
}
|
61 |
+
|
62 |
+
// Compress an array of integers, return a compressed buffer (as an ArrayBuffer).
|
63 |
+
// It is expected that the integers are non-negative: the caller is responsible
|
64 |
+
// for making this check. Floating-point numbers are not supported.
|
65 |
+
export function compress(input: number[]) {
|
66 |
+
var c = input.length;
|
67 |
+
var buf = new ArrayBuffer(computeCompressedSizeInBytes(input));
|
68 |
+
var view = new Int8Array(buf);
|
69 |
+
var pos = 0;
|
70 |
+
for (var i = 0; i < c; i++) {
|
71 |
+
var val = input[i];
|
72 |
+
if (val < 1 << 7) {
|
73 |
+
view[pos++] = val;
|
74 |
+
} else if (val < 1 << 14) {
|
75 |
+
view[pos++] = (val & 0x7f) | 0x80;
|
76 |
+
view[pos++] = val >>> 7;
|
77 |
+
} else if (val < 1 << 21) {
|
78 |
+
view[pos++] = (val & 0x7f) | 0x80;
|
79 |
+
view[pos++] = ((val >>> 7) & 0x7f) | 0x80;
|
80 |
+
view[pos++] = val >>> 14;
|
81 |
+
} else if (val < 1 << 28) {
|
82 |
+
view[pos++] = (val & 0x7f) | 0x80;
|
83 |
+
view[pos++] = ((val >>> 7) & 0x7f) | 0x80;
|
84 |
+
view[pos++] = ((val >>> 14) & 0x7f) | 0x80;
|
85 |
+
view[pos++] = val >>> 21;
|
86 |
+
} else {
|
87 |
+
view[pos++] = (val & 0x7f) | 0x80;
|
88 |
+
view[pos++] = ((val >>> 7) & 0x7f) | 0x80;
|
89 |
+
view[pos++] = ((val >>> 14) & 0x7f) | 0x80;
|
90 |
+
view[pos++] = ((val >>> 21) & 0x7f) | 0x80;
|
91 |
+
view[pos++] = val >>> 28;
|
92 |
+
}
|
93 |
+
}
|
94 |
+
return buf;
|
95 |
+
}
|
96 |
+
|
97 |
+
// From a compressed array of integers stored ArrayBuffer,
|
98 |
+
// compute the number of compressed integers by scanning the input.
|
99 |
+
export function computeHowManyIntegers(input: ArrayBuffer) {
|
100 |
+
var view = new Uint8Array(input);
|
101 |
+
var c = view.length;
|
102 |
+
var count = 0;
|
103 |
+
for (var i = 0; i < c; i++) {
|
104 |
+
count += view[i] >>> 7;
|
105 |
+
}
|
106 |
+
return c - count;
|
107 |
+
}
|
108 |
+
// Uncompress an array of integer from an ArrayBuffer, return the array.
|
109 |
+
// It is assumed that they were compressed using the compress function, the caller
|
110 |
+
// is responsible for ensuring that it is the case.
|
111 |
+
export function uncompress(input: ArrayBuffer) {
|
112 |
+
var array = []; // The size of the output is not yet known.
|
113 |
+
var inbyte = new Int8Array(input);
|
114 |
+
var end = inbyte.length;
|
115 |
+
var pos = 0;
|
116 |
+
while (end > pos) {
|
117 |
+
var c = inbyte[pos++];
|
118 |
+
var v = c & 0x7f;
|
119 |
+
if (c >= 0) {
|
120 |
+
array.push(v);
|
121 |
+
continue;
|
122 |
+
}
|
123 |
+
c = inbyte[pos++];
|
124 |
+
v |= (c & 0x7f) << 7;
|
125 |
+
if (c >= 0) {
|
126 |
+
array.push(v);
|
127 |
+
continue;
|
128 |
+
}
|
129 |
+
c = inbyte[pos++];
|
130 |
+
v |= (c & 0x7f) << 14;
|
131 |
+
if (c >= 0) {
|
132 |
+
array.push(v);
|
133 |
+
continue;
|
134 |
+
}
|
135 |
+
c = inbyte[pos++];
|
136 |
+
v |= (c & 0x7f) << 21;
|
137 |
+
if (c >= 0) {
|
138 |
+
array.push(v);
|
139 |
+
continue;
|
140 |
+
}
|
141 |
+
c = inbyte[pos++];
|
142 |
+
v |= c << 28;
|
143 |
+
v >>>= 0; // make positive
|
144 |
+
array.push(v);
|
145 |
+
}
|
146 |
+
return array;
|
147 |
+
}
|
148 |
+
|
149 |
+
// Compress an array of integers, return a compressed buffer (as an ArrayBuffer).
|
150 |
+
// The integers can be signed (negative), but floating-point values are not supported.
|
151 |
+
export function compressSigned(input: number[]) {
|
152 |
+
var c = input.length;
|
153 |
+
var buf = new ArrayBuffer(computeCompressedSizeInBytesSigned(input));
|
154 |
+
var view = new Int8Array(buf);
|
155 |
+
var pos = 0;
|
156 |
+
for (var i = 0; i < c; i++) {
|
157 |
+
var val = zigzag_encode(input[i]);
|
158 |
+
if (val < 1 << 7) {
|
159 |
+
view[pos++] = val;
|
160 |
+
} else if (val < 1 << 14) {
|
161 |
+
view[pos++] = (val & 0x7f) | 0x80;
|
162 |
+
view[pos++] = val >>> 7;
|
163 |
+
} else if (val < 1 << 21) {
|
164 |
+
view[pos++] = (val & 0x7f) | 0x80;
|
165 |
+
view[pos++] = ((val >>> 7) & 0x7f) | 0x80;
|
166 |
+
view[pos++] = val >>> 14;
|
167 |
+
} else if (val < 1 << 28) {
|
168 |
+
view[pos++] = (val & 0x7f) | 0x80;
|
169 |
+
view[pos++] = ((val >>> 7) & 0x7f) | 0x80;
|
170 |
+
view[pos++] = ((val >>> 14) & 0x7f) | 0x80;
|
171 |
+
view[pos++] = val >>> 21;
|
172 |
+
} else {
|
173 |
+
view[pos++] = (val & 0x7f) | 0x80;
|
174 |
+
view[pos++] = ((val >>> 7) & 0x7f) | 0x80;
|
175 |
+
view[pos++] = ((val >>> 14) & 0x7f) | 0x80;
|
176 |
+
view[pos++] = ((val >>> 21) & 0x7f) | 0x80;
|
177 |
+
view[pos++] = val >>> 28;
|
178 |
+
}
|
179 |
+
}
|
180 |
+
return buf;
|
181 |
+
}
|
182 |
+
|
183 |
+
// Uncompress an array of integer from an ArrayBuffer, return the array.
|
184 |
+
// It is assumed that they were compressed using the compressSigned function, the caller
|
185 |
+
// is responsible for ensuring that it is the case.
|
186 |
+
export function uncompressSigned(input: ArrayBuffer) {
|
187 |
+
var array = []; // The size of the output is not yet known.
|
188 |
+
var inbyte = new Int8Array(input);
|
189 |
+
var end = inbyte.length;
|
190 |
+
var pos = 0;
|
191 |
+
while (end > pos) {
|
192 |
+
var c = inbyte[pos++];
|
193 |
+
var v = c & 0x7f;
|
194 |
+
if (c >= 0) {
|
195 |
+
array.push(zigzag_decode(v));
|
196 |
+
continue;
|
197 |
+
}
|
198 |
+
c = inbyte[pos++];
|
199 |
+
v |= (c & 0x7f) << 7;
|
200 |
+
if (c >= 0) {
|
201 |
+
array.push(zigzag_decode(v));
|
202 |
+
continue;
|
203 |
+
}
|
204 |
+
c = inbyte[pos++];
|
205 |
+
v |= (c & 0x7f) << 14;
|
206 |
+
if (c >= 0) {
|
207 |
+
array.push(zigzag_decode(v));
|
208 |
+
continue;
|
209 |
+
}
|
210 |
+
c = inbyte[pos++];
|
211 |
+
v |= (c & 0x7f) << 21;
|
212 |
+
if (c >= 0) {
|
213 |
+
array.push(zigzag_decode(v));
|
214 |
+
continue;
|
215 |
+
}
|
216 |
+
c = inbyte[pos++];
|
217 |
+
v |= c << 28;
|
218 |
+
array.push(zigzag_decode(v));
|
219 |
+
}
|
220 |
+
return array;
|
221 |
+
}
|
patches/convex/util/assertNever.ts
ADDED
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// From https://www.typescriptlang.org/docs/handbook/unions-and-intersections.html#union-exhaustiveness-checking
|
2 |
+
export function assertNever(x: never): never {
|
3 |
+
throw new Error(`Unexpected object: ${JSON.stringify(x)}`);
|
4 |
+
}
|
patches/convex/util/asyncMap.test.ts
ADDED
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { asyncMap } from './asyncMap';
|
2 |
+
|
3 |
+
describe('asyncMap', () => {
|
4 |
+
it('should map over a list asynchronously', async () => {
|
5 |
+
const list = [1, 2, 3];
|
6 |
+
const result = await asyncMap(list, async (item: number) => item * 2);
|
7 |
+
expect(result).toEqual([2, 4, 6]);
|
8 |
+
});
|
9 |
+
|
10 |
+
it('should handle empty list input', async () => {
|
11 |
+
const list: number[] = [];
|
12 |
+
const result = await asyncMap(list, async (item: number) => item * 2);
|
13 |
+
expect(result).toEqual([]);
|
14 |
+
});
|
15 |
+
});
|