Jofthomas HF staff commited on
Commit
8cbe088
1 Parent(s): ddc0a44

push bulk 1

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. Dockerfile +10 -5
  2. README.md +2 -1
  3. README.md.yml +8 -0
  4. map.png +0 -0
  5. patches/PixiStaticMap.tsx +0 -128
  6. patches/assets/GrayCat.png +0 -0
  7. patches/assets/OrangeCat.png +0 -0
  8. patches/assets/hf.svg +18 -0
  9. patches/assets/map.png +0 -0
  10. patches/assets/map_night.png +0 -0
  11. patches/characters.ts +26 -14
  12. patches/convex/agent/conversation.ts +345 -0
  13. patches/convex/agent/embeddingsCache.ts +110 -0
  14. patches/convex/agent/memory.ts +450 -0
  15. patches/convex/agent/schema.ts +53 -0
  16. patches/convex/aiTown/agent.ts +368 -0
  17. patches/convex/aiTown/agentDescription.ts +27 -0
  18. patches/convex/aiTown/agentInputs.ts +155 -0
  19. patches/convex/aiTown/agentOperations.ts +182 -0
  20. patches/convex/aiTown/conversation.ts +395 -0
  21. patches/convex/aiTown/conversationMembership.ts +38 -0
  22. patches/convex/aiTown/dayNightCycle.ts +71 -0
  23. patches/convex/aiTown/game.ts +374 -0
  24. patches/convex/aiTown/ids.ts +32 -0
  25. patches/convex/aiTown/inputHandler.ts +9 -0
  26. patches/convex/aiTown/inputs.ts +25 -0
  27. patches/convex/aiTown/insertInput.ts +20 -0
  28. patches/convex/aiTown/location.ts +32 -0
  29. patches/convex/aiTown/main.ts +154 -0
  30. patches/convex/aiTown/movement.ts +189 -0
  31. patches/convex/aiTown/player.ts +310 -0
  32. patches/convex/aiTown/playerDescription.ts +35 -0
  33. patches/convex/aiTown/schema.ts +79 -0
  34. patches/convex/aiTown/world.ts +70 -0
  35. patches/convex/aiTown/worldMap.ts +94 -0
  36. patches/{constants.ts → convex/constants.ts} +81 -78
  37. patches/convex/crons.ts +89 -0
  38. patches/convex/engine/abstractGame.ts +199 -0
  39. patches/convex/engine/historicalObject.test.ts +47 -0
  40. patches/convex/engine/historicalObject.ts +355 -0
  41. patches/convex/engine/schema.ts +56 -0
  42. patches/convex/http.ts +10 -0
  43. patches/convex/init.ts +125 -0
  44. patches/convex/messages.ts +53 -0
  45. patches/{music.ts → convex/music.ts} +135 -135
  46. patches/convex/schema.ts +27 -0
  47. patches/convex/testing.ts +202 -0
  48. patches/convex/util/FastIntegerCompression.ts +221 -0
  49. patches/convex/util/assertNever.ts +4 -0
  50. 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/llm.ts ./convex/util/
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: 'f1',
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: 'f4',
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: 'f6',
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: 'f3',
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.1,
89
  },
90
  {
91
  name: 'f2',
92
  textureUrl: '/assets/32x32folk.png',
93
  spritesheetData: f2SpritesheetData,
94
- speed: 0.1,
95
  },
96
  {
97
  name: 'f3',
98
  textureUrl: '/assets/32x32folk.png',
99
  spritesheetData: f3SpritesheetData,
100
- speed: 0.1,
101
  },
102
  {
103
  name: 'f4',
104
  textureUrl: '/assets/32x32folk.png',
105
  spritesheetData: f4SpritesheetData,
106
- speed: 0.1,
107
  },
108
  {
109
  name: 'f5',
110
  textureUrl: '/assets/32x32folk.png',
111
  spritesheetData: f5SpritesheetData,
112
- speed: 0.1,
113
  },
114
  {
115
  name: 'f6',
116
  textureUrl: '/assets/32x32folk.png',
117
  spritesheetData: f6SpritesheetData,
118
- speed: 0.1,
119
  },
120
  {
121
  name: 'f7',
122
  textureUrl: '/assets/32x32folk.png',
123
  spritesheetData: f7SpritesheetData,
124
- speed: 0.1,
125
  },
126
  {
127
  name: 'f8',
128
  textureUrl: '/assets/32x32folk.png',
129
  spritesheetData: f8SpritesheetData,
130
- speed: 0.1,
 
 
 
 
 
 
 
 
 
 
 
 
131
  },
132
  ];
133
 
134
  // Characters move at 0.75 tiles per second.
135
- export const movementSpeed = 0.75;
 
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
- // Bound the number of pathfinding searches we do per game step.
76
- export const MAX_PATHFINDS_PER_STEP = 16;
77
-
78
- export const DEFAULT_NAME = 'Me';
 
 
 
 
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
+ });