Spaces:
Sleeping
Sleeping
bulk2
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- patches/convex/_generated/api.d.ts +125 -0
- patches/convex/_generated/api.js +23 -0
- patches/convex/_generated/dataModel.d.ts +61 -0
- patches/convex/_generated/server.d.ts +143 -0
- patches/convex/_generated/server.js +90 -0
- patches/convex/agent/conversation.ts +345 -346
- patches/convex/agent/embeddingsCache.ts +110 -110
- patches/convex/agent/memory.ts +450 -450
- patches/convex/agent/schema.ts +53 -53
- patches/convex/aiTown/agent.ts +384 -368
- patches/convex/aiTown/agentDescription.ts +27 -27
- patches/convex/aiTown/agentInputs.ts +158 -155
- patches/convex/aiTown/agentOperations.ts +180 -182
- patches/convex/aiTown/conversation.ts +395 -395
- patches/convex/aiTown/conversationMembership.ts +38 -38
- patches/convex/aiTown/dayNightCycle.ts +0 -71
- patches/convex/aiTown/game.ts +374 -374
- patches/convex/aiTown/gameCycle.ts +97 -0
- patches/convex/aiTown/ids.ts +32 -32
- patches/convex/aiTown/inputHandler.ts +9 -9
- patches/convex/aiTown/inputs.ts +25 -25
- patches/convex/aiTown/insertInput.ts +20 -20
- patches/convex/aiTown/location.ts +32 -32
- patches/convex/aiTown/main.ts +154 -154
- patches/convex/aiTown/movement.ts +189 -189
- patches/convex/aiTown/player.ts +342 -314
- patches/convex/aiTown/playerDescription.ts +42 -39
- patches/convex/aiTown/schema.ts +79 -79
- patches/convex/aiTown/voting.ts +58 -0
- patches/convex/aiTown/world.ts +75 -70
- patches/convex/aiTown/worldMap.ts +91 -94
- patches/convex/constants.ts +90 -81
- patches/convex/crons.ts +89 -89
- patches/convex/engine/abstractGame.ts +199 -200
- patches/convex/engine/historicalObject.test.ts +47 -47
- patches/convex/engine/historicalObject.ts +355 -355
- patches/convex/engine/schema.ts +56 -56
- patches/convex/http.ts +10 -10
- patches/convex/init.ts +128 -125
- patches/convex/messages.ts +53 -53
- patches/convex/music.ts +135 -135
- patches/convex/schema.ts +27 -27
- patches/convex/testing.ts +203 -202
- patches/convex/util/FastIntegerCompression.ts +221 -221
- patches/convex/util/assertNever.ts +4 -4
- patches/convex/util/asyncMap.test.ts +14 -14
- patches/convex/util/asyncMap.ts +20 -20
- patches/convex/util/compression.test.ts +90 -90
- patches/convex/util/compression.ts +71 -71
- patches/convex/util/geometry.test.ts +298 -298
patches/convex/_generated/api.d.ts
ADDED
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/* eslint-disable */
|
2 |
+
/**
|
3 |
+
* Generated `api` utility.
|
4 |
+
*
|
5 |
+
* THIS CODE IS AUTOMATICALLY GENERATED.
|
6 |
+
*
|
7 |
+
* Generated by [email protected].
|
8 |
+
* To regenerate, run `npx convex dev`.
|
9 |
+
* @module
|
10 |
+
*/
|
11 |
+
|
12 |
+
import type {
|
13 |
+
ApiFromModules,
|
14 |
+
FilterApi,
|
15 |
+
FunctionReference,
|
16 |
+
} from "convex/server";
|
17 |
+
import type * as agent_conversation from "../agent/conversation.js";
|
18 |
+
import type * as agent_embeddingsCache from "../agent/embeddingsCache.js";
|
19 |
+
import type * as agent_memory from "../agent/memory.js";
|
20 |
+
import type * as aiTown_agent from "../aiTown/agent.js";
|
21 |
+
import type * as aiTown_agentDescription from "../aiTown/agentDescription.js";
|
22 |
+
import type * as aiTown_agentInputs from "../aiTown/agentInputs.js";
|
23 |
+
import type * as aiTown_agentOperations from "../aiTown/agentOperations.js";
|
24 |
+
import type * as aiTown_conversation from "../aiTown/conversation.js";
|
25 |
+
import type * as aiTown_conversationMembership from "../aiTown/conversationMembership.js";
|
26 |
+
import type * as aiTown_game from "../aiTown/game.js";
|
27 |
+
import type * as aiTown_gameCycle from "../aiTown/gameCycle.js";
|
28 |
+
import type * as aiTown_ids from "../aiTown/ids.js";
|
29 |
+
import type * as aiTown_inputHandler from "../aiTown/inputHandler.js";
|
30 |
+
import type * as aiTown_inputs from "../aiTown/inputs.js";
|
31 |
+
import type * as aiTown_insertInput from "../aiTown/insertInput.js";
|
32 |
+
import type * as aiTown_location from "../aiTown/location.js";
|
33 |
+
import type * as aiTown_main from "../aiTown/main.js";
|
34 |
+
import type * as aiTown_movement from "../aiTown/movement.js";
|
35 |
+
import type * as aiTown_player from "../aiTown/player.js";
|
36 |
+
import type * as aiTown_playerDescription from "../aiTown/playerDescription.js";
|
37 |
+
import type * as aiTown_voting from "../aiTown/voting.js";
|
38 |
+
import type * as aiTown_world from "../aiTown/world.js";
|
39 |
+
import type * as aiTown_worldMap from "../aiTown/worldMap.js";
|
40 |
+
import type * as constants from "../constants.js";
|
41 |
+
import type * as crons from "../crons.js";
|
42 |
+
import type * as engine_abstractGame from "../engine/abstractGame.js";
|
43 |
+
import type * as engine_historicalObject from "../engine/historicalObject.js";
|
44 |
+
import type * as http from "../http.js";
|
45 |
+
import type * as init from "../init.js";
|
46 |
+
import type * as messages from "../messages.js";
|
47 |
+
import type * as music from "../music.js";
|
48 |
+
import type * as testing from "../testing.js";
|
49 |
+
import type * as util_FastIntegerCompression from "../util/FastIntegerCompression.js";
|
50 |
+
import type * as util_assertNever from "../util/assertNever.js";
|
51 |
+
import type * as util_asyncMap from "../util/asyncMap.js";
|
52 |
+
import type * as util_compression from "../util/compression.js";
|
53 |
+
import type * as util_geometry from "../util/geometry.js";
|
54 |
+
import type * as util_isSimpleObject from "../util/isSimpleObject.js";
|
55 |
+
import type * as util_llm from "../util/llm.js";
|
56 |
+
import type * as util_minheap from "../util/minheap.js";
|
57 |
+
import type * as util_object from "../util/object.js";
|
58 |
+
import type * as util_sleep from "../util/sleep.js";
|
59 |
+
import type * as util_types from "../util/types.js";
|
60 |
+
import type * as util_xxhash from "../util/xxhash.js";
|
61 |
+
import type * as world from "../world.js";
|
62 |
+
|
63 |
+
/**
|
64 |
+
* A utility for referencing Convex functions in your app's API.
|
65 |
+
*
|
66 |
+
* Usage:
|
67 |
+
* ```js
|
68 |
+
* const myFunctionReference = api.myModule.myFunction;
|
69 |
+
* ```
|
70 |
+
*/
|
71 |
+
declare const fullApi: ApiFromModules<{
|
72 |
+
"agent/conversation": typeof agent_conversation;
|
73 |
+
"agent/embeddingsCache": typeof agent_embeddingsCache;
|
74 |
+
"agent/memory": typeof agent_memory;
|
75 |
+
"aiTown/agent": typeof aiTown_agent;
|
76 |
+
"aiTown/agentDescription": typeof aiTown_agentDescription;
|
77 |
+
"aiTown/agentInputs": typeof aiTown_agentInputs;
|
78 |
+
"aiTown/agentOperations": typeof aiTown_agentOperations;
|
79 |
+
"aiTown/conversation": typeof aiTown_conversation;
|
80 |
+
"aiTown/conversationMembership": typeof aiTown_conversationMembership;
|
81 |
+
"aiTown/game": typeof aiTown_game;
|
82 |
+
"aiTown/gameCycle": typeof aiTown_gameCycle;
|
83 |
+
"aiTown/ids": typeof aiTown_ids;
|
84 |
+
"aiTown/inputHandler": typeof aiTown_inputHandler;
|
85 |
+
"aiTown/inputs": typeof aiTown_inputs;
|
86 |
+
"aiTown/insertInput": typeof aiTown_insertInput;
|
87 |
+
"aiTown/location": typeof aiTown_location;
|
88 |
+
"aiTown/main": typeof aiTown_main;
|
89 |
+
"aiTown/movement": typeof aiTown_movement;
|
90 |
+
"aiTown/player": typeof aiTown_player;
|
91 |
+
"aiTown/playerDescription": typeof aiTown_playerDescription;
|
92 |
+
"aiTown/voting": typeof aiTown_voting;
|
93 |
+
"aiTown/world": typeof aiTown_world;
|
94 |
+
"aiTown/worldMap": typeof aiTown_worldMap;
|
95 |
+
constants: typeof constants;
|
96 |
+
crons: typeof crons;
|
97 |
+
"engine/abstractGame": typeof engine_abstractGame;
|
98 |
+
"engine/historicalObject": typeof engine_historicalObject;
|
99 |
+
http: typeof http;
|
100 |
+
init: typeof init;
|
101 |
+
messages: typeof messages;
|
102 |
+
music: typeof music;
|
103 |
+
testing: typeof testing;
|
104 |
+
"util/FastIntegerCompression": typeof util_FastIntegerCompression;
|
105 |
+
"util/assertNever": typeof util_assertNever;
|
106 |
+
"util/asyncMap": typeof util_asyncMap;
|
107 |
+
"util/compression": typeof util_compression;
|
108 |
+
"util/geometry": typeof util_geometry;
|
109 |
+
"util/isSimpleObject": typeof util_isSimpleObject;
|
110 |
+
"util/llm": typeof util_llm;
|
111 |
+
"util/minheap": typeof util_minheap;
|
112 |
+
"util/object": typeof util_object;
|
113 |
+
"util/sleep": typeof util_sleep;
|
114 |
+
"util/types": typeof util_types;
|
115 |
+
"util/xxhash": typeof util_xxhash;
|
116 |
+
world: typeof world;
|
117 |
+
}>;
|
118 |
+
export declare const api: FilterApi<
|
119 |
+
typeof fullApi,
|
120 |
+
FunctionReference<any, "public">
|
121 |
+
>;
|
122 |
+
export declare const internal: FilterApi<
|
123 |
+
typeof fullApi,
|
124 |
+
FunctionReference<any, "internal">
|
125 |
+
>;
|
patches/convex/_generated/api.js
ADDED
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/* eslint-disable */
|
2 |
+
/**
|
3 |
+
* Generated `api` utility.
|
4 |
+
*
|
5 |
+
* THIS CODE IS AUTOMATICALLY GENERATED.
|
6 |
+
*
|
7 |
+
* Generated by [email protected].
|
8 |
+
* To regenerate, run `npx convex dev`.
|
9 |
+
* @module
|
10 |
+
*/
|
11 |
+
|
12 |
+
import { anyApi } from "convex/server";
|
13 |
+
|
14 |
+
/**
|
15 |
+
* A utility for referencing Convex functions in your app's API.
|
16 |
+
*
|
17 |
+
* Usage:
|
18 |
+
* ```js
|
19 |
+
* const myFunctionReference = api.myModule.myFunction;
|
20 |
+
* ```
|
21 |
+
*/
|
22 |
+
export const api = anyApi;
|
23 |
+
export const internal = anyApi;
|
patches/convex/_generated/dataModel.d.ts
ADDED
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/* eslint-disable */
|
2 |
+
/**
|
3 |
+
* Generated data model types.
|
4 |
+
*
|
5 |
+
* THIS CODE IS AUTOMATICALLY GENERATED.
|
6 |
+
*
|
7 |
+
* Generated by [email protected].
|
8 |
+
* To regenerate, run `npx convex dev`.
|
9 |
+
* @module
|
10 |
+
*/
|
11 |
+
|
12 |
+
import type {
|
13 |
+
DataModelFromSchemaDefinition,
|
14 |
+
DocumentByName,
|
15 |
+
TableNamesInDataModel,
|
16 |
+
SystemTableNames,
|
17 |
+
} from "convex/server";
|
18 |
+
import type { GenericId } from "convex/values";
|
19 |
+
import schema from "../schema.js";
|
20 |
+
|
21 |
+
/**
|
22 |
+
* The names of all of your Convex tables.
|
23 |
+
*/
|
24 |
+
export type TableNames = TableNamesInDataModel<DataModel>;
|
25 |
+
|
26 |
+
/**
|
27 |
+
* The type of a document stored in Convex.
|
28 |
+
*
|
29 |
+
* @typeParam TableName - A string literal type of the table name (like "users").
|
30 |
+
*/
|
31 |
+
export type Doc<TableName extends TableNames> = DocumentByName<
|
32 |
+
DataModel,
|
33 |
+
TableName
|
34 |
+
>;
|
35 |
+
|
36 |
+
/**
|
37 |
+
* An identifier for a document in Convex.
|
38 |
+
*
|
39 |
+
* Convex documents are uniquely identified by their `Id`, which is accessible
|
40 |
+
* on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
|
41 |
+
*
|
42 |
+
* Documents can be loaded using `db.get(id)` in query and mutation functions.
|
43 |
+
*
|
44 |
+
* IDs are just strings at runtime, but this type can be used to distinguish them from other
|
45 |
+
* strings when type checking.
|
46 |
+
*
|
47 |
+
* @typeParam TableName - A string literal type of the table name (like "users").
|
48 |
+
*/
|
49 |
+
export type Id<TableName extends TableNames | SystemTableNames> =
|
50 |
+
GenericId<TableName>;
|
51 |
+
|
52 |
+
/**
|
53 |
+
* A type describing your Convex data model.
|
54 |
+
*
|
55 |
+
* This type includes information about what tables you have, the type of
|
56 |
+
* documents stored in those tables, and the indexes defined on them.
|
57 |
+
*
|
58 |
+
* This type is used to parameterize methods like `queryGeneric` and
|
59 |
+
* `mutationGeneric` to make them type-safe.
|
60 |
+
*/
|
61 |
+
export type DataModel = DataModelFromSchemaDefinition<typeof schema>;
|
patches/convex/_generated/server.d.ts
ADDED
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/* eslint-disable */
|
2 |
+
/**
|
3 |
+
* Generated utilities for implementing server-side Convex query and mutation functions.
|
4 |
+
*
|
5 |
+
* THIS CODE IS AUTOMATICALLY GENERATED.
|
6 |
+
*
|
7 |
+
* Generated by [email protected].
|
8 |
+
* To regenerate, run `npx convex dev`.
|
9 |
+
* @module
|
10 |
+
*/
|
11 |
+
|
12 |
+
import {
|
13 |
+
ActionBuilder,
|
14 |
+
HttpActionBuilder,
|
15 |
+
MutationBuilder,
|
16 |
+
QueryBuilder,
|
17 |
+
GenericActionCtx,
|
18 |
+
GenericMutationCtx,
|
19 |
+
GenericQueryCtx,
|
20 |
+
GenericDatabaseReader,
|
21 |
+
GenericDatabaseWriter,
|
22 |
+
} from "convex/server";
|
23 |
+
import type { DataModel } from "./dataModel.js";
|
24 |
+
|
25 |
+
/**
|
26 |
+
* Define a query in this Convex app's public API.
|
27 |
+
*
|
28 |
+
* This function will be allowed to read your Convex database and will be accessible from the client.
|
29 |
+
*
|
30 |
+
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
31 |
+
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
32 |
+
*/
|
33 |
+
export declare const query: QueryBuilder<DataModel, "public">;
|
34 |
+
|
35 |
+
/**
|
36 |
+
* Define a query that is only accessible from other Convex functions (but not from the client).
|
37 |
+
*
|
38 |
+
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
|
39 |
+
*
|
40 |
+
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
41 |
+
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
42 |
+
*/
|
43 |
+
export declare const internalQuery: QueryBuilder<DataModel, "internal">;
|
44 |
+
|
45 |
+
/**
|
46 |
+
* Define a mutation in this Convex app's public API.
|
47 |
+
*
|
48 |
+
* This function will be allowed to modify your Convex database and will be accessible from the client.
|
49 |
+
*
|
50 |
+
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
51 |
+
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
52 |
+
*/
|
53 |
+
export declare const mutation: MutationBuilder<DataModel, "public">;
|
54 |
+
|
55 |
+
/**
|
56 |
+
* Define a mutation that is only accessible from other Convex functions (but not from the client).
|
57 |
+
*
|
58 |
+
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
|
59 |
+
*
|
60 |
+
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
61 |
+
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
62 |
+
*/
|
63 |
+
export declare const internalMutation: MutationBuilder<DataModel, "internal">;
|
64 |
+
|
65 |
+
/**
|
66 |
+
* Define an action in this Convex app's public API.
|
67 |
+
*
|
68 |
+
* An action is a function which can execute any JavaScript code, including non-deterministic
|
69 |
+
* code and code with side-effects, like calling third-party services.
|
70 |
+
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
|
71 |
+
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
|
72 |
+
*
|
73 |
+
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
|
74 |
+
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
|
75 |
+
*/
|
76 |
+
export declare const action: ActionBuilder<DataModel, "public">;
|
77 |
+
|
78 |
+
/**
|
79 |
+
* Define an action that is only accessible from other Convex functions (but not from the client).
|
80 |
+
*
|
81 |
+
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
|
82 |
+
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
|
83 |
+
*/
|
84 |
+
export declare const internalAction: ActionBuilder<DataModel, "internal">;
|
85 |
+
|
86 |
+
/**
|
87 |
+
* Define an HTTP action.
|
88 |
+
*
|
89 |
+
* This function will be used to respond to HTTP requests received by a Convex
|
90 |
+
* deployment if the requests matches the path and method where this action
|
91 |
+
* is routed. Be sure to route your action in `convex/http.js`.
|
92 |
+
*
|
93 |
+
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
|
94 |
+
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
|
95 |
+
*/
|
96 |
+
export declare const httpAction: HttpActionBuilder;
|
97 |
+
|
98 |
+
/**
|
99 |
+
* A set of services for use within Convex query functions.
|
100 |
+
*
|
101 |
+
* The query context is passed as the first argument to any Convex query
|
102 |
+
* function run on the server.
|
103 |
+
*
|
104 |
+
* This differs from the {@link MutationCtx} because all of the services are
|
105 |
+
* read-only.
|
106 |
+
*/
|
107 |
+
export type QueryCtx = GenericQueryCtx<DataModel>;
|
108 |
+
|
109 |
+
/**
|
110 |
+
* A set of services for use within Convex mutation functions.
|
111 |
+
*
|
112 |
+
* The mutation context is passed as the first argument to any Convex mutation
|
113 |
+
* function run on the server.
|
114 |
+
*/
|
115 |
+
export type MutationCtx = GenericMutationCtx<DataModel>;
|
116 |
+
|
117 |
+
/**
|
118 |
+
* A set of services for use within Convex action functions.
|
119 |
+
*
|
120 |
+
* The action context is passed as the first argument to any Convex action
|
121 |
+
* function run on the server.
|
122 |
+
*/
|
123 |
+
export type ActionCtx = GenericActionCtx<DataModel>;
|
124 |
+
|
125 |
+
/**
|
126 |
+
* An interface to read from the database within Convex query functions.
|
127 |
+
*
|
128 |
+
* The two entry points are {@link DatabaseReader.get}, which fetches a single
|
129 |
+
* document by its {@link Id}, or {@link DatabaseReader.query}, which starts
|
130 |
+
* building a query.
|
131 |
+
*/
|
132 |
+
export type DatabaseReader = GenericDatabaseReader<DataModel>;
|
133 |
+
|
134 |
+
/**
|
135 |
+
* An interface to read from and write to the database within Convex mutation
|
136 |
+
* functions.
|
137 |
+
*
|
138 |
+
* Convex guarantees that all writes within a single mutation are
|
139 |
+
* executed atomically, so you never have to worry about partial writes leaving
|
140 |
+
* your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
|
141 |
+
* for the guarantees Convex provides your functions.
|
142 |
+
*/
|
143 |
+
export type DatabaseWriter = GenericDatabaseWriter<DataModel>;
|
patches/convex/_generated/server.js
ADDED
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/* eslint-disable */
|
2 |
+
/**
|
3 |
+
* Generated utilities for implementing server-side Convex query and mutation functions.
|
4 |
+
*
|
5 |
+
* THIS CODE IS AUTOMATICALLY GENERATED.
|
6 |
+
*
|
7 |
+
* Generated by [email protected].
|
8 |
+
* To regenerate, run `npx convex dev`.
|
9 |
+
* @module
|
10 |
+
*/
|
11 |
+
|
12 |
+
import {
|
13 |
+
actionGeneric,
|
14 |
+
httpActionGeneric,
|
15 |
+
queryGeneric,
|
16 |
+
mutationGeneric,
|
17 |
+
internalActionGeneric,
|
18 |
+
internalMutationGeneric,
|
19 |
+
internalQueryGeneric,
|
20 |
+
} from "convex/server";
|
21 |
+
|
22 |
+
/**
|
23 |
+
* Define a query in this Convex app's public API.
|
24 |
+
*
|
25 |
+
* This function will be allowed to read your Convex database and will be accessible from the client.
|
26 |
+
*
|
27 |
+
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
28 |
+
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
29 |
+
*/
|
30 |
+
export const query = queryGeneric;
|
31 |
+
|
32 |
+
/**
|
33 |
+
* Define a query that is only accessible from other Convex functions (but not from the client).
|
34 |
+
*
|
35 |
+
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
|
36 |
+
*
|
37 |
+
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
38 |
+
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
39 |
+
*/
|
40 |
+
export const internalQuery = internalQueryGeneric;
|
41 |
+
|
42 |
+
/**
|
43 |
+
* Define a mutation in this Convex app's public API.
|
44 |
+
*
|
45 |
+
* This function will be allowed to modify your Convex database and will be accessible from the client.
|
46 |
+
*
|
47 |
+
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
48 |
+
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
49 |
+
*/
|
50 |
+
export const mutation = mutationGeneric;
|
51 |
+
|
52 |
+
/**
|
53 |
+
* Define a mutation that is only accessible from other Convex functions (but not from the client).
|
54 |
+
*
|
55 |
+
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
|
56 |
+
*
|
57 |
+
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
58 |
+
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
59 |
+
*/
|
60 |
+
export const internalMutation = internalMutationGeneric;
|
61 |
+
|
62 |
+
/**
|
63 |
+
* Define an action in this Convex app's public API.
|
64 |
+
*
|
65 |
+
* An action is a function which can execute any JavaScript code, including non-deterministic
|
66 |
+
* code and code with side-effects, like calling third-party services.
|
67 |
+
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
|
68 |
+
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
|
69 |
+
*
|
70 |
+
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
|
71 |
+
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
|
72 |
+
*/
|
73 |
+
export const action = actionGeneric;
|
74 |
+
|
75 |
+
/**
|
76 |
+
* Define an action that is only accessible from other Convex functions (but not from the client).
|
77 |
+
*
|
78 |
+
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
|
79 |
+
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
|
80 |
+
*/
|
81 |
+
export const internalAction = internalActionGeneric;
|
82 |
+
|
83 |
+
/**
|
84 |
+
* Define a Convex HTTP action.
|
85 |
+
*
|
86 |
+
* @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object
|
87 |
+
* as its second.
|
88 |
+
* @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`.
|
89 |
+
*/
|
90 |
+
export const httpAction = httpActionGeneric;
|
patches/convex/agent/conversation.ts
CHANGED
@@ -1,346 +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 |
-
|
266 |
-
|
267 |
-
|
268 |
-
|
269 |
-
|
270 |
-
|
271 |
-
.
|
272 |
-
.
|
273 |
-
|
274 |
-
|
275 |
-
|
276 |
-
|
277 |
-
|
278 |
-
|
279 |
-
|
280 |
-
|
281 |
-
|
282 |
-
|
283 |
-
|
284 |
-
|
285 |
-
|
286 |
-
.
|
287 |
-
.
|
288 |
-
|
289 |
-
|
290 |
-
|
291 |
-
|
292 |
-
|
293 |
-
|
294 |
-
|
295 |
-
|
296 |
-
.
|
297 |
-
.
|
298 |
-
|
299 |
-
|
300 |
-
|
301 |
-
|
302 |
-
|
303 |
-
|
304 |
-
.
|
305 |
-
|
306 |
-
|
307 |
-
.eq('
|
308 |
-
.eq('
|
309 |
-
|
310 |
-
|
311 |
-
|
312 |
-
.
|
313 |
-
|
314 |
-
|
315 |
-
|
316 |
-
|
317 |
-
|
318 |
-
.
|
319 |
-
|
320 |
-
|
321 |
-
)
|
322 |
-
|
323 |
-
|
324 |
-
|
325 |
-
|
326 |
-
|
327 |
-
|
328 |
-
|
329 |
-
|
330 |
-
|
331 |
-
|
332 |
-
|
333 |
-
|
334 |
-
|
335 |
-
|
336 |
-
|
337 |
-
|
338 |
-
|
339 |
-
|
340 |
-
|
341 |
-
|
342 |
-
|
343 |
-
|
344 |
-
|
345 |
-
|
346 |
-
}
|
|
|
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
CHANGED
@@ -1,110 +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 |
-
});
|
|
|
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
CHANGED
@@ -1,450 +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 |
-
}
|
|
|
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
CHANGED
@@ -1,53 +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 |
-
};
|
|
|
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
CHANGED
@@ -1,368 +1,384 @@
|
|
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 |
-
|
260 |
-
|
261 |
-
|
262 |
-
|
263 |
-
|
264 |
-
|
265 |
-
|
266 |
-
|
267 |
-
|
268 |
-
|
269 |
-
}
|
270 |
-
|
271 |
-
|
272 |
-
|
273 |
-
|
274 |
-
|
275 |
-
|
276 |
-
|
277 |
-
|
278 |
-
|
279 |
-
|
280 |
-
|
281 |
-
|
282 |
-
|
283 |
-
|
284 |
-
}
|
285 |
-
|
286 |
-
|
287 |
-
|
288 |
-
|
289 |
-
|
290 |
-
|
291 |
-
|
292 |
-
|
293 |
-
|
294 |
-
|
295 |
-
|
296 |
-
|
297 |
-
|
298 |
-
|
299 |
-
|
300 |
-
|
301 |
-
|
302 |
-
|
303 |
-
|
304 |
-
|
305 |
-
|
306 |
-
|
307 |
-
|
308 |
-
|
309 |
-
|
310 |
-
|
311 |
-
|
312 |
-
|
313 |
-
|
314 |
-
|
315 |
-
|
316 |
-
|
317 |
-
|
318 |
-
|
319 |
-
|
320 |
-
|
321 |
-
|
322 |
-
|
323 |
-
|
324 |
-
|
325 |
-
|
326 |
-
|
327 |
-
|
328 |
-
|
329 |
-
|
330 |
-
|
331 |
-
|
332 |
-
|
333 |
-
},
|
334 |
-
|
335 |
-
|
336 |
-
|
337 |
-
|
338 |
-
|
339 |
-
|
340 |
-
|
341 |
-
|
342 |
-
|
343 |
-
|
344 |
-
|
345 |
-
|
346 |
-
|
347 |
-
|
348 |
-
|
349 |
-
|
350 |
-
|
351 |
-
|
352 |
-
|
353 |
-
|
354 |
-
|
355 |
-
|
356 |
-
|
357 |
-
|
358 |
-
|
359 |
-
|
360 |
-
|
361 |
-
|
362 |
-
|
363 |
-
|
364 |
-
|
365 |
-
|
366 |
-
|
367 |
-
|
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 |
+
kill(game: Game, now: number) {
|
260 |
+
console.log(`agent ${ this.id } is killed`)
|
261 |
+
|
262 |
+
// Remove schedule operation if any.
|
263 |
+
const operationId = this.inProgressOperation?.operationId;
|
264 |
+
if (operationId !== undefined) {
|
265 |
+
const index = game.pendingOperations.findIndex(op => op.args[0] === operationId);
|
266 |
+
|
267 |
+
if (index !== -1) {
|
268 |
+
game.pendingOperations.splice(index, 1);
|
269 |
+
}
|
270 |
+
}
|
271 |
+
|
272 |
+
game.world.agents.delete(this.id);
|
273 |
+
}
|
274 |
+
|
275 |
+
serialize(): SerializedAgent {
|
276 |
+
return {
|
277 |
+
id: this.id,
|
278 |
+
playerId: this.playerId,
|
279 |
+
toRemember: this.toRemember,
|
280 |
+
lastConversation: this.lastConversation,
|
281 |
+
lastInviteAttempt: this.lastInviteAttempt,
|
282 |
+
inProgressOperation: this.inProgressOperation,
|
283 |
+
};
|
284 |
+
}
|
285 |
+
}
|
286 |
+
|
287 |
+
export const serializedAgent = {
|
288 |
+
id: agentId,
|
289 |
+
playerId: playerId,
|
290 |
+
toRemember: v.optional(conversationId),
|
291 |
+
lastConversation: v.optional(v.number()),
|
292 |
+
lastInviteAttempt: v.optional(v.number()),
|
293 |
+
inProgressOperation: v.optional(
|
294 |
+
v.object({
|
295 |
+
name: v.string(),
|
296 |
+
operationId: v.string(),
|
297 |
+
started: v.number(),
|
298 |
+
}),
|
299 |
+
),
|
300 |
+
};
|
301 |
+
export type SerializedAgent = ObjectType<typeof serializedAgent>;
|
302 |
+
|
303 |
+
type AgentOperations = typeof internal.aiTown.agentOperations;
|
304 |
+
|
305 |
+
export async function runAgentOperation(ctx: MutationCtx, operation: string, args: any) {
|
306 |
+
let reference;
|
307 |
+
switch (operation) {
|
308 |
+
case 'agentRememberConversation':
|
309 |
+
reference = internal.aiTown.agentOperations.agentRememberConversation;
|
310 |
+
break;
|
311 |
+
case 'agentGenerateMessage':
|
312 |
+
reference = internal.aiTown.agentOperations.agentGenerateMessage;
|
313 |
+
break;
|
314 |
+
case 'agentDoSomething':
|
315 |
+
reference = internal.aiTown.agentOperations.agentDoSomething;
|
316 |
+
break;
|
317 |
+
default:
|
318 |
+
throw new Error(`Unknown operation: ${operation}`);
|
319 |
+
}
|
320 |
+
await ctx.scheduler.runAfter(0, reference, args);
|
321 |
+
}
|
322 |
+
|
323 |
+
export const agentSendMessage = internalMutation({
|
324 |
+
args: {
|
325 |
+
worldId: v.id('worlds'),
|
326 |
+
conversationId,
|
327 |
+
agentId,
|
328 |
+
playerId,
|
329 |
+
text: v.string(),
|
330 |
+
messageUuid: v.string(),
|
331 |
+
leaveConversation: v.boolean(),
|
332 |
+
operationId: v.string(),
|
333 |
+
},
|
334 |
+
handler: async (ctx, args) => {
|
335 |
+
await ctx.db.insert('messages', {
|
336 |
+
conversationId: args.conversationId,
|
337 |
+
author: args.playerId,
|
338 |
+
text: args.text,
|
339 |
+
messageUuid: args.messageUuid,
|
340 |
+
worldId: args.worldId,
|
341 |
+
});
|
342 |
+
await insertInput(ctx, args.worldId, 'agentFinishSendingMessage', {
|
343 |
+
conversationId: args.conversationId,
|
344 |
+
agentId: args.agentId,
|
345 |
+
timestamp: Date.now(),
|
346 |
+
leaveConversation: args.leaveConversation,
|
347 |
+
operationId: args.operationId,
|
348 |
+
});
|
349 |
+
},
|
350 |
+
});
|
351 |
+
|
352 |
+
export const findConversationCandidate = internalQuery({
|
353 |
+
args: {
|
354 |
+
now: v.number(),
|
355 |
+
worldId: v.id('worlds'),
|
356 |
+
player: v.object(serializedPlayer),
|
357 |
+
otherFreePlayers: v.array(v.object(serializedPlayer)),
|
358 |
+
},
|
359 |
+
handler: async (ctx, { now, worldId, player, otherFreePlayers }) => {
|
360 |
+
const { position } = player;
|
361 |
+
const candidates = [];
|
362 |
+
|
363 |
+
for (const otherPlayer of otherFreePlayers) {
|
364 |
+
// Find the latest conversation we're both members of.
|
365 |
+
const lastMember = await ctx.db
|
366 |
+
.query('participatedTogether')
|
367 |
+
.withIndex('edge', (q) =>
|
368 |
+
q.eq('worldId', worldId).eq('player1', player.id).eq('player2', otherPlayer.id),
|
369 |
+
)
|
370 |
+
.order('desc')
|
371 |
+
.first();
|
372 |
+
if (lastMember) {
|
373 |
+
if (now < lastMember.ended + PLAYER_CONVERSATION_COOLDOWN) {
|
374 |
+
continue;
|
375 |
+
}
|
376 |
+
}
|
377 |
+
candidates.push({ id: otherPlayer.id, position });
|
378 |
+
}
|
379 |
+
|
380 |
+
// Sort by distance and take the nearest candidate.
|
381 |
+
candidates.sort((a, b) => distance(a.position, position) - distance(b.position, position));
|
382 |
+
return candidates[0]?.id;
|
383 |
+
},
|
384 |
+
});
|
patches/convex/aiTown/agentDescription.ts
CHANGED
@@ -1,27 +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>;
|
|
|
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
CHANGED
@@ -1,155 +1,158 @@
|
|
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 |
-
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
const
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
agent.inProgressOperation
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
delete agent.
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
const
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
agent.inProgressOperation
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
const
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
const
|
88 |
-
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
const
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
agent.inProgressOperation
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
|
111 |
-
|
112 |
-
|
113 |
-
|
114 |
-
|
115 |
-
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
|
127 |
-
|
128 |
-
|
129 |
-
|
130 |
-
description.
|
131 |
-
|
132 |
-
|
133 |
-
|
134 |
-
|
135 |
-
|
136 |
-
|
137 |
-
|
138 |
-
|
139 |
-
|
140 |
-
|
141 |
-
|
142 |
-
|
143 |
-
|
144 |
-
|
145 |
-
|
146 |
-
|
147 |
-
|
148 |
-
|
149 |
-
|
150 |
-
|
151 |
-
|
152 |
-
|
153 |
-
|
154 |
-
|
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 |
+
import { CharacterTypeSchema } from './playerDescription';
|
12 |
+
|
13 |
+
export const agentInputs = {
|
14 |
+
finishRememberConversation: inputHandler({
|
15 |
+
args: {
|
16 |
+
operationId: v.string(),
|
17 |
+
agentId,
|
18 |
+
},
|
19 |
+
handler: (game, now, args) => {
|
20 |
+
const agentId = parseGameId('agents', args.agentId);
|
21 |
+
const agent = game.world.agents.get(agentId);
|
22 |
+
if (!agent) {
|
23 |
+
throw new Error(`Couldn't find agent: ${agentId}`);
|
24 |
+
}
|
25 |
+
if (
|
26 |
+
!agent.inProgressOperation ||
|
27 |
+
agent.inProgressOperation.operationId !== args.operationId
|
28 |
+
) {
|
29 |
+
console.debug(`Agent ${agentId} isn't remembering ${args.operationId}`);
|
30 |
+
} else {
|
31 |
+
delete agent.inProgressOperation;
|
32 |
+
delete agent.toRemember;
|
33 |
+
}
|
34 |
+
return null;
|
35 |
+
},
|
36 |
+
}),
|
37 |
+
finishDoSomething: inputHandler({
|
38 |
+
args: {
|
39 |
+
operationId: v.string(),
|
40 |
+
agentId: v.id('agents'),
|
41 |
+
destination: v.optional(point),
|
42 |
+
invitee: v.optional(v.id('players')),
|
43 |
+
activity: v.optional(activity),
|
44 |
+
},
|
45 |
+
handler: (game, now, args) => {
|
46 |
+
const agentId = parseGameId('agents', args.agentId);
|
47 |
+
const agent = game.world.agents.get(agentId);
|
48 |
+
if (!agent) {
|
49 |
+
throw new Error(`Couldn't find agent: ${agentId}`);
|
50 |
+
}
|
51 |
+
if (
|
52 |
+
!agent.inProgressOperation ||
|
53 |
+
agent.inProgressOperation.operationId !== args.operationId
|
54 |
+
) {
|
55 |
+
console.debug(`Agent ${agentId} didn't have ${args.operationId} in progress`);
|
56 |
+
return null;
|
57 |
+
}
|
58 |
+
delete agent.inProgressOperation;
|
59 |
+
const player = game.world.players.get(agent.playerId)!;
|
60 |
+
if (args.invitee) {
|
61 |
+
const inviteeId = parseGameId('players', args.invitee);
|
62 |
+
const invitee = game.world.players.get(inviteeId);
|
63 |
+
if (!invitee) {
|
64 |
+
throw new Error(`Couldn't find player: ${inviteeId}`);
|
65 |
+
}
|
66 |
+
Conversation.start(game, now, player, invitee);
|
67 |
+
agent.lastInviteAttempt = now;
|
68 |
+
}
|
69 |
+
if (args.destination) {
|
70 |
+
movePlayer(game, now, player, args.destination);
|
71 |
+
}
|
72 |
+
if (args.activity) {
|
73 |
+
player.activity = args.activity;
|
74 |
+
}
|
75 |
+
return null;
|
76 |
+
},
|
77 |
+
}),
|
78 |
+
agentFinishSendingMessage: inputHandler({
|
79 |
+
args: {
|
80 |
+
agentId,
|
81 |
+
conversationId,
|
82 |
+
timestamp: v.number(),
|
83 |
+
operationId: v.string(),
|
84 |
+
leaveConversation: v.boolean(),
|
85 |
+
},
|
86 |
+
handler: (game, now, args) => {
|
87 |
+
const agentId = parseGameId('agents', args.agentId);
|
88 |
+
const agent = game.world.agents.get(agentId);
|
89 |
+
if (!agent) {
|
90 |
+
throw new Error(`Couldn't find agent: ${agentId}`);
|
91 |
+
}
|
92 |
+
const player = game.world.players.get(agent.playerId);
|
93 |
+
if (!player) {
|
94 |
+
throw new Error(`Couldn't find player: ${agent.playerId}`);
|
95 |
+
}
|
96 |
+
const conversationId = parseGameId('conversations', args.conversationId);
|
97 |
+
const conversation = game.world.conversations.get(conversationId);
|
98 |
+
if (!conversation) {
|
99 |
+
throw new Error(`Couldn't find conversation: ${conversationId}`);
|
100 |
+
}
|
101 |
+
if (
|
102 |
+
!agent.inProgressOperation ||
|
103 |
+
agent.inProgressOperation.operationId !== args.operationId
|
104 |
+
) {
|
105 |
+
console.debug(`Agent ${agentId} wasn't sending a message ${args.operationId}`);
|
106 |
+
return null;
|
107 |
+
}
|
108 |
+
delete agent.inProgressOperation;
|
109 |
+
conversationInputs.finishSendingMessage.handler(game, now, {
|
110 |
+
playerId: agent.playerId,
|
111 |
+
conversationId: args.conversationId,
|
112 |
+
timestamp: args.timestamp,
|
113 |
+
});
|
114 |
+
if (args.leaveConversation) {
|
115 |
+
conversation.leave(game, now, player);
|
116 |
+
}
|
117 |
+
return null;
|
118 |
+
},
|
119 |
+
}),
|
120 |
+
createAgent: inputHandler({
|
121 |
+
args: {
|
122 |
+
descriptionIndex: v.number(),
|
123 |
+
type: CharacterTypeSchema
|
124 |
+
},
|
125 |
+
handler: (game, now, args) => {
|
126 |
+
const description = Descriptions[args.descriptionIndex];
|
127 |
+
const playerId = Player.join(
|
128 |
+
game,
|
129 |
+
now,
|
130 |
+
description.name,
|
131 |
+
description.character,
|
132 |
+
description.identity,
|
133 |
+
args.type,
|
134 |
+
);
|
135 |
+
const agentId = game.allocId('agents');
|
136 |
+
game.world.agents.set(
|
137 |
+
agentId,
|
138 |
+
new Agent({
|
139 |
+
id: agentId,
|
140 |
+
playerId: playerId,
|
141 |
+
inProgressOperation: undefined,
|
142 |
+
lastConversation: undefined,
|
143 |
+
lastInviteAttempt: undefined,
|
144 |
+
toRemember: undefined,
|
145 |
+
}),
|
146 |
+
);
|
147 |
+
game.agentDescriptions.set(
|
148 |
+
agentId,
|
149 |
+
new AgentDescription({
|
150 |
+
agentId: agentId,
|
151 |
+
identity: description.identity,
|
152 |
+
plan: description.plan,
|
153 |
+
}),
|
154 |
+
);
|
155 |
+
return { agentId };
|
156 |
+
},
|
157 |
+
}),
|
158 |
+
};
|
patches/convex/aiTown/agentOperations.ts
CHANGED
@@ -1,182 +1,180 @@
|
|
1 |
-
|
2 |
-
|
3 |
-
import {
|
4 |
-
import {
|
5 |
-
import {
|
6 |
-
import {
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
} from '
|
13 |
-
import {
|
14 |
-
import {
|
15 |
-
import {
|
16 |
-
import {
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
args.
|
32 |
-
args.
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
|
76 |
-
args.
|
77 |
-
args.
|
78 |
-
|
79 |
-
|
80 |
-
);
|
81 |
-
|
82 |
-
|
83 |
-
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
const
|
108 |
-
|
109 |
-
const
|
110 |
-
|
111 |
-
|
112 |
-
|
113 |
-
|
114 |
-
const
|
115 |
-
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
|
127 |
-
|
128 |
-
|
129 |
-
|
130 |
-
|
131 |
-
|
132 |
-
|
133 |
-
|
134 |
-
|
135 |
-
|
136 |
-
|
137 |
-
|
138 |
-
|
139 |
-
|
140 |
-
|
141 |
-
|
142 |
-
|
143 |
-
|
144 |
-
|
145 |
-
|
146 |
-
|
147 |
-
|
148 |
-
|
149 |
-
|
150 |
-
|
151 |
-
|
152 |
-
|
153 |
-
|
154 |
-
|
155 |
-
|
156 |
-
|
157 |
-
|
158 |
-
|
159 |
-
|
160 |
-
|
161 |
-
|
162 |
-
|
163 |
-
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
|
172 |
-
|
173 |
-
|
174 |
-
|
175 |
-
|
176 |
-
|
177 |
-
|
178 |
-
|
179 |
-
|
180 |
-
|
181 |
-
};
|
182 |
-
}
|
|
|
1 |
+
import { v } from 'convex/values';
|
2 |
+
import { internalAction } from '../_generated/server';
|
3 |
+
import { WorldMap, serializedWorldMap } from './worldMap';
|
4 |
+
import { rememberConversation } from '../agent/memory';
|
5 |
+
import { GameId, agentId, conversationId, playerId } from './ids';
|
6 |
+
import {
|
7 |
+
continueConversationMessage,
|
8 |
+
leaveConversationMessage,
|
9 |
+
startConversationMessage,
|
10 |
+
} from '../agent/conversation';
|
11 |
+
import { assertNever } from '../util/assertNever';
|
12 |
+
import { serializedAgent } from './agent';
|
13 |
+
import { ACTIVITIES, ACTIVITY_COOLDOWN, CONVERSATION_COOLDOWN } from '../constants';
|
14 |
+
import { api, internal } from '../_generated/api';
|
15 |
+
import { sleep } from '../util/sleep';
|
16 |
+
import { serializedPlayer } from './player';
|
17 |
+
|
18 |
+
export const agentRememberConversation = internalAction({
|
19 |
+
args: {
|
20 |
+
worldId: v.id('worlds'),
|
21 |
+
playerId,
|
22 |
+
agentId,
|
23 |
+
conversationId,
|
24 |
+
operationId: v.string(),
|
25 |
+
},
|
26 |
+
handler: async (ctx, args) => {
|
27 |
+
await rememberConversation(
|
28 |
+
ctx,
|
29 |
+
args.worldId,
|
30 |
+
args.agentId as GameId<'agents'>,
|
31 |
+
args.playerId as GameId<'players'>,
|
32 |
+
args.conversationId as GameId<'conversations'>,
|
33 |
+
);
|
34 |
+
await sleep(Math.random() * 1000);
|
35 |
+
await ctx.runMutation(api.aiTown.main.sendInput, {
|
36 |
+
worldId: args.worldId,
|
37 |
+
name: 'finishRememberConversation',
|
38 |
+
args: {
|
39 |
+
agentId: args.agentId,
|
40 |
+
operationId: args.operationId,
|
41 |
+
},
|
42 |
+
});
|
43 |
+
},
|
44 |
+
});
|
45 |
+
|
46 |
+
export const agentGenerateMessage = internalAction({
|
47 |
+
args: {
|
48 |
+
worldId: v.id('worlds'),
|
49 |
+
playerId,
|
50 |
+
agentId,
|
51 |
+
conversationId,
|
52 |
+
otherPlayerId: playerId,
|
53 |
+
operationId: v.string(),
|
54 |
+
type: v.union(v.literal('start'), v.literal('continue'), v.literal('leave')),
|
55 |
+
messageUuid: v.string(),
|
56 |
+
},
|
57 |
+
handler: async (ctx, args) => {
|
58 |
+
let completionFn;
|
59 |
+
switch (args.type) {
|
60 |
+
case 'start':
|
61 |
+
completionFn = startConversationMessage;
|
62 |
+
break;
|
63 |
+
case 'continue':
|
64 |
+
completionFn = continueConversationMessage;
|
65 |
+
break;
|
66 |
+
case 'leave':
|
67 |
+
completionFn = leaveConversationMessage;
|
68 |
+
break;
|
69 |
+
default:
|
70 |
+
assertNever(args.type);
|
71 |
+
}
|
72 |
+
const completion = await completionFn(
|
73 |
+
ctx,
|
74 |
+
args.worldId,
|
75 |
+
args.conversationId as GameId<'conversations'>,
|
76 |
+
args.playerId as GameId<'players'>,
|
77 |
+
args.otherPlayerId as GameId<'players'>,
|
78 |
+
);
|
79 |
+
// TODO: stream in the text instead of reading it all at once.
|
80 |
+
const text = await completion.readAll();
|
81 |
+
|
82 |
+
await ctx.runMutation(internal.aiTown.agent.agentSendMessage, {
|
83 |
+
worldId: args.worldId,
|
84 |
+
conversationId: args.conversationId,
|
85 |
+
agentId: args.agentId,
|
86 |
+
playerId: args.playerId,
|
87 |
+
text,
|
88 |
+
messageUuid: args.messageUuid,
|
89 |
+
leaveConversation: args.type === 'leave',
|
90 |
+
operationId: args.operationId,
|
91 |
+
});
|
92 |
+
},
|
93 |
+
});
|
94 |
+
|
95 |
+
export const agentDoSomething = internalAction({
|
96 |
+
args: {
|
97 |
+
worldId: v.id('worlds'),
|
98 |
+
player: v.object(serializedPlayer),
|
99 |
+
agent: v.object(serializedAgent),
|
100 |
+
map: v.object(serializedWorldMap),
|
101 |
+
otherFreePlayers: v.array(v.object(serializedPlayer)),
|
102 |
+
operationId: v.string(),
|
103 |
+
},
|
104 |
+
handler: async (ctx, args) => {
|
105 |
+
const { player, agent } = args;
|
106 |
+
const map = new WorldMap(args.map);
|
107 |
+
const now = Date.now();
|
108 |
+
// Don't try to start a new conversation if we were just in one.
|
109 |
+
const justLeftConversation =
|
110 |
+
agent.lastConversation && now < agent.lastConversation + CONVERSATION_COOLDOWN;
|
111 |
+
// Don't try again if we recently tried to find someone to invite.
|
112 |
+
const recentlyAttemptedInvite =
|
113 |
+
agent.lastInviteAttempt && now < agent.lastInviteAttempt + CONVERSATION_COOLDOWN;
|
114 |
+
const recentActivity = player.activity && now < player.activity.until + ACTIVITY_COOLDOWN;
|
115 |
+
// Decide whether to do an activity or wander somewhere.
|
116 |
+
if (!player.pathfinding) {
|
117 |
+
if (recentActivity || justLeftConversation) {
|
118 |
+
await sleep(Math.random() * 1000);
|
119 |
+
await ctx.runMutation(api.aiTown.main.sendInput, {
|
120 |
+
worldId: args.worldId,
|
121 |
+
name: 'finishDoSomething',
|
122 |
+
args: {
|
123 |
+
operationId: args.operationId,
|
124 |
+
agentId: agent.id,
|
125 |
+
destination: wanderDestination(map),
|
126 |
+
},
|
127 |
+
});
|
128 |
+
return;
|
129 |
+
} else {
|
130 |
+
// TODO: have LLM choose the activity & emoji
|
131 |
+
const activity = ACTIVITIES[Math.floor(Math.random() * ACTIVITIES.length)];
|
132 |
+
await sleep(Math.random() * 1000);
|
133 |
+
await ctx.runMutation(api.aiTown.main.sendInput, {
|
134 |
+
worldId: args.worldId,
|
135 |
+
name: 'finishDoSomething',
|
136 |
+
args: {
|
137 |
+
operationId: args.operationId,
|
138 |
+
agentId: agent.id,
|
139 |
+
activity: {
|
140 |
+
description: activity.description,
|
141 |
+
emoji: activity.emoji,
|
142 |
+
until: Date.now() + activity.duration,
|
143 |
+
},
|
144 |
+
},
|
145 |
+
});
|
146 |
+
return;
|
147 |
+
}
|
148 |
+
}
|
149 |
+
const invitee =
|
150 |
+
justLeftConversation || recentlyAttemptedInvite
|
151 |
+
? undefined
|
152 |
+
: await ctx.runQuery(internal.aiTown.agent.findConversationCandidate, {
|
153 |
+
now,
|
154 |
+
worldId: args.worldId,
|
155 |
+
player: args.player,
|
156 |
+
otherFreePlayers: args.otherFreePlayers,
|
157 |
+
});
|
158 |
+
|
159 |
+
// TODO: We hit a lot of OCC errors on sending inputs in this file. It's
|
160 |
+
// easy for them to get scheduled at the same time and line up in time.
|
161 |
+
await sleep(Math.random() * 1000);
|
162 |
+
await ctx.runMutation(api.aiTown.main.sendInput, {
|
163 |
+
worldId: args.worldId,
|
164 |
+
name: 'finishDoSomething',
|
165 |
+
args: {
|
166 |
+
operationId: args.operationId,
|
167 |
+
agentId: args.agent.id,
|
168 |
+
invitee,
|
169 |
+
},
|
170 |
+
});
|
171 |
+
},
|
172 |
+
});
|
173 |
+
|
174 |
+
function wanderDestination(worldMap: WorldMap) {
|
175 |
+
// Wander someonewhere at least one tile away from the edge.
|
176 |
+
return {
|
177 |
+
x: 1 + Math.floor(Math.random() * (worldMap.width - 2)),
|
178 |
+
y: 1 + Math.floor(Math.random() * (worldMap.height - 2)),
|
179 |
+
};
|
180 |
+
}
|
|
|
|
patches/convex/aiTown/conversation.ts
CHANGED
@@ -1,395 +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 |
-
};
|
|
|
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
CHANGED
@@ -1,38 +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 |
-
}
|
|
|
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
DELETED
@@ -1,71 +0,0 @@
|
|
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
CHANGED
@@ -1,374 +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
|
179 |
-
this.world.
|
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 |
-
});
|
|
|
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 game cycle counter
|
179 |
+
this.world.gameCycle.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/gameCycle.ts
ADDED
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { v, Infer, ObjectType } from 'convex/values';
|
2 |
+
import { Game } from './game';
|
3 |
+
import {
|
4 |
+
DAY_DURATION,
|
5 |
+
NIGHT_DURATION,
|
6 |
+
WWOLF_VOTE_DURATION,
|
7 |
+
PLAYER_KILL_VOTE_DURATION,
|
8 |
+
LLM_VOTE_DURATION,
|
9 |
+
} from '../constants';
|
10 |
+
import { processVotes } from './voting';
|
11 |
+
|
12 |
+
type CycleState = 'Day' | 'Night' | 'WerewolfVoting' | 'PlayerKillVoting' | 'LLMsVoting' | 'LobbyState'
|
13 |
+
|
14 |
+
const stateDurations: { [key in CycleState]: number } = {
|
15 |
+
Day: DAY_DURATION,
|
16 |
+
Night: NIGHT_DURATION,
|
17 |
+
WerewolfVoting: WWOLF_VOTE_DURATION,
|
18 |
+
PlayerKillVoting: PLAYER_KILL_VOTE_DURATION,
|
19 |
+
LLMsVoting: LLM_VOTE_DURATION,
|
20 |
+
LobbyState: Infinity
|
21 |
+
};
|
22 |
+
|
23 |
+
const normalCycle: CycleState[] = [
|
24 |
+
'Day',
|
25 |
+
'Night',
|
26 |
+
'WerewolfVoting',
|
27 |
+
'PlayerKillVoting',
|
28 |
+
];
|
29 |
+
|
30 |
+
|
31 |
+
export const gameCycleSchema = {
|
32 |
+
currentTime: v.number(),
|
33 |
+
cycleState: v.union(
|
34 |
+
v.literal('Day'),
|
35 |
+
v.literal('Night'),
|
36 |
+
v.literal('WerewolfVoting'),
|
37 |
+
v.literal('PlayerKillVoting'),
|
38 |
+
v.literal('LLMsVoting'),
|
39 |
+
v.literal('LobbyState'),
|
40 |
+
),
|
41 |
+
cycleIndex: v.number(),
|
42 |
+
};
|
43 |
+
|
44 |
+
export type SerializedGameCycle = ObjectType<typeof gameCycleSchema>;
|
45 |
+
|
46 |
+
const onStateChange = (prevState: CycleState, newState: CycleState, game: Game, now: number) => {
|
47 |
+
console.log(`state changed: ${ prevState } -> ${ newState }`);
|
48 |
+
if (prevState === 'PlayerKillVoting') {
|
49 |
+
const mostVotedPlayer = processVotes(game.world.votes, [...game.world.players.values()])[0];
|
50 |
+
// TODO: Kill the player
|
51 |
+
const playerToKill = game.world.players.get(mostVotedPlayer)
|
52 |
+
if (playerToKill != undefined) {
|
53 |
+
playerToKill.kill(game, now)
|
54 |
+
}
|
55 |
+
}
|
56 |
+
if (prevState === 'WerewolfVoting') {
|
57 |
+
const mostVotedPlayer = processVotes(game.world.votes, [...game.world.players.values()])[0];
|
58 |
+
// TODO: Check if most voted player is werewolf
|
59 |
+
}
|
60 |
+
// TODO: Implement LLM voting
|
61 |
+
};
|
62 |
+
|
63 |
+
export class GameCycle {
|
64 |
+
currentTime: number;
|
65 |
+
cycleState: CycleState;
|
66 |
+
cycleIndex: number;
|
67 |
+
|
68 |
+
constructor(serialized: SerializedGameCycle) {
|
69 |
+
const { currentTime, cycleState, cycleIndex } = serialized;
|
70 |
+
this.currentTime = currentTime;
|
71 |
+
this.cycleState = cycleState;
|
72 |
+
this.cycleIndex = cycleIndex;
|
73 |
+
}
|
74 |
+
|
75 |
+
// Tick method to increment the counter
|
76 |
+
tick(game: Game, tickDuration: number) {
|
77 |
+
this.currentTime += tickDuration;
|
78 |
+
|
79 |
+
if (this.currentTime >= stateDurations[this.cycleState]) {
|
80 |
+
const prevState = this.cycleState;
|
81 |
+
this.currentTime = 0;
|
82 |
+
this.cycleIndex = (this.cycleIndex + 1) % normalCycle.length;
|
83 |
+
this.cycleState = normalCycle[this.cycleIndex];
|
84 |
+
onStateChange(prevState, this.cycleState, game, tickDuration);
|
85 |
+
}
|
86 |
+
}
|
87 |
+
|
88 |
+
|
89 |
+
serialize(): SerializedGameCycle {
|
90 |
+
const { currentTime, cycleState, cycleIndex } = this;
|
91 |
+
return {
|
92 |
+
currentTime,
|
93 |
+
cycleState,
|
94 |
+
cycleIndex,
|
95 |
+
};
|
96 |
+
}
|
97 |
+
}
|
patches/convex/aiTown/ids.ts
CHANGED
@@ -1,32 +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();
|
|
|
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
CHANGED
@@ -1,9 +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 |
-
}
|
|
|
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
CHANGED
@@ -1,25 +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;
|
|
|
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
CHANGED
@@ -1,20 +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 |
-
}
|
|
|
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
CHANGED
@@ -1,32 +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 |
-
}
|
|
|
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
CHANGED
@@ -1,154 +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 |
-
});
|
|
|
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
CHANGED
@@ -1,189 +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 |
-
}
|
|
|
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
CHANGED
@@ -1,314 +1,342 @@
|
|
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 |
-
|
175 |
-
|
176 |
-
) {
|
177 |
-
if (tokenIdentifier) {
|
178 |
-
let numHumans = 0;
|
179 |
-
for (const player of game.world.players.values()) {
|
180 |
-
if (player.human) {
|
181 |
-
numHumans++;
|
182 |
-
}
|
183 |
-
if (player.human === tokenIdentifier) {
|
184 |
-
throw new Error(`You are already in this game!`);
|
185 |
-
}
|
186 |
-
}
|
187 |
-
if (numHumans >= MAX_HUMAN_PLAYERS) {
|
188 |
-
throw new Error(`Only ${MAX_HUMAN_PLAYERS} human players allowed at once.`);
|
189 |
-
}
|
190 |
-
}
|
191 |
-
let position;
|
192 |
-
for (let attempt = 0; attempt < 10; attempt++) {
|
193 |
-
const candidate = {
|
194 |
-
x: Math.floor(Math.random() * game.worldMap.width),
|
195 |
-
y: Math.floor(Math.random() * game.worldMap.height),
|
196 |
-
};
|
197 |
-
if (blocked(game, now, candidate)) {
|
198 |
-
continue;
|
199 |
-
}
|
200 |
-
position = candidate;
|
201 |
-
break;
|
202 |
-
}
|
203 |
-
if (!position) {
|
204 |
-
throw new Error(`Failed to find a free position!`);
|
205 |
-
}
|
206 |
-
const facingOptions = [
|
207 |
-
{ dx: 1, dy: 0 },
|
208 |
-
{ dx: -1, dy: 0 },
|
209 |
-
{ dx: 0, dy: 1 },
|
210 |
-
{ dx: 0, dy: -1 },
|
211 |
-
];
|
212 |
-
const facing = facingOptions[Math.floor(Math.random() * facingOptions.length)];
|
213 |
-
if (!characters.find((c) => c.name === character)) {
|
214 |
-
throw new Error(`Invalid character: ${character}`);
|
215 |
-
}
|
216 |
-
const playerId = game.allocId('players');
|
217 |
-
game.world.players.set(
|
218 |
-
playerId,
|
219 |
-
new Player({
|
220 |
-
id: playerId,
|
221 |
-
human: tokenIdentifier,
|
222 |
-
lastInput: now,
|
223 |
-
position,
|
224 |
-
facing,
|
225 |
-
speed: 0,
|
226 |
-
}),
|
227 |
-
);
|
228 |
-
|
229 |
-
|
230 |
-
|
231 |
-
|
232 |
-
|
233 |
-
|
234 |
-
|
235 |
-
|
236 |
-
|
237 |
-
|
238 |
-
|
239 |
-
|
240 |
-
|
241 |
-
|
242 |
-
|
243 |
-
|
244 |
-
|
245 |
-
|
246 |
-
|
247 |
-
)
|
248 |
-
|
249 |
-
|
250 |
-
|
251 |
-
|
252 |
-
|
253 |
-
|
254 |
-
|
255 |
-
|
256 |
-
|
257 |
-
|
258 |
-
|
259 |
-
|
260 |
-
|
261 |
-
|
262 |
-
|
263 |
-
|
264 |
-
|
265 |
-
|
266 |
-
|
267 |
-
}
|
268 |
-
|
269 |
-
|
270 |
-
|
271 |
-
|
272 |
-
|
273 |
-
|
274 |
-
|
275 |
-
|
276 |
-
|
277 |
-
|
278 |
-
|
279 |
-
|
280 |
-
|
281 |
-
|
282 |
-
|
283 |
-
|
284 |
-
|
285 |
-
|
286 |
-
|
287 |
-
|
288 |
-
|
289 |
-
|
290 |
-
|
291 |
-
|
292 |
-
|
293 |
-
|
294 |
-
|
295 |
-
|
296 |
-
|
297 |
-
|
298 |
-
|
299 |
-
},
|
300 |
-
handler: (game, now, args) => {
|
301 |
-
const playerId = parseGameId('players', args.playerId);
|
302 |
-
const player = game.world.players.get(playerId);
|
303 |
-
if (!player) {
|
304 |
-
throw new Error(`Invalid player ID ${playerId}`);
|
305 |
-
}
|
306 |
-
|
307 |
-
|
308 |
-
|
309 |
-
|
310 |
-
|
311 |
-
|
312 |
-
|
313 |
-
|
314 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 { CharacterType, CharacterTypeSchema, 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 |
+
type: CharacterType,
|
175 |
+
tokenIdentifier?: string,
|
176 |
+
) {
|
177 |
+
if (tokenIdentifier) {
|
178 |
+
let numHumans = 0;
|
179 |
+
for (const player of game.world.players.values()) {
|
180 |
+
if (player.human) {
|
181 |
+
numHumans++;
|
182 |
+
}
|
183 |
+
if (player.human === tokenIdentifier) {
|
184 |
+
throw new Error(`You are already in this game!`);
|
185 |
+
}
|
186 |
+
}
|
187 |
+
if (numHumans >= MAX_HUMAN_PLAYERS) {
|
188 |
+
throw new Error(`Only ${MAX_HUMAN_PLAYERS} human players allowed at once.`);
|
189 |
+
}
|
190 |
+
}
|
191 |
+
let position;
|
192 |
+
for (let attempt = 0; attempt < 10; attempt++) {
|
193 |
+
const candidate = {
|
194 |
+
x: Math.floor(Math.random() * game.worldMap.width),
|
195 |
+
y: Math.floor(Math.random() * game.worldMap.height),
|
196 |
+
};
|
197 |
+
if (blocked(game, now, candidate)) {
|
198 |
+
continue;
|
199 |
+
}
|
200 |
+
position = candidate;
|
201 |
+
break;
|
202 |
+
}
|
203 |
+
if (!position) {
|
204 |
+
throw new Error(`Failed to find a free position!`);
|
205 |
+
}
|
206 |
+
const facingOptions = [
|
207 |
+
{ dx: 1, dy: 0 },
|
208 |
+
{ dx: -1, dy: 0 },
|
209 |
+
{ dx: 0, dy: 1 },
|
210 |
+
{ dx: 0, dy: -1 },
|
211 |
+
];
|
212 |
+
const facing = facingOptions[Math.floor(Math.random() * facingOptions.length)];
|
213 |
+
if (!characters.find((c) => c.name === character)) {
|
214 |
+
throw new Error(`Invalid character: ${character}`);
|
215 |
+
}
|
216 |
+
const playerId = game.allocId('players');
|
217 |
+
game.world.players.set(
|
218 |
+
playerId,
|
219 |
+
new Player({
|
220 |
+
id: playerId,
|
221 |
+
human: tokenIdentifier,
|
222 |
+
lastInput: now,
|
223 |
+
position,
|
224 |
+
facing,
|
225 |
+
speed: 0,
|
226 |
+
}),
|
227 |
+
);
|
228 |
+
game.playerDescriptions.set(
|
229 |
+
playerId,
|
230 |
+
new PlayerDescription({
|
231 |
+
playerId,
|
232 |
+
character,
|
233 |
+
description,
|
234 |
+
name,
|
235 |
+
type,
|
236 |
+
}),
|
237 |
+
);
|
238 |
+
game.descriptionsModified = true;
|
239 |
+
return playerId;
|
240 |
+
}
|
241 |
+
|
242 |
+
leave(game: Game, now: number) {
|
243 |
+
// Stop our conversation if we're leaving the game.
|
244 |
+
const conversation = [...game.world.conversations.values()].find((c) =>
|
245 |
+
c.participants.has(this.id),
|
246 |
+
);
|
247 |
+
if (conversation) {
|
248 |
+
conversation.stop(game, now);
|
249 |
+
}
|
250 |
+
game.world.players.delete(this.id);
|
251 |
+
}
|
252 |
+
|
253 |
+
kill(game: Game, now: number) {
|
254 |
+
const playerId = this.id
|
255 |
+
console.log(`player ${ playerId } is killed`)
|
256 |
+
// first leave:
|
257 |
+
this.leave(game, now)
|
258 |
+
|
259 |
+
// if the player is npc, kill agent as well
|
260 |
+
const agent = [...game.world.agents.values()].find(
|
261 |
+
agent => agent.playerId === playerId
|
262 |
+
)
|
263 |
+
|
264 |
+
if (agent) {
|
265 |
+
agent.kill(game, now)
|
266 |
+
}
|
267 |
+
}
|
268 |
+
|
269 |
+
serialize(): SerializedPlayer {
|
270 |
+
const { id, human, pathfinding, activity, lastInput, position, facing, speed } = this;
|
271 |
+
return {
|
272 |
+
id,
|
273 |
+
human,
|
274 |
+
pathfinding,
|
275 |
+
activity,
|
276 |
+
lastInput,
|
277 |
+
position,
|
278 |
+
facing,
|
279 |
+
speed,
|
280 |
+
};
|
281 |
+
}
|
282 |
+
}
|
283 |
+
|
284 |
+
export const playerInputs = {
|
285 |
+
join: inputHandler({
|
286 |
+
args: {
|
287 |
+
name: v.string(),
|
288 |
+
character: v.string(),
|
289 |
+
description: v.string(),
|
290 |
+
tokenIdentifier: v.optional(v.string()),
|
291 |
+
type: CharacterTypeSchema
|
292 |
+
},
|
293 |
+
handler: (game, now, args) => {
|
294 |
+
Player.join(game, now, args.name, args.character, args.description, args.type ,args.tokenIdentifier);
|
295 |
+
return null;
|
296 |
+
},
|
297 |
+
}),
|
298 |
+
leave: inputHandler({
|
299 |
+
args: { playerId },
|
300 |
+
handler: (game, now, args) => {
|
301 |
+
const playerId = parseGameId('players', args.playerId);
|
302 |
+
const player = game.world.players.get(playerId);
|
303 |
+
if (!player) {
|
304 |
+
throw new Error(`Invalid player ID ${playerId}`);
|
305 |
+
}
|
306 |
+
player.leave(game, now);
|
307 |
+
return null;
|
308 |
+
},
|
309 |
+
}),
|
310 |
+
moveTo: inputHandler({
|
311 |
+
args: {
|
312 |
+
playerId,
|
313 |
+
destination: v.union(point, v.null()),
|
314 |
+
},
|
315 |
+
handler: (game, now, args) => {
|
316 |
+
const playerId = parseGameId('players', args.playerId);
|
317 |
+
const player = game.world.players.get(playerId);
|
318 |
+
if (!player) {
|
319 |
+
throw new Error(`Invalid player ID ${playerId}`);
|
320 |
+
}
|
321 |
+
if (args.destination) {
|
322 |
+
movePlayer(game, now, player, args.destination);
|
323 |
+
} else {
|
324 |
+
stopPlayer(player);
|
325 |
+
}
|
326 |
+
return null;
|
327 |
+
},
|
328 |
+
}),
|
329 |
+
|
330 |
+
vote: inputHandler({
|
331 |
+
args: {
|
332 |
+
votedPlayerId: v.string(),
|
333 |
+
voteType: v.string(),
|
334 |
+
},
|
335 |
+
handler: (game, now, args) => {
|
336 |
+
const votedPlayerId = parseGameId('players', args.votedPlayerId);
|
337 |
+
// TODO: Implement the fucntion
|
338 |
+
// game.vote(votedPlayerId);
|
339 |
+
return null;
|
340 |
+
},
|
341 |
+
}),
|
342 |
+
};
|
patches/convex/aiTown/playerDescription.ts
CHANGED
@@ -1,39 +1,42 @@
|
|
1 |
-
import { ObjectType, v } from 'convex/values';
|
2 |
-
import { GameId, parseGameId, playerId } from './ids';
|
3 |
-
|
4 |
-
export
|
5 |
-
|
6 |
-
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
this.
|
26 |
-
this.
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
|
|
|
|
|
|
|
1 |
+
import { ObjectType, v } from 'convex/values';
|
2 |
+
import { GameId, parseGameId, playerId } from './ids';
|
3 |
+
|
4 |
+
export type CharacterType = 'villager' | 'werewolf';
|
5 |
+
export const CharacterTypeSchema = v.union(v.literal('villager'), v.literal('werewolf'));
|
6 |
+
|
7 |
+
export const serializedPlayerDescription = {
|
8 |
+
playerId,
|
9 |
+
name: v.string(),
|
10 |
+
description: v.string(),
|
11 |
+
character: v.string(),
|
12 |
+
type: CharacterTypeSchema,
|
13 |
+
};
|
14 |
+
export type SerializedPlayerDescription = ObjectType<typeof serializedPlayerDescription>;
|
15 |
+
|
16 |
+
export class PlayerDescription {
|
17 |
+
playerId: GameId<'players'>;
|
18 |
+
name: string;
|
19 |
+
description: string;
|
20 |
+
character: string;
|
21 |
+
type: CharacterType;
|
22 |
+
|
23 |
+
constructor(serialized: SerializedPlayerDescription) {
|
24 |
+
const { playerId, name, description, character, type } = serialized;
|
25 |
+
this.playerId = parseGameId('players', playerId);
|
26 |
+
this.name = name;
|
27 |
+
this.description = description;
|
28 |
+
this.character = character;
|
29 |
+
this.type = type;
|
30 |
+
}
|
31 |
+
|
32 |
+
serialize(): SerializedPlayerDescription {
|
33 |
+
const { playerId, name, description, character, type } = this;
|
34 |
+
return {
|
35 |
+
playerId,
|
36 |
+
name,
|
37 |
+
description,
|
38 |
+
type: type,
|
39 |
+
character,
|
40 |
+
};
|
41 |
+
}
|
42 |
+
}
|
patches/convex/aiTown/schema.ts
CHANGED
@@ -1,79 +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 |
-
};
|
|
|
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/voting.ts
ADDED
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { ObjectType, v } from "convex/values";
|
2 |
+
import { GameId, parseGameId, playerId } from "./ids";
|
3 |
+
import { Player } from "./player";
|
4 |
+
|
5 |
+
export type VoteType = 'WarewolfVote' | 'PlayerKill' | 'LLMVote'
|
6 |
+
|
7 |
+
export const VotesSchema = {
|
8 |
+
votesType: v.string(),
|
9 |
+
votes: v.array(v.object({
|
10 |
+
playerId: playerId,
|
11 |
+
voter: playerId,
|
12 |
+
}))
|
13 |
+
}
|
14 |
+
|
15 |
+
export type SerializedVotes = ObjectType<typeof VotesSchema>;
|
16 |
+
export class Votes {
|
17 |
+
votesType: string;
|
18 |
+
votes: {
|
19 |
+
playerId: GameId<'players'>;
|
20 |
+
voter: GameId<'players'>;
|
21 |
+
}[];
|
22 |
+
|
23 |
+
constructor(serialized: SerializedVotes) {
|
24 |
+
const { votesType, votes } = serialized;
|
25 |
+
|
26 |
+
this.votesType = votesType;
|
27 |
+
this.votes = votes.map((vote) => ({
|
28 |
+
playerId: parseGameId('players', vote.playerId),
|
29 |
+
voter: parseGameId('players', vote.voter),
|
30 |
+
}));
|
31 |
+
}
|
32 |
+
|
33 |
+
serialize(): SerializedVotes {
|
34 |
+
const { votesType, votes } = this;
|
35 |
+
return {
|
36 |
+
votesType,
|
37 |
+
votes,
|
38 |
+
};
|
39 |
+
}
|
40 |
+
}
|
41 |
+
|
42 |
+
export const processVotes = (votes: Votes, players: Player[], k: number = 1) => {
|
43 |
+
// Select the players with the most votes
|
44 |
+
const voteCounts: Record<GameId<'players'>, number> = {};
|
45 |
+
players.forEach(player => {
|
46 |
+
voteCounts[player.id] = 0;
|
47 |
+
});
|
48 |
+
|
49 |
+
// Tally the votes
|
50 |
+
votes.votes.forEach(vote => {
|
51 |
+
voteCounts[vote.playerId] = (voteCounts[vote.playerId] || 0) + 1;
|
52 |
+
});
|
53 |
+
|
54 |
+
const sortedVoteCounts = Object.entries(voteCounts).sort((a, b) => b[1] - a[1]);
|
55 |
+
const topKPlayers = sortedVoteCounts.slice(0, k).map(entry => entry[0]);
|
56 |
+
return topKPlayers as GameId<'players'>[];
|
57 |
+
}
|
58 |
+
|
patches/convex/aiTown/world.ts
CHANGED
@@ -1,70 +1,75 @@
|
|
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 {
|
8 |
-
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
)
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
export
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
this.
|
41 |
-
this.
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
}
|
54 |
-
|
55 |
-
|
56 |
-
return
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
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 { GameCycle, gameCycleSchema } from './gameCycle';
|
8 |
+
import { Votes, VotesSchema } from './voting';
|
9 |
+
|
10 |
+
export const historicalLocations = v.array(
|
11 |
+
v.object({
|
12 |
+
playerId,
|
13 |
+
location: v.bytes(),
|
14 |
+
}),
|
15 |
+
);
|
16 |
+
|
17 |
+
export const serializedWorld = {
|
18 |
+
nextId: v.number(),
|
19 |
+
conversations: v.array(v.object(serializedConversation)),
|
20 |
+
players: v.array(v.object(serializedPlayer)),
|
21 |
+
agents: v.array(v.object(serializedAgent)),
|
22 |
+
historicalLocations: v.optional(historicalLocations),
|
23 |
+
gameCycle: v.object(gameCycleSchema),
|
24 |
+
votes: v.object(VotesSchema)
|
25 |
+
};
|
26 |
+
export type SerializedWorld = ObjectType<typeof serializedWorld>;
|
27 |
+
|
28 |
+
export class World {
|
29 |
+
nextId: number;
|
30 |
+
conversations: Map<GameId<'conversations'>, Conversation>;
|
31 |
+
players: Map<GameId<'players'>, Player>;
|
32 |
+
agents: Map<GameId<'agents'>, Agent>;
|
33 |
+
historicalLocations?: Map<GameId<'players'>, ArrayBuffer>;
|
34 |
+
gameCycle: GameCycle;
|
35 |
+
votes: Votes;
|
36 |
+
|
37 |
+
constructor(serialized: SerializedWorld) {
|
38 |
+
const { nextId, historicalLocations } = serialized;
|
39 |
+
|
40 |
+
this.nextId = nextId;
|
41 |
+
this.conversations = parseMap(serialized.conversations, Conversation, (c) => c.id);
|
42 |
+
this.players = parseMap(serialized.players, Player, (p) => p.id);
|
43 |
+
this.agents = parseMap(serialized.agents, Agent, (a) => a.id);
|
44 |
+
this.gameCycle = new GameCycle(serialized.gameCycle);
|
45 |
+
this.votes = new Votes(serialized.votes);
|
46 |
+
|
47 |
+
if (historicalLocations) {
|
48 |
+
this.historicalLocations = new Map();
|
49 |
+
for (const { playerId, location } of historicalLocations) {
|
50 |
+
this.historicalLocations.set(parseGameId('players', playerId), location);
|
51 |
+
}
|
52 |
+
}
|
53 |
+
}
|
54 |
+
|
55 |
+
playerConversation(player: Player): Conversation | undefined {
|
56 |
+
return [...this.conversations.values()].find((c) => c.participants.has(player.id));
|
57 |
+
}
|
58 |
+
|
59 |
+
serialize(): SerializedWorld {
|
60 |
+
return {
|
61 |
+
nextId: this.nextId,
|
62 |
+
conversations: [...this.conversations.values()].map((c) => c.serialize()),
|
63 |
+
players: [...this.players.values()].map((p) => p.serialize()),
|
64 |
+
agents: [...this.agents.values()].map((a) => a.serialize()),
|
65 |
+
historicalLocations:
|
66 |
+
this.historicalLocations &&
|
67 |
+
[...this.historicalLocations.entries()].map(([playerId, location]) => ({
|
68 |
+
playerId,
|
69 |
+
location,
|
70 |
+
})),
|
71 |
+
gameCycle: this.gameCycle.serialize(),
|
72 |
+
votes: this.votes.serialize(),
|
73 |
+
};
|
74 |
+
}
|
75 |
+
}
|
patches/convex/aiTown/worldMap.ts
CHANGED
@@ -1,94 +1,91 @@
|
|
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 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
this.
|
61 |
-
|
62 |
-
this.
|
63 |
-
this.
|
64 |
-
this.
|
65 |
-
this.
|
66 |
-
this.
|
67 |
-
this.
|
68 |
-
this.
|
69 |
-
this.
|
70 |
-
this.
|
71 |
-
this.
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
};
|
93 |
-
}
|
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 |
+
// Width & height of tileset image, px.
|
24 |
+
tileSetDimX: v.number(),
|
25 |
+
tileSetDimY: v.number(),
|
26 |
+
|
27 |
+
// Tile size in pixels (assume square)
|
28 |
+
tileDim: v.number(),
|
29 |
+
bgTiles: v.array(v.array(v.array(v.number()))),
|
30 |
+
decorTiles: v.array(v.array(v.array(v.number()))),
|
31 |
+
objectTiles: v.array(tileLayer),
|
32 |
+
bgTilesN: v.array(v.array(v.array(v.number()))),
|
33 |
+
decorTilesN: v.array(v.array(v.array(v.number()))),
|
34 |
+
objectTilesN: v.array(tileLayer),
|
35 |
+
animatedSprites: v.array(v.object(animatedSprite)),
|
36 |
+
};
|
37 |
+
export type SerializedWorldMap = ObjectType<typeof serializedWorldMap>;
|
38 |
+
|
39 |
+
export class WorldMap {
|
40 |
+
width: number;
|
41 |
+
height: number;
|
42 |
+
|
43 |
+
tileSetUrl: string;
|
44 |
+
tileSetDimX: number;
|
45 |
+
tileSetDimY: number;
|
46 |
+
|
47 |
+
tileDim: number;
|
48 |
+
|
49 |
+
bgTiles: TileLayer[];
|
50 |
+
decorTiles: TileLayer[];
|
51 |
+
objectTiles: TileLayer[];
|
52 |
+
bgTilesN: TileLayer[];
|
53 |
+
decorTilesN: TileLayer[];
|
54 |
+
objectTilesN: TileLayer[];
|
55 |
+
animatedSprites: AnimatedSprite[];
|
56 |
+
|
57 |
+
constructor(serialized: SerializedWorldMap) {
|
58 |
+
this.width = serialized.width;
|
59 |
+
this.height = serialized.height;
|
60 |
+
this.tileSetUrl = serialized.tileSetUrl;
|
61 |
+
|
62 |
+
this.tileSetDimX = serialized.tileSetDimX;
|
63 |
+
this.tileSetDimY = serialized.tileSetDimY;
|
64 |
+
this.tileDim = serialized.tileDim;
|
65 |
+
this.bgTiles = serialized.bgTiles;
|
66 |
+
this.decorTiles = serialized.decorTiles;
|
67 |
+
this.objectTiles = serialized.objectTiles;
|
68 |
+
this.bgTilesN = serialized.bgTilesN;
|
69 |
+
this.decorTilesN = serialized.decorTilesN;
|
70 |
+
this.objectTilesN = serialized.objectTilesN;
|
71 |
+
this.animatedSprites = serialized.animatedSprites;
|
72 |
+
}
|
73 |
+
|
74 |
+
serialize(): SerializedWorldMap {
|
75 |
+
return {
|
76 |
+
width: this.width,
|
77 |
+
height: this.height,
|
78 |
+
tileSetUrl: this.tileSetUrl,
|
79 |
+
tileSetDimX: this.tileSetDimX,
|
80 |
+
tileSetDimY: this.tileSetDimY,
|
81 |
+
tileDim: this.tileDim,
|
82 |
+
bgTiles: this.bgTiles,
|
83 |
+
objectTiles: this.objectTiles,
|
84 |
+
decorTiles:this.decorTiles,
|
85 |
+
bgTilesN: this.bgTilesN,
|
86 |
+
objectTilesN: this.objectTilesN,
|
87 |
+
decorTilesN:this.decorTilesN,
|
88 |
+
animatedSprites: this.animatedSprites,
|
89 |
+
};
|
90 |
+
}
|
91 |
+
}
|
|
|
|
|
|
patches/convex/constants.ts
CHANGED
@@ -1,81 +1,90 @@
|
|
1 |
-
|
2 |
-
export const ACTION_TIMEOUT = 60_000
|
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 =
|
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
|
76 |
-
export const
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
export const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 = 3;
|
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 |
+
export const DAY_DURATION = 60000;
|
75 |
+
export const NIGHT_DURATION = 60000;
|
76 |
+
export const WWOLF_VOTE_DURATION = 30000;
|
77 |
+
export const PLAYER_KILL_VOTE_DURATION = 30000;
|
78 |
+
export const LLM_VOTE_DURATION = 60000;
|
79 |
+
|
80 |
+
// Debugging
|
81 |
+
// export const DAY_DURATION = 100;
|
82 |
+
// export const NIGHT_DURATION = 100;
|
83 |
+
// export const WWOLF_VOTE_DURATION = 100;
|
84 |
+
// export const PLAYER_KILL_VOTE_DURATION = 100;
|
85 |
+
// export const LLM_VOTE_DURATION = 100;
|
86 |
+
|
87 |
+
// Bound the number of pathfinding searches we do per game step.
|
88 |
+
export const MAX_PATHFINDS_PER_STEP = 16;
|
89 |
+
|
90 |
+
export const DEFAULT_NAME = 'Me';
|
patches/convex/crons.ts
CHANGED
@@ -1,89 +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 |
-
});
|
|
|
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
CHANGED
@@ -1,200 +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 |
-
|
147 |
-
|
148 |
-
|
149 |
-
|
150 |
-
|
151 |
-
|
152 |
-
|
153 |
-
|
154 |
-
|
155 |
-
|
156 |
-
|
157 |
-
|
158 |
-
|
159 |
-
|
160 |
-
|
161 |
-
|
162 |
-
|
163 |
-
|
164 |
-
|
165 |
-
.
|
166 |
-
|
167 |
-
|
168 |
-
)
|
169 |
-
.
|
170 |
-
|
171 |
-
|
172 |
-
|
173 |
-
|
174 |
-
|
175 |
-
|
176 |
-
|
177 |
-
|
178 |
-
)
|
179 |
-
|
180 |
-
|
181 |
-
engine.currentTime &&
|
182 |
-
update.engine.currentTime
|
183 |
-
|
184 |
-
|
185 |
-
|
186 |
-
|
187 |
-
|
188 |
-
|
189 |
-
|
190 |
-
|
191 |
-
|
192 |
-
|
193 |
-
|
194 |
-
|
195 |
-
|
196 |
-
|
197 |
-
input.
|
198 |
-
|
199 |
-
|
200 |
-
}
|
|
|
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
CHANGED
@@ -1,47 +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 |
-
});
|
|
|
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
CHANGED
@@ -1,355 +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 |
-
}
|
|
|
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
CHANGED
@@ -1,56 +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 |
-
};
|
|
|
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
CHANGED
@@ -1,10 +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;
|
|
|
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
CHANGED
@@ -1,125 +1,128 @@
|
|
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
|
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 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
.
|
48 |
-
.
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
const
|
56 |
-
const
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
},
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
-
worldId,
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
|
111 |
-
if (world
|
112 |
-
|
113 |
-
}
|
114 |
-
|
115 |
-
|
116 |
-
|
117 |
-
|
118 |
-
.
|
119 |
-
.
|
120 |
-
.
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
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 } 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 |
+
type: 'villager',
|
36 |
+
});
|
37 |
+
}
|
38 |
+
}
|
39 |
+
},
|
40 |
+
});
|
41 |
+
export default init;
|
42 |
+
|
43 |
+
async function getOrCreateDefaultWorld(ctx: MutationCtx) {
|
44 |
+
const now = Date.now();
|
45 |
+
|
46 |
+
let worldStatus = await ctx.db
|
47 |
+
.query('worldStatus')
|
48 |
+
.filter((q) => q.eq(q.field('isDefault'), true))
|
49 |
+
.unique();
|
50 |
+
if (worldStatus) {
|
51 |
+
const engine = (await ctx.db.get(worldStatus.engineId))!;
|
52 |
+
return { worldStatus, engine };
|
53 |
+
}
|
54 |
+
|
55 |
+
const engineId = await createEngine(ctx);
|
56 |
+
const engine = (await ctx.db.get(engineId))!;
|
57 |
+
const worldId = await ctx.db.insert('worlds', {
|
58 |
+
nextId: 0,
|
59 |
+
agents: [],
|
60 |
+
conversations: [],
|
61 |
+
players: [],
|
62 |
+
// initialize game cycle counter
|
63 |
+
gameCycle: {
|
64 |
+
currentTime: 0,
|
65 |
+
cycleState: 'Day',
|
66 |
+
cycleIndex: 0,
|
67 |
+
},
|
68 |
+
votes: {
|
69 |
+
votesType: 'KillVotes',
|
70 |
+
votes: [],
|
71 |
+
},
|
72 |
+
});
|
73 |
+
const worldStatusId = await ctx.db.insert('worldStatus', {
|
74 |
+
engineId: engineId,
|
75 |
+
isDefault: true,
|
76 |
+
lastViewed: now,
|
77 |
+
status: 'running',
|
78 |
+
worldId: worldId,
|
79 |
+
});
|
80 |
+
worldStatus = (await ctx.db.get(worldStatusId))!;
|
81 |
+
await ctx.db.insert('maps', {
|
82 |
+
worldId,
|
83 |
+
width: map.mapwidth,
|
84 |
+
height: map.mapheight,
|
85 |
+
tileSetUrl: map.tilesetpath,
|
86 |
+
tileSetDimX: map.tilesetpxw,
|
87 |
+
tileSetDimY: map.tilesetpxh,
|
88 |
+
tileDim: map.tiledim,
|
89 |
+
bgTiles: map.bgtiles,
|
90 |
+
objectTiles: map.objmap,
|
91 |
+
decorTiles: map.decors,
|
92 |
+
bgTilesN: map.bgtilesN,
|
93 |
+
objectTilesN: map.objmapN,
|
94 |
+
decorTilesN: map.decorsN,
|
95 |
+
animatedSprites: map.animatedsprites,
|
96 |
+
});
|
97 |
+
await ctx.scheduler.runAfter(0, internal.aiTown.main.runStep, {
|
98 |
+
worldId,
|
99 |
+
generationNumber: engine.generationNumber,
|
100 |
+
maxDuration: ENGINE_ACTION_DURATION,
|
101 |
+
});
|
102 |
+
return { worldStatus, engine };
|
103 |
+
}
|
104 |
+
|
105 |
+
async function shouldCreateAgents(
|
106 |
+
db: DatabaseReader,
|
107 |
+
worldId: Id<'worlds'>,
|
108 |
+
engineId: Id<'engines'>,
|
109 |
+
) {
|
110 |
+
const world = await db.get(worldId);
|
111 |
+
if (!world) {
|
112 |
+
throw new Error(`Invalid world ID: ${worldId}`);
|
113 |
+
}
|
114 |
+
if (world.agents.length > 0) {
|
115 |
+
return false;
|
116 |
+
}
|
117 |
+
const unactionedJoinInputs = await db
|
118 |
+
.query('inputs')
|
119 |
+
.withIndex('byInputNumber', (q) => q.eq('engineId', engineId))
|
120 |
+
.order('asc')
|
121 |
+
.filter((q) => q.eq(q.field('name'), 'createAgent'))
|
122 |
+
.filter((q) => q.eq(q.field('returnValue'), undefined))
|
123 |
+
.collect();
|
124 |
+
if (unactionedJoinInputs.length > 0) {
|
125 |
+
return false;
|
126 |
+
}
|
127 |
+
return true;
|
128 |
+
}
|
patches/convex/messages.ts
CHANGED
@@ -1,53 +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 |
-
});
|
|
|
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/convex/music.ts
CHANGED
@@ -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 '/ai-town/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
CHANGED
@@ -1,27 +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 |
-
});
|
|
|
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
CHANGED
@@ -1,202 +1,203 @@
|
|
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 |
-
|
146 |
-
|
147 |
-
|
148 |
-
const
|
149 |
-
|
150 |
-
.
|
151 |
-
.
|
152 |
-
|
153 |
-
|
154 |
-
|
155 |
-
|
156 |
-
|
157 |
-
|
158 |
-
|
159 |
-
|
160 |
-
|
161 |
-
|
162 |
-
|
163 |
-
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
}
|
170 |
-
|
171 |
-
|
172 |
-
|
173 |
-
|
174 |
-
|
175 |
-
|
176 |
-
}
|
177 |
-
|
178 |
-
|
179 |
-
|
180 |
-
|
181 |
-
|
182 |
-
|
183 |
-
|
184 |
-
{ content: '
|
185 |
-
|
186 |
-
|
187 |
-
|
188 |
-
}
|
189 |
-
|
190 |
-
|
191 |
-
|
192 |
-
|
193 |
-
|
194 |
-
|
195 |
-
|
196 |
-
'
|
197 |
-
'
|
198 |
-
'p:
|
199 |
-
|
200 |
-
|
201 |
-
|
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 |
+
type: 'villager',
|
141 |
+
});
|
142 |
+
}
|
143 |
+
},
|
144 |
+
});
|
145 |
+
|
146 |
+
export const randomPositions = internalMutation({
|
147 |
+
handler: async (ctx) => {
|
148 |
+
const { worldStatus } = await getDefaultWorld(ctx.db);
|
149 |
+
const map = await ctx.db
|
150 |
+
.query('maps')
|
151 |
+
.withIndex('worldId', (q) => q.eq('worldId', worldStatus.worldId))
|
152 |
+
.unique();
|
153 |
+
if (!map) {
|
154 |
+
throw new Error(`No map for world ${worldStatus.worldId}`);
|
155 |
+
}
|
156 |
+
const world = await ctx.db.get(worldStatus.worldId);
|
157 |
+
if (!world) {
|
158 |
+
throw new Error(`No world for world ${worldStatus.worldId}`);
|
159 |
+
}
|
160 |
+
for (const player of world.players) {
|
161 |
+
await insertInput(ctx, world._id, 'moveTo', {
|
162 |
+
playerId: player.id,
|
163 |
+
destination: {
|
164 |
+
x: 1 + Math.floor(Math.random() * (map.width - 2)),
|
165 |
+
y: 1 + Math.floor(Math.random() * (map.height - 2)),
|
166 |
+
},
|
167 |
+
});
|
168 |
+
}
|
169 |
+
},
|
170 |
+
});
|
171 |
+
|
172 |
+
export const testEmbedding = internalAction({
|
173 |
+
args: { input: v.string() },
|
174 |
+
handler: async (_ctx, args) => {
|
175 |
+
return await fetchEmbedding(args.input);
|
176 |
+
},
|
177 |
+
});
|
178 |
+
|
179 |
+
export const testCompletion = internalAction({
|
180 |
+
args: {},
|
181 |
+
handler: async (ctx, args) => {
|
182 |
+
return await chatCompletion({
|
183 |
+
messages: [
|
184 |
+
{ content: 'You are helpful', role: 'system' },
|
185 |
+
{ content: 'Where is pizza?', role: 'user' },
|
186 |
+
],
|
187 |
+
});
|
188 |
+
},
|
189 |
+
});
|
190 |
+
|
191 |
+
export const testConvo = internalAction({
|
192 |
+
args: {},
|
193 |
+
handler: async (ctx, args) => {
|
194 |
+
const a: any = (await startConversationMessage(
|
195 |
+
ctx,
|
196 |
+
'm1707m46wmefpejw1k50rqz7856qw3ew' as Id<'worlds'>,
|
197 |
+
'c:115' as GameId<'conversations'>,
|
198 |
+
'p:0' as GameId<'players'>,
|
199 |
+
'p:6' as GameId<'players'>,
|
200 |
+
)) as any;
|
201 |
+
return await a.readAll();
|
202 |
+
},
|
203 |
+
});
|
patches/convex/util/FastIntegerCompression.ts
CHANGED
@@ -1,221 +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 |
-
}
|
|
|
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
CHANGED
@@ -1,4 +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 |
-
}
|
|
|
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
CHANGED
@@ -1,15 +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 |
});
|
|
|
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 |
});
|
patches/convex/util/asyncMap.ts
CHANGED
@@ -1,20 +1,20 @@
|
|
1 |
-
/**
|
2 |
-
* asyncMap returns the results of applying an async function over an list.
|
3 |
-
*
|
4 |
-
* @param list - Iterable object of items, e.g. an Array, Set, Object.keys
|
5 |
-
* @param asyncTransform
|
6 |
-
* @returns
|
7 |
-
*/
|
8 |
-
|
9 |
-
export async function asyncMap<FromType, ToType>(
|
10 |
-
list: Iterable<FromType>,
|
11 |
-
asyncTransform: (item: FromType, index: number) => Promise<ToType>,
|
12 |
-
): Promise<ToType[]> {
|
13 |
-
const promises: Promise<ToType>[] = [];
|
14 |
-
let idx = 0;
|
15 |
-
for (const item of list) {
|
16 |
-
promises.push(asyncTransform(item, idx));
|
17 |
-
idx += 1;
|
18 |
-
}
|
19 |
-
return Promise.all(promises);
|
20 |
-
}
|
|
|
1 |
+
/**
|
2 |
+
* asyncMap returns the results of applying an async function over an list.
|
3 |
+
*
|
4 |
+
* @param list - Iterable object of items, e.g. an Array, Set, Object.keys
|
5 |
+
* @param asyncTransform
|
6 |
+
* @returns
|
7 |
+
*/
|
8 |
+
|
9 |
+
export async function asyncMap<FromType, ToType>(
|
10 |
+
list: Iterable<FromType>,
|
11 |
+
asyncTransform: (item: FromType, index: number) => Promise<ToType>,
|
12 |
+
): Promise<ToType[]> {
|
13 |
+
const promises: Promise<ToType>[] = [];
|
14 |
+
let idx = 0;
|
15 |
+
for (const item of list) {
|
16 |
+
promises.push(asyncTransform(item, idx));
|
17 |
+
idx += 1;
|
18 |
+
}
|
19 |
+
return Promise.all(promises);
|
20 |
+
}
|
patches/convex/util/compression.test.ts
CHANGED
@@ -1,90 +1,90 @@
|
|
1 |
-
import {
|
2 |
-
deltaDecode,
|
3 |
-
deltaEncode,
|
4 |
-
quantize,
|
5 |
-
runLengthDecode,
|
6 |
-
runLengthEncode,
|
7 |
-
unquantize,
|
8 |
-
} from './compression';
|
9 |
-
|
10 |
-
describe('compression', () => {
|
11 |
-
test('quantize (approximately) roundtrips', () => {
|
12 |
-
const precisions = [-1, 0, 1, 4, 8];
|
13 |
-
const datasets = [
|
14 |
-
// Random samples from [-2^32, 2^32]
|
15 |
-
[
|
16 |
-
-2331813745.435792, 4165391630.4586916, 2508162414.104561, -3815881222.355323,
|
17 |
-
3182227671.241928, -2091141304.634983, -3454731809.638463, 1539778764.4030657,
|
18 |
-
3723556916.971266, 4014694279.989772, 1165331218.5641785, -4209073662.9696226,
|
19 |
-
-3837962324.440032, 2145014827.7712336, -631662265.4694176, 4116219084.927844,
|
20 |
-
],
|
21 |
-
|
22 |
-
// [-2^16, 2^16]
|
23 |
-
[
|
24 |
-
-29109.399926296363, 24836.163035466132, 59528.43800645282, 5706.0239888604265,
|
25 |
-
61844.35496542655, -46030.9434605508, 10288.243500897894, -48623.38350764701,
|
26 |
-
-62182.09862667126, 20639.535833017246, -7691.974206406943, -44505.52704528734,
|
27 |
-
-28755.644095767944, 38244.45061335398, -14135.607864461621, -14792.956311113172,
|
28 |
-
],
|
29 |
-
|
30 |
-
// [-2^8, 2^8]
|
31 |
-
[
|
32 |
-
-67.02672070745166, -117.41024397385388, -243.41065459675673, 160.3825635900851,
|
33 |
-
191.79026087008378, 89.76668679513216, -10.719096486254784, 205.25021491717217,
|
34 |
-
-68.83096015839055, 44.321620651742364, -203.44266714551503, -19.734642986127426,
|
35 |
-
159.0214530150044, 72.07459707399431, -242.49909539291787, -246.50759645751867,
|
36 |
-
],
|
37 |
-
|
38 |
-
// [-2^4, 2^4]
|
39 |
-
[
|
40 |
-
14.993015665565746, -14.206729228453774, -1.503306544783097, -8.618521795982875,
|
41 |
-
15.14825900944064, -0.7561338814569538, -4.372631369200661, -14.296889398516797,
|
42 |
-
-0.7673738652041102, 5.880288329769968, -0.12246711347653516, 2.6074790469727773,
|
43 |
-
-1.0378494460674226, -5.395209965702431, -0.9218194118035932, -1.8677599340100492,
|
44 |
-
],
|
45 |
-
];
|
46 |
-
for (const values of datasets) {
|
47 |
-
for (const precision of precisions) {
|
48 |
-
const maxError = Math.max(1 / (1 << precision), 1e-8);
|
49 |
-
const roundTripped = unquantize(quantize(values, precision), precision);
|
50 |
-
expect(values.length).toEqual(roundTripped.length);
|
51 |
-
for (let i = 0; i < values.length; i++) {
|
52 |
-
const value = values[i];
|
53 |
-
const roundtrippedValue = roundTripped[i];
|
54 |
-
expect(Math.abs(value - roundtrippedValue)).toBeLessThanOrEqual(maxError);
|
55 |
-
}
|
56 |
-
}
|
57 |
-
}
|
58 |
-
});
|
59 |
-
|
60 |
-
test('delta encode roundtrips', () => {
|
61 |
-
const data = [
|
62 |
-
41476, -13450, -59451, -65102, -32493, -39078, -53884, 40784, 32081, -40422, 43421, 17184,
|
63 |
-
23042, 27548, -61705, -45215, -39037, 61611, -43945, 28001, -64417, -54192, -56325, 24401,
|
64 |
-
17735, 37464, -39842, 54964, 14469, -47248, -39450,
|
65 |
-
];
|
66 |
-
const roundtripped = deltaDecode(deltaEncode(data));
|
67 |
-
expect(data).toEqual(roundtripped);
|
68 |
-
});
|
69 |
-
|
70 |
-
test('run length encode roundtrips', () => {
|
71 |
-
const datasets = [
|
72 |
-
// No repetitions.
|
73 |
-
[
|
74 |
-
41476, -13450, -59451, -65102, -32493, -39078, -53884, 40784, 32081, -40422, 43421, 17184,
|
75 |
-
23042, 27548, -61705, -45215, -39037, 61611, -43945, 28001, -64417, -54192, -56325, 24401,
|
76 |
-
17735, 37464, -39842, 54964, 14469, -47248, -39450,
|
77 |
-
],
|
78 |
-
// All repetitions.
|
79 |
-
[10, 10, 10, 10, 10, 10],
|
80 |
-
// Just one value.
|
81 |
-
[11],
|
82 |
-
// Repetitions in the middle of unique values.
|
83 |
-
[1, 2, 3, 4, 4, 4, 4, 5, 6, 7],
|
84 |
-
];
|
85 |
-
for (const data of datasets) {
|
86 |
-
const roundtripped = runLengthDecode(runLengthEncode(data));
|
87 |
-
expect(data).toEqual(roundtripped);
|
88 |
-
}
|
89 |
-
});
|
90 |
-
});
|
|
|
1 |
+
import {
|
2 |
+
deltaDecode,
|
3 |
+
deltaEncode,
|
4 |
+
quantize,
|
5 |
+
runLengthDecode,
|
6 |
+
runLengthEncode,
|
7 |
+
unquantize,
|
8 |
+
} from './compression';
|
9 |
+
|
10 |
+
describe('compression', () => {
|
11 |
+
test('quantize (approximately) roundtrips', () => {
|
12 |
+
const precisions = [-1, 0, 1, 4, 8];
|
13 |
+
const datasets = [
|
14 |
+
// Random samples from [-2^32, 2^32]
|
15 |
+
[
|
16 |
+
-2331813745.435792, 4165391630.4586916, 2508162414.104561, -3815881222.355323,
|
17 |
+
3182227671.241928, -2091141304.634983, -3454731809.638463, 1539778764.4030657,
|
18 |
+
3723556916.971266, 4014694279.989772, 1165331218.5641785, -4209073662.9696226,
|
19 |
+
-3837962324.440032, 2145014827.7712336, -631662265.4694176, 4116219084.927844,
|
20 |
+
],
|
21 |
+
|
22 |
+
// [-2^16, 2^16]
|
23 |
+
[
|
24 |
+
-29109.399926296363, 24836.163035466132, 59528.43800645282, 5706.0239888604265,
|
25 |
+
61844.35496542655, -46030.9434605508, 10288.243500897894, -48623.38350764701,
|
26 |
+
-62182.09862667126, 20639.535833017246, -7691.974206406943, -44505.52704528734,
|
27 |
+
-28755.644095767944, 38244.45061335398, -14135.607864461621, -14792.956311113172,
|
28 |
+
],
|
29 |
+
|
30 |
+
// [-2^8, 2^8]
|
31 |
+
[
|
32 |
+
-67.02672070745166, -117.41024397385388, -243.41065459675673, 160.3825635900851,
|
33 |
+
191.79026087008378, 89.76668679513216, -10.719096486254784, 205.25021491717217,
|
34 |
+
-68.83096015839055, 44.321620651742364, -203.44266714551503, -19.734642986127426,
|
35 |
+
159.0214530150044, 72.07459707399431, -242.49909539291787, -246.50759645751867,
|
36 |
+
],
|
37 |
+
|
38 |
+
// [-2^4, 2^4]
|
39 |
+
[
|
40 |
+
14.993015665565746, -14.206729228453774, -1.503306544783097, -8.618521795982875,
|
41 |
+
15.14825900944064, -0.7561338814569538, -4.372631369200661, -14.296889398516797,
|
42 |
+
-0.7673738652041102, 5.880288329769968, -0.12246711347653516, 2.6074790469727773,
|
43 |
+
-1.0378494460674226, -5.395209965702431, -0.9218194118035932, -1.8677599340100492,
|
44 |
+
],
|
45 |
+
];
|
46 |
+
for (const values of datasets) {
|
47 |
+
for (const precision of precisions) {
|
48 |
+
const maxError = Math.max(1 / (1 << precision), 1e-8);
|
49 |
+
const roundTripped = unquantize(quantize(values, precision), precision);
|
50 |
+
expect(values.length).toEqual(roundTripped.length);
|
51 |
+
for (let i = 0; i < values.length; i++) {
|
52 |
+
const value = values[i];
|
53 |
+
const roundtrippedValue = roundTripped[i];
|
54 |
+
expect(Math.abs(value - roundtrippedValue)).toBeLessThanOrEqual(maxError);
|
55 |
+
}
|
56 |
+
}
|
57 |
+
}
|
58 |
+
});
|
59 |
+
|
60 |
+
test('delta encode roundtrips', () => {
|
61 |
+
const data = [
|
62 |
+
41476, -13450, -59451, -65102, -32493, -39078, -53884, 40784, 32081, -40422, 43421, 17184,
|
63 |
+
23042, 27548, -61705, -45215, -39037, 61611, -43945, 28001, -64417, -54192, -56325, 24401,
|
64 |
+
17735, 37464, -39842, 54964, 14469, -47248, -39450,
|
65 |
+
];
|
66 |
+
const roundtripped = deltaDecode(deltaEncode(data));
|
67 |
+
expect(data).toEqual(roundtripped);
|
68 |
+
});
|
69 |
+
|
70 |
+
test('run length encode roundtrips', () => {
|
71 |
+
const datasets = [
|
72 |
+
// No repetitions.
|
73 |
+
[
|
74 |
+
41476, -13450, -59451, -65102, -32493, -39078, -53884, 40784, 32081, -40422, 43421, 17184,
|
75 |
+
23042, 27548, -61705, -45215, -39037, 61611, -43945, 28001, -64417, -54192, -56325, 24401,
|
76 |
+
17735, 37464, -39842, 54964, 14469, -47248, -39450,
|
77 |
+
],
|
78 |
+
// All repetitions.
|
79 |
+
[10, 10, 10, 10, 10, 10],
|
80 |
+
// Just one value.
|
81 |
+
[11],
|
82 |
+
// Repetitions in the middle of unique values.
|
83 |
+
[1, 2, 3, 4, 4, 4, 4, 5, 6, 7],
|
84 |
+
];
|
85 |
+
for (const data of datasets) {
|
86 |
+
const roundtripped = runLengthDecode(runLengthEncode(data));
|
87 |
+
expect(data).toEqual(roundtripped);
|
88 |
+
}
|
89 |
+
});
|
90 |
+
});
|
patches/convex/util/compression.ts
CHANGED
@@ -1,71 +1,71 @@
|
|
1 |
-
export function quantize(values: number[], precision: number) {
|
2 |
-
const factor = 1 << precision;
|
3 |
-
return values.map((v) => Math.floor(v * factor));
|
4 |
-
}
|
5 |
-
|
6 |
-
export function unquantize(quantized: number[], precision: number) {
|
7 |
-
const reciprocal = 1 / (1 << precision);
|
8 |
-
return quantized.map((q) => q * reciprocal);
|
9 |
-
}
|
10 |
-
|
11 |
-
export function deltaEncode(values: number[], initialValue = 0) {
|
12 |
-
let prev = initialValue;
|
13 |
-
const deltas = [];
|
14 |
-
for (const value of values) {
|
15 |
-
deltas.push(value - prev);
|
16 |
-
prev = value;
|
17 |
-
}
|
18 |
-
return deltas;
|
19 |
-
}
|
20 |
-
|
21 |
-
export function deltaDecode(deltas: number[], initialValue = 0) {
|
22 |
-
let prev = initialValue;
|
23 |
-
const values = [];
|
24 |
-
for (const delta of deltas) {
|
25 |
-
const value = prev + delta;
|
26 |
-
values.push(value);
|
27 |
-
prev = value;
|
28 |
-
}
|
29 |
-
return values;
|
30 |
-
}
|
31 |
-
|
32 |
-
export function runLengthEncode(values: number[]) {
|
33 |
-
let hasPrevious = false;
|
34 |
-
let previous = 0;
|
35 |
-
let count = 0;
|
36 |
-
const encoded = [];
|
37 |
-
for (const value of values) {
|
38 |
-
if (!hasPrevious) {
|
39 |
-
previous = value;
|
40 |
-
count = 1;
|
41 |
-
hasPrevious = true;
|
42 |
-
continue;
|
43 |
-
}
|
44 |
-
if (previous === value) {
|
45 |
-
count += 1;
|
46 |
-
continue;
|
47 |
-
}
|
48 |
-
encoded.push(previous, count);
|
49 |
-
previous = value;
|
50 |
-
count = 1;
|
51 |
-
}
|
52 |
-
if (hasPrevious) {
|
53 |
-
encoded.push(previous, count);
|
54 |
-
}
|
55 |
-
return encoded;
|
56 |
-
}
|
57 |
-
|
58 |
-
export function runLengthDecode(encoded: number[]) {
|
59 |
-
if (encoded.length % 2 !== 0) {
|
60 |
-
throw new Error(`Invalid RLE encoded length: ${encoded.length}`);
|
61 |
-
}
|
62 |
-
const values = [];
|
63 |
-
for (let i = 0; i < encoded.length; i += 2) {
|
64 |
-
const value = encoded[i];
|
65 |
-
const count = encoded[i + 1];
|
66 |
-
for (let j = 0; j < count; j++) {
|
67 |
-
values.push(value);
|
68 |
-
}
|
69 |
-
}
|
70 |
-
return values;
|
71 |
-
}
|
|
|
1 |
+
export function quantize(values: number[], precision: number) {
|
2 |
+
const factor = 1 << precision;
|
3 |
+
return values.map((v) => Math.floor(v * factor));
|
4 |
+
}
|
5 |
+
|
6 |
+
export function unquantize(quantized: number[], precision: number) {
|
7 |
+
const reciprocal = 1 / (1 << precision);
|
8 |
+
return quantized.map((q) => q * reciprocal);
|
9 |
+
}
|
10 |
+
|
11 |
+
export function deltaEncode(values: number[], initialValue = 0) {
|
12 |
+
let prev = initialValue;
|
13 |
+
const deltas = [];
|
14 |
+
for (const value of values) {
|
15 |
+
deltas.push(value - prev);
|
16 |
+
prev = value;
|
17 |
+
}
|
18 |
+
return deltas;
|
19 |
+
}
|
20 |
+
|
21 |
+
export function deltaDecode(deltas: number[], initialValue = 0) {
|
22 |
+
let prev = initialValue;
|
23 |
+
const values = [];
|
24 |
+
for (const delta of deltas) {
|
25 |
+
const value = prev + delta;
|
26 |
+
values.push(value);
|
27 |
+
prev = value;
|
28 |
+
}
|
29 |
+
return values;
|
30 |
+
}
|
31 |
+
|
32 |
+
export function runLengthEncode(values: number[]) {
|
33 |
+
let hasPrevious = false;
|
34 |
+
let previous = 0;
|
35 |
+
let count = 0;
|
36 |
+
const encoded = [];
|
37 |
+
for (const value of values) {
|
38 |
+
if (!hasPrevious) {
|
39 |
+
previous = value;
|
40 |
+
count = 1;
|
41 |
+
hasPrevious = true;
|
42 |
+
continue;
|
43 |
+
}
|
44 |
+
if (previous === value) {
|
45 |
+
count += 1;
|
46 |
+
continue;
|
47 |
+
}
|
48 |
+
encoded.push(previous, count);
|
49 |
+
previous = value;
|
50 |
+
count = 1;
|
51 |
+
}
|
52 |
+
if (hasPrevious) {
|
53 |
+
encoded.push(previous, count);
|
54 |
+
}
|
55 |
+
return encoded;
|
56 |
+
}
|
57 |
+
|
58 |
+
export function runLengthDecode(encoded: number[]) {
|
59 |
+
if (encoded.length % 2 !== 0) {
|
60 |
+
throw new Error(`Invalid RLE encoded length: ${encoded.length}`);
|
61 |
+
}
|
62 |
+
const values = [];
|
63 |
+
for (let i = 0; i < encoded.length; i += 2) {
|
64 |
+
const value = encoded[i];
|
65 |
+
const count = encoded[i + 1];
|
66 |
+
for (let j = 0; j < count; j++) {
|
67 |
+
values.push(value);
|
68 |
+
}
|
69 |
+
}
|
70 |
+
return values;
|
71 |
+
}
|
patches/convex/util/geometry.test.ts
CHANGED
@@ -1,298 +1,298 @@
|
|
1 |
-
import { compressPath, distance, manhattanDistance, normalize, orientationDegrees, pathOverlaps, pathPosition, pointsEqual, vector, vectorLength } from './geometry';
|
2 |
-
import { Path, Vector } from './types';
|
3 |
-
|
4 |
-
describe('distance', () => {
|
5 |
-
test('should return the correct distance for two points', () => {
|
6 |
-
const p0 = { x: 0, y: 0 };
|
7 |
-
const p1 = { x: 3, y: 4 };
|
8 |
-
const expectedDistance = 5;
|
9 |
-
|
10 |
-
const actualDistance = distance(p0, p1);
|
11 |
-
|
12 |
-
expect(actualDistance).toBe(expectedDistance);
|
13 |
-
});
|
14 |
-
|
15 |
-
test('should return 0 for the same point', () => {
|
16 |
-
const p0 = { x: 1, y: 2 };
|
17 |
-
const expectedDistance = 0;
|
18 |
-
|
19 |
-
const actualDistance = distance(p0, p0);
|
20 |
-
|
21 |
-
expect(actualDistance).toBe(expectedDistance);
|
22 |
-
});
|
23 |
-
|
24 |
-
test('should return the correct distance for negative points', () => {
|
25 |
-
const p0 = { x: -2, y: -3 };
|
26 |
-
const p1 = { x: 1, y: 2 };
|
27 |
-
const expectedDistance = 5.83;
|
28 |
-
|
29 |
-
const actualDistance = distance(p0, p1);
|
30 |
-
|
31 |
-
expect(actualDistance).toBeCloseTo(expectedDistance);
|
32 |
-
});
|
33 |
-
});
|
34 |
-
|
35 |
-
describe('pointsEqual', () => {
|
36 |
-
test('should return true for identical points', () => {
|
37 |
-
const p0 = { x: 1, y: 2 };
|
38 |
-
const p1 = { x: 1, y: 2 };
|
39 |
-
expect(pointsEqual(p0, p1)).toBe(true);
|
40 |
-
});
|
41 |
-
|
42 |
-
test('should return false for non-idential points', () => {
|
43 |
-
const p0 = { x: 3, y: 2 };
|
44 |
-
const p1 = { x: 5, y: 3 };
|
45 |
-
expect(pointsEqual(p0, p1)).toBe(false);
|
46 |
-
});
|
47 |
-
|
48 |
-
test('should return false for different x coordinates', () => {
|
49 |
-
const p0 = { x: 1, y: 2 };
|
50 |
-
const p1 = { x: 2, y: 2 };
|
51 |
-
expect(pointsEqual(p0, p1)).toBe(false);
|
52 |
-
});
|
53 |
-
|
54 |
-
test('should return false for different y coordinates', () => {
|
55 |
-
const p0 = { x: 1, y: 2 };
|
56 |
-
const p1 = { x: 1, y: 3 };
|
57 |
-
expect(pointsEqual(p0, p1)).toBe(false);
|
58 |
-
});
|
59 |
-
});
|
60 |
-
|
61 |
-
describe("manhattanDistance", () => {
|
62 |
-
test("should return correct distance for points on the same axis", () => {
|
63 |
-
const p0 = { x: 1, y: 0 };
|
64 |
-
const p1 = { x: 1, y: 2 };
|
65 |
-
expect(manhattanDistance(p0, p1)).toBe(2);
|
66 |
-
});
|
67 |
-
|
68 |
-
test("should return correct distance for points on different axes", () => {
|
69 |
-
const p0 = { x: 1, y: 0 };
|
70 |
-
const p1 = { x: 3, y: 2 };
|
71 |
-
expect(manhattanDistance(p0, p1)).toBe(4);
|
72 |
-
});
|
73 |
-
|
74 |
-
test("should return correct distance for negative points", () => {
|
75 |
-
const p0 = { x: -2, y: 0 };
|
76 |
-
const p1 = { x: 1, y: -2 };
|
77 |
-
expect(manhattanDistance(p0, p1)).toBe(5);
|
78 |
-
});
|
79 |
-
|
80 |
-
test("should return correct distance for identical points", () => {
|
81 |
-
const p0 = { x: 1, y: 2 };
|
82 |
-
const p1 = { x: 1, y: 2 };
|
83 |
-
expect(manhattanDistance(p0, p1)).toBe(0);
|
84 |
-
});
|
85 |
-
});
|
86 |
-
|
87 |
-
describe('pathOverlaps', () => {
|
88 |
-
test('should throw an error if the path does not have 2 entries', () => {
|
89 |
-
const path: Path = [
|
90 |
-
[0, 0, 0, 1, 0]
|
91 |
-
];
|
92 |
-
const time = 0;
|
93 |
-
expect(() => pathOverlaps(path, time)).toThrowError('Invalid path: [[0,0,0,1,0]]');
|
94 |
-
});
|
95 |
-
|
96 |
-
test('should return true if the time is within the path', () => {
|
97 |
-
const path: Path = [
|
98 |
-
[0, 0, 0, 1, 1],
|
99 |
-
[0, 2, 0, 1, 2]
|
100 |
-
];
|
101 |
-
const time = 1.5;
|
102 |
-
expect(pathOverlaps(path, time)).toBe(true);
|
103 |
-
});
|
104 |
-
|
105 |
-
test('should return false if the time is before the start of the path', () => {
|
106 |
-
const path: Path = [
|
107 |
-
[0, 0, 0, 1, 1],
|
108 |
-
[0, 2, 0, 1, 2]
|
109 |
-
];
|
110 |
-
const time = 0.5;
|
111 |
-
expect(pathOverlaps(path, time)).toBe(false);
|
112 |
-
});
|
113 |
-
|
114 |
-
test('should return false if the time is after the end of the path', () => {
|
115 |
-
const path: Path = [
|
116 |
-
[0, 0, 0, 1, 1],
|
117 |
-
[0, 2, 0, 1, 2]
|
118 |
-
];
|
119 |
-
const time = 2.5;
|
120 |
-
expect(pathOverlaps(path, time)).toBe(false);
|
121 |
-
});
|
122 |
-
});
|
123 |
-
|
124 |
-
describe('pathPosition', () => {
|
125 |
-
test('should throw an error if the path does not have 2 entries', () => {
|
126 |
-
const path: Path = [
|
127 |
-
[0, 0, 0, 1, 0]
|
128 |
-
];
|
129 |
-
const time = 0;
|
130 |
-
expect(() => pathPosition(path, time)).toThrowError('Invalid path: [[0,0,0,1,0]]');
|
131 |
-
});
|
132 |
-
|
133 |
-
test('returns the first point when time is less than the start time', () => {
|
134 |
-
const path: Path = [
|
135 |
-
[1, 2, 3, 4, 2],
|
136 |
-
[5, 6, 3, 4, 3]
|
137 |
-
];
|
138 |
-
|
139 |
-
const result = pathPosition(path, 1);
|
140 |
-
|
141 |
-
expect(result.position).toEqual({ x: 1, y: 2 });
|
142 |
-
expect(result.facing).toEqual({ dx: 3, dy: 4 });
|
143 |
-
expect(result.velocity).toBe(0);
|
144 |
-
});
|
145 |
-
|
146 |
-
test('returns the last point when time is greater than the end time', () => {
|
147 |
-
const path: Path = [
|
148 |
-
[1, 2, 3, 4, 2],
|
149 |
-
[5, 6, 3, 4, 3]
|
150 |
-
];
|
151 |
-
|
152 |
-
const result = pathPosition(path, 4);
|
153 |
-
|
154 |
-
expect(result.position).toEqual({ x: 5, y: 6 });
|
155 |
-
expect(result.facing).toEqual({ dx: 3, dy: 4 });
|
156 |
-
expect(result.velocity).toBe(0);
|
157 |
-
});
|
158 |
-
|
159 |
-
test('returns the interpolated point for time between two segments', () => {
|
160 |
-
const path: Path = [
|
161 |
-
[1, 2, 7, 8, 2],
|
162 |
-
[5, 6, 7, 8, 3],
|
163 |
-
[10, 11, 7, 8, 4],
|
164 |
-
[14, 15, 7, 8, 5]
|
165 |
-
];
|
166 |
-
|
167 |
-
const result = pathPosition(path, 4.5);
|
168 |
-
|
169 |
-
expect(result.position).toEqual({ x: 12, y: 13 });
|
170 |
-
expect(result.facing).toEqual({ dx: 7, dy: 8 });
|
171 |
-
expect(result.velocity).toBeCloseTo(5.657);
|
172 |
-
});
|
173 |
-
});
|
174 |
-
|
175 |
-
describe('vector', () => {
|
176 |
-
test('should return a vector with dx = 1 and dy = 2', () => {
|
177 |
-
const p0 = { x: 1, y: 2 };
|
178 |
-
const p1 = { x: 2, y: 4 };
|
179 |
-
const expected = { dx: 1, dy: 2 };
|
180 |
-
const actual = vector(p0, p1);
|
181 |
-
expect(actual).toEqual(expected);
|
182 |
-
});
|
183 |
-
|
184 |
-
test('should return a vector with dx = 0 and dy = 0', () => {
|
185 |
-
const p0 = { x: 1, y: 2 };
|
186 |
-
const p1 = { x: 1, y: 2 };
|
187 |
-
const expected = { dx: 0, dy: 0 };
|
188 |
-
const actual = vector(p0, p1);
|
189 |
-
expect(actual).toEqual(expected);
|
190 |
-
});
|
191 |
-
|
192 |
-
test('should return a vector with dx = 0 and dy = -1', () => {
|
193 |
-
const p0 = { x: 1, y: 2 };
|
194 |
-
const p1 = { x: 1, y: 1 };
|
195 |
-
const expected = { dx: 0, dy: -1 };
|
196 |
-
const actual = vector(p0, p1);
|
197 |
-
expect(actual).toEqual(expected);
|
198 |
-
});
|
199 |
-
});
|
200 |
-
|
201 |
-
describe('vectorLength', () => {
|
202 |
-
test('returns the correct length for a vector', () => {
|
203 |
-
const vector: Vector = { dx: 3.14, dy: 4 };
|
204 |
-
expect(vectorLength(vector)).toBeCloseTo(5.09);
|
205 |
-
});
|
206 |
-
|
207 |
-
test('returns the correct length for a vector with negative components', () => {
|
208 |
-
const vector: Vector = { dx: -3, dy: -4 };
|
209 |
-
expect(vectorLength(vector)).toBeCloseTo(5);
|
210 |
-
});
|
211 |
-
|
212 |
-
test('returns the correct length for a vector with zero components', () => {
|
213 |
-
const vector: Vector = { dx: 0, dy: 0 };
|
214 |
-
expect(vectorLength(vector)).toBeCloseTo(0);
|
215 |
-
});
|
216 |
-
});
|
217 |
-
|
218 |
-
describe('normalize', () => {
|
219 |
-
test('should return null for vector length less than EPSILON', () => {
|
220 |
-
const vector: Vector = { dx: 0, dy: 0 };
|
221 |
-
const result = normalize(vector);
|
222 |
-
expect(result).toBeNull();
|
223 |
-
});
|
224 |
-
|
225 |
-
test('should return a normalized vector', () => {
|
226 |
-
const vector: Vector = { dx: 3, dy: 4 };
|
227 |
-
const result = normalize(vector);
|
228 |
-
expect(result).toEqual({ dx: 0.6, dy: 0.8 });
|
229 |
-
});
|
230 |
-
});
|
231 |
-
|
232 |
-
describe('orientationDegrees', () => {
|
233 |
-
test('should throw an error for a vector length smaller than EPSILON', () => {
|
234 |
-
expect(() => orientationDegrees({ dx: 0, dy: 0 })).toThrowError("Can't compute the orientation of too small vector {\"dx\":0,\"dy\":0}");
|
235 |
-
});
|
236 |
-
test('should return 0 for a vector pointing to the right', () => {
|
237 |
-
expect(orientationDegrees({ dx: 1, dy: 0 })).toBe(0);
|
238 |
-
});
|
239 |
-
|
240 |
-
test('should return 90 for a vector pointing up', () => {
|
241 |
-
expect(orientationDegrees({ dx: 0, dy: 1 })).toBe(90);
|
242 |
-
});
|
243 |
-
|
244 |
-
test('should return 180 for a vector pointing to the left', () => {
|
245 |
-
expect(orientationDegrees({ dx: -1, dy: 0 })).toBe(180);
|
246 |
-
});
|
247 |
-
|
248 |
-
test('should return 270 for a vector pointing down', () => {
|
249 |
-
expect(orientationDegrees({ dx: 0, dy: -1 })).toBe(270);
|
250 |
-
});
|
251 |
-
});
|
252 |
-
|
253 |
-
|
254 |
-
describe('compressPath', () => {
|
255 |
-
test('should not compress a path with only 2 entries', () => {
|
256 |
-
const facing = { dx: 0, dy: 1 };
|
257 |
-
const compressed = compressPath([
|
258 |
-
{ position: { x: 0, y: 0 }, facing, t: 0 },
|
259 |
-
{ position: { x: 0, y: 1 }, facing, t: 1 },
|
260 |
-
]);
|
261 |
-
expect(compressed).toEqual([
|
262 |
-
[0, 0, 0, 1, 0],
|
263 |
-
[0, 1, 0, 1, 1],
|
264 |
-
]);
|
265 |
-
});
|
266 |
-
|
267 |
-
test('should compress a line', () => {
|
268 |
-
const facing = { dx: 0, dy: 1 };
|
269 |
-
const compressed = compressPath([
|
270 |
-
{ position: { x: 0, y: 0 }, facing, t: 0 },
|
271 |
-
{ position: { x: 0, y: 1 }, facing, t: 1 },
|
272 |
-
{ position: { x: 0, y: 2 }, facing, t: 2 },
|
273 |
-
{ position: { x: 0, y: 3 }, facing, t: 3 },
|
274 |
-
{ position: { x: 0, y: 4 }, facing, t: 4 },
|
275 |
-
]);
|
276 |
-
expect(compressed).toEqual([
|
277 |
-
[0, 0, 0, 1, 0],
|
278 |
-
[0, 4, 0, 1, 4],
|
279 |
-
]);
|
280 |
-
});
|
281 |
-
|
282 |
-
test('should compress a line with a turn', () => {
|
283 |
-
const facingUp = { dx: 0, dy: 1 };
|
284 |
-
const facingRight = { dx: 1, dy: 0 };
|
285 |
-
const compressed = compressPath([
|
286 |
-
{ position: { x: 0, y: 0 }, facing: facingUp, t: 0 },
|
287 |
-
{ position: { x: 0, y: 1 }, facing: facingUp, t: 1 },
|
288 |
-
{ position: { x: 0, y: 2 }, facing: facingRight, t: 2 },
|
289 |
-
{ position: { x: 1, y: 2 }, facing: facingRight, t: 3 },
|
290 |
-
{ position: { x: 2, y: 2 }, facing: facingRight, t: 4 },
|
291 |
-
]);
|
292 |
-
expect(compressed).toEqual([
|
293 |
-
[0, 0, 0, 1, 0],
|
294 |
-
[0, 2, 1, 0, 2],
|
295 |
-
[2, 2, 1, 0, 4],
|
296 |
-
]);
|
297 |
-
});
|
298 |
-
});
|
|
|
1 |
+
import { compressPath, distance, manhattanDistance, normalize, orientationDegrees, pathOverlaps, pathPosition, pointsEqual, vector, vectorLength } from './geometry';
|
2 |
+
import { Path, Vector } from './types';
|
3 |
+
|
4 |
+
describe('distance', () => {
|
5 |
+
test('should return the correct distance for two points', () => {
|
6 |
+
const p0 = { x: 0, y: 0 };
|
7 |
+
const p1 = { x: 3, y: 4 };
|
8 |
+
const expectedDistance = 5;
|
9 |
+
|
10 |
+
const actualDistance = distance(p0, p1);
|
11 |
+
|
12 |
+
expect(actualDistance).toBe(expectedDistance);
|
13 |
+
});
|
14 |
+
|
15 |
+
test('should return 0 for the same point', () => {
|
16 |
+
const p0 = { x: 1, y: 2 };
|
17 |
+
const expectedDistance = 0;
|
18 |
+
|
19 |
+
const actualDistance = distance(p0, p0);
|
20 |
+
|
21 |
+
expect(actualDistance).toBe(expectedDistance);
|
22 |
+
});
|
23 |
+
|
24 |
+
test('should return the correct distance for negative points', () => {
|
25 |
+
const p0 = { x: -2, y: -3 };
|
26 |
+
const p1 = { x: 1, y: 2 };
|
27 |
+
const expectedDistance = 5.83;
|
28 |
+
|
29 |
+
const actualDistance = distance(p0, p1);
|
30 |
+
|
31 |
+
expect(actualDistance).toBeCloseTo(expectedDistance);
|
32 |
+
});
|
33 |
+
});
|
34 |
+
|
35 |
+
describe('pointsEqual', () => {
|
36 |
+
test('should return true for identical points', () => {
|
37 |
+
const p0 = { x: 1, y: 2 };
|
38 |
+
const p1 = { x: 1, y: 2 };
|
39 |
+
expect(pointsEqual(p0, p1)).toBe(true);
|
40 |
+
});
|
41 |
+
|
42 |
+
test('should return false for non-idential points', () => {
|
43 |
+
const p0 = { x: 3, y: 2 };
|
44 |
+
const p1 = { x: 5, y: 3 };
|
45 |
+
expect(pointsEqual(p0, p1)).toBe(false);
|
46 |
+
});
|
47 |
+
|
48 |
+
test('should return false for different x coordinates', () => {
|
49 |
+
const p0 = { x: 1, y: 2 };
|
50 |
+
const p1 = { x: 2, y: 2 };
|
51 |
+
expect(pointsEqual(p0, p1)).toBe(false);
|
52 |
+
});
|
53 |
+
|
54 |
+
test('should return false for different y coordinates', () => {
|
55 |
+
const p0 = { x: 1, y: 2 };
|
56 |
+
const p1 = { x: 1, y: 3 };
|
57 |
+
expect(pointsEqual(p0, p1)).toBe(false);
|
58 |
+
});
|
59 |
+
});
|
60 |
+
|
61 |
+
describe("manhattanDistance", () => {
|
62 |
+
test("should return correct distance for points on the same axis", () => {
|
63 |
+
const p0 = { x: 1, y: 0 };
|
64 |
+
const p1 = { x: 1, y: 2 };
|
65 |
+
expect(manhattanDistance(p0, p1)).toBe(2);
|
66 |
+
});
|
67 |
+
|
68 |
+
test("should return correct distance for points on different axes", () => {
|
69 |
+
const p0 = { x: 1, y: 0 };
|
70 |
+
const p1 = { x: 3, y: 2 };
|
71 |
+
expect(manhattanDistance(p0, p1)).toBe(4);
|
72 |
+
});
|
73 |
+
|
74 |
+
test("should return correct distance for negative points", () => {
|
75 |
+
const p0 = { x: -2, y: 0 };
|
76 |
+
const p1 = { x: 1, y: -2 };
|
77 |
+
expect(manhattanDistance(p0, p1)).toBe(5);
|
78 |
+
});
|
79 |
+
|
80 |
+
test("should return correct distance for identical points", () => {
|
81 |
+
const p0 = { x: 1, y: 2 };
|
82 |
+
const p1 = { x: 1, y: 2 };
|
83 |
+
expect(manhattanDistance(p0, p1)).toBe(0);
|
84 |
+
});
|
85 |
+
});
|
86 |
+
|
87 |
+
describe('pathOverlaps', () => {
|
88 |
+
test('should throw an error if the path does not have 2 entries', () => {
|
89 |
+
const path: Path = [
|
90 |
+
[0, 0, 0, 1, 0]
|
91 |
+
];
|
92 |
+
const time = 0;
|
93 |
+
expect(() => pathOverlaps(path, time)).toThrowError('Invalid path: [[0,0,0,1,0]]');
|
94 |
+
});
|
95 |
+
|
96 |
+
test('should return true if the time is within the path', () => {
|
97 |
+
const path: Path = [
|
98 |
+
[0, 0, 0, 1, 1],
|
99 |
+
[0, 2, 0, 1, 2]
|
100 |
+
];
|
101 |
+
const time = 1.5;
|
102 |
+
expect(pathOverlaps(path, time)).toBe(true);
|
103 |
+
});
|
104 |
+
|
105 |
+
test('should return false if the time is before the start of the path', () => {
|
106 |
+
const path: Path = [
|
107 |
+
[0, 0, 0, 1, 1],
|
108 |
+
[0, 2, 0, 1, 2]
|
109 |
+
];
|
110 |
+
const time = 0.5;
|
111 |
+
expect(pathOverlaps(path, time)).toBe(false);
|
112 |
+
});
|
113 |
+
|
114 |
+
test('should return false if the time is after the end of the path', () => {
|
115 |
+
const path: Path = [
|
116 |
+
[0, 0, 0, 1, 1],
|
117 |
+
[0, 2, 0, 1, 2]
|
118 |
+
];
|
119 |
+
const time = 2.5;
|
120 |
+
expect(pathOverlaps(path, time)).toBe(false);
|
121 |
+
});
|
122 |
+
});
|
123 |
+
|
124 |
+
describe('pathPosition', () => {
|
125 |
+
test('should throw an error if the path does not have 2 entries', () => {
|
126 |
+
const path: Path = [
|
127 |
+
[0, 0, 0, 1, 0]
|
128 |
+
];
|
129 |
+
const time = 0;
|
130 |
+
expect(() => pathPosition(path, time)).toThrowError('Invalid path: [[0,0,0,1,0]]');
|
131 |
+
});
|
132 |
+
|
133 |
+
test('returns the first point when time is less than the start time', () => {
|
134 |
+
const path: Path = [
|
135 |
+
[1, 2, 3, 4, 2],
|
136 |
+
[5, 6, 3, 4, 3]
|
137 |
+
];
|
138 |
+
|
139 |
+
const result = pathPosition(path, 1);
|
140 |
+
|
141 |
+
expect(result.position).toEqual({ x: 1, y: 2 });
|
142 |
+
expect(result.facing).toEqual({ dx: 3, dy: 4 });
|
143 |
+
expect(result.velocity).toBe(0);
|
144 |
+
});
|
145 |
+
|
146 |
+
test('returns the last point when time is greater than the end time', () => {
|
147 |
+
const path: Path = [
|
148 |
+
[1, 2, 3, 4, 2],
|
149 |
+
[5, 6, 3, 4, 3]
|
150 |
+
];
|
151 |
+
|
152 |
+
const result = pathPosition(path, 4);
|
153 |
+
|
154 |
+
expect(result.position).toEqual({ x: 5, y: 6 });
|
155 |
+
expect(result.facing).toEqual({ dx: 3, dy: 4 });
|
156 |
+
expect(result.velocity).toBe(0);
|
157 |
+
});
|
158 |
+
|
159 |
+
test('returns the interpolated point for time between two segments', () => {
|
160 |
+
const path: Path = [
|
161 |
+
[1, 2, 7, 8, 2],
|
162 |
+
[5, 6, 7, 8, 3],
|
163 |
+
[10, 11, 7, 8, 4],
|
164 |
+
[14, 15, 7, 8, 5]
|
165 |
+
];
|
166 |
+
|
167 |
+
const result = pathPosition(path, 4.5);
|
168 |
+
|
169 |
+
expect(result.position).toEqual({ x: 12, y: 13 });
|
170 |
+
expect(result.facing).toEqual({ dx: 7, dy: 8 });
|
171 |
+
expect(result.velocity).toBeCloseTo(5.657);
|
172 |
+
});
|
173 |
+
});
|
174 |
+
|
175 |
+
describe('vector', () => {
|
176 |
+
test('should return a vector with dx = 1 and dy = 2', () => {
|
177 |
+
const p0 = { x: 1, y: 2 };
|
178 |
+
const p1 = { x: 2, y: 4 };
|
179 |
+
const expected = { dx: 1, dy: 2 };
|
180 |
+
const actual = vector(p0, p1);
|
181 |
+
expect(actual).toEqual(expected);
|
182 |
+
});
|
183 |
+
|
184 |
+
test('should return a vector with dx = 0 and dy = 0', () => {
|
185 |
+
const p0 = { x: 1, y: 2 };
|
186 |
+
const p1 = { x: 1, y: 2 };
|
187 |
+
const expected = { dx: 0, dy: 0 };
|
188 |
+
const actual = vector(p0, p1);
|
189 |
+
expect(actual).toEqual(expected);
|
190 |
+
});
|
191 |
+
|
192 |
+
test('should return a vector with dx = 0 and dy = -1', () => {
|
193 |
+
const p0 = { x: 1, y: 2 };
|
194 |
+
const p1 = { x: 1, y: 1 };
|
195 |
+
const expected = { dx: 0, dy: -1 };
|
196 |
+
const actual = vector(p0, p1);
|
197 |
+
expect(actual).toEqual(expected);
|
198 |
+
});
|
199 |
+
});
|
200 |
+
|
201 |
+
describe('vectorLength', () => {
|
202 |
+
test('returns the correct length for a vector', () => {
|
203 |
+
const vector: Vector = { dx: 3.14, dy: 4 };
|
204 |
+
expect(vectorLength(vector)).toBeCloseTo(5.09);
|
205 |
+
});
|
206 |
+
|
207 |
+
test('returns the correct length for a vector with negative components', () => {
|
208 |
+
const vector: Vector = { dx: -3, dy: -4 };
|
209 |
+
expect(vectorLength(vector)).toBeCloseTo(5);
|
210 |
+
});
|
211 |
+
|
212 |
+
test('returns the correct length for a vector with zero components', () => {
|
213 |
+
const vector: Vector = { dx: 0, dy: 0 };
|
214 |
+
expect(vectorLength(vector)).toBeCloseTo(0);
|
215 |
+
});
|
216 |
+
});
|
217 |
+
|
218 |
+
describe('normalize', () => {
|
219 |
+
test('should return null for vector length less than EPSILON', () => {
|
220 |
+
const vector: Vector = { dx: 0, dy: 0 };
|
221 |
+
const result = normalize(vector);
|
222 |
+
expect(result).toBeNull();
|
223 |
+
});
|
224 |
+
|
225 |
+
test('should return a normalized vector', () => {
|
226 |
+
const vector: Vector = { dx: 3, dy: 4 };
|
227 |
+
const result = normalize(vector);
|
228 |
+
expect(result).toEqual({ dx: 0.6, dy: 0.8 });
|
229 |
+
});
|
230 |
+
});
|
231 |
+
|
232 |
+
describe('orientationDegrees', () => {
|
233 |
+
test('should throw an error for a vector length smaller than EPSILON', () => {
|
234 |
+
expect(() => orientationDegrees({ dx: 0, dy: 0 })).toThrowError("Can't compute the orientation of too small vector {\"dx\":0,\"dy\":0}");
|
235 |
+
});
|
236 |
+
test('should return 0 for a vector pointing to the right', () => {
|
237 |
+
expect(orientationDegrees({ dx: 1, dy: 0 })).toBe(0);
|
238 |
+
});
|
239 |
+
|
240 |
+
test('should return 90 for a vector pointing up', () => {
|
241 |
+
expect(orientationDegrees({ dx: 0, dy: 1 })).toBe(90);
|
242 |
+
});
|
243 |
+
|
244 |
+
test('should return 180 for a vector pointing to the left', () => {
|
245 |
+
expect(orientationDegrees({ dx: -1, dy: 0 })).toBe(180);
|
246 |
+
});
|
247 |
+
|
248 |
+
test('should return 270 for a vector pointing down', () => {
|
249 |
+
expect(orientationDegrees({ dx: 0, dy: -1 })).toBe(270);
|
250 |
+
});
|
251 |
+
});
|
252 |
+
|
253 |
+
|
254 |
+
describe('compressPath', () => {
|
255 |
+
test('should not compress a path with only 2 entries', () => {
|
256 |
+
const facing = { dx: 0, dy: 1 };
|
257 |
+
const compressed = compressPath([
|
258 |
+
{ position: { x: 0, y: 0 }, facing, t: 0 },
|
259 |
+
{ position: { x: 0, y: 1 }, facing, t: 1 },
|
260 |
+
]);
|
261 |
+
expect(compressed).toEqual([
|
262 |
+
[0, 0, 0, 1, 0],
|
263 |
+
[0, 1, 0, 1, 1],
|
264 |
+
]);
|
265 |
+
});
|
266 |
+
|
267 |
+
test('should compress a line', () => {
|
268 |
+
const facing = { dx: 0, dy: 1 };
|
269 |
+
const compressed = compressPath([
|
270 |
+
{ position: { x: 0, y: 0 }, facing, t: 0 },
|
271 |
+
{ position: { x: 0, y: 1 }, facing, t: 1 },
|
272 |
+
{ position: { x: 0, y: 2 }, facing, t: 2 },
|
273 |
+
{ position: { x: 0, y: 3 }, facing, t: 3 },
|
274 |
+
{ position: { x: 0, y: 4 }, facing, t: 4 },
|
275 |
+
]);
|
276 |
+
expect(compressed).toEqual([
|
277 |
+
[0, 0, 0, 1, 0],
|
278 |
+
[0, 4, 0, 1, 4],
|
279 |
+
]);
|
280 |
+
});
|
281 |
+
|
282 |
+
test('should compress a line with a turn', () => {
|
283 |
+
const facingUp = { dx: 0, dy: 1 };
|
284 |
+
const facingRight = { dx: 1, dy: 0 };
|
285 |
+
const compressed = compressPath([
|
286 |
+
{ position: { x: 0, y: 0 }, facing: facingUp, t: 0 },
|
287 |
+
{ position: { x: 0, y: 1 }, facing: facingUp, t: 1 },
|
288 |
+
{ position: { x: 0, y: 2 }, facing: facingRight, t: 2 },
|
289 |
+
{ position: { x: 1, y: 2 }, facing: facingRight, t: 3 },
|
290 |
+
{ position: { x: 2, y: 2 }, facing: facingRight, t: 4 },
|
291 |
+
]);
|
292 |
+
expect(compressed).toEqual([
|
293 |
+
[0, 0, 0, 1, 0],
|
294 |
+
[0, 2, 1, 0, 2],
|
295 |
+
[2, 2, 1, 0, 4],
|
296 |
+
]);
|
297 |
+
});
|
298 |
+
});
|