@jgengine/convex 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -3
- package/LICENSE +203 -661
- package/README.md +3 -3
- package/dist/convexSaveBackend.d.ts +35 -0
- package/dist/convexSaveBackend.js +24 -0
- package/dist/hostedServer.d.ts +98 -0
- package/dist/hostedServer.js +353 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/occ.d.ts +2 -0
- package/dist/occ.js +2 -0
- package/dist/server.d.ts +63 -2
- package/dist/server.js +74 -12
- package/package.json +4 -4
- package/skills/jgengine/SKILL.md +51 -0
- package/skills/jgengine/api.md +434 -0
- package/skills/jgengine/capabilities.md +29 -0
- package/skills/jgengine/reference-cartridge.md +72 -0
- package/skills/jgengine-multiplayer/SKILL.md +36 -0
- package/skills/jgengine-multiplayer/api.md +611 -0
- package/skills/jgengine-multiplayer/capabilities.md +9 -0
- package/skills/jgengine-multiplayer/reference.md +69 -0
- package/llms.txt +0 -951
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ import { resolveConvexMultiplayer } from "@jgengine/convex/resolveConvexMultipla
|
|
|
11
11
|
|
|
12
12
|
const session = resolveConvexMultiplayer({
|
|
13
13
|
game: myGame,
|
|
14
|
-
gameId: "
|
|
14
|
+
gameId: "my-game",
|
|
15
15
|
url: import.meta.env.VITE_CONVEX_URL,
|
|
16
16
|
force: import.meta.env.VITE_CONVEX_URL !== undefined,
|
|
17
17
|
});
|
|
@@ -44,6 +44,6 @@ No game-specific code lives there — any JGengine game can point at the same de
|
|
|
44
44
|
|
|
45
45
|
Every factory defaults to `auth: "anonymous"` — the client's `externalId` is trusted as claimed, fine for local dev but spoofable. Pass `{ auth: "required" }` to every factory for production; the resolved actor becomes `ctx.auth.getUserIdentity()`'s `subject`, and `externalId` is only cross-checked against it, never trusted alone.
|
|
46
46
|
|
|
47
|
-
See `examples/convex-host` for the reference thin consumer (`bunx convex dev` codegens `convex/_generated/` and prints the dev URL)
|
|
47
|
+
See `examples/convex-host` for the reference thin consumer (`bunx convex dev` codegens `convex/_generated/` and prints the dev URL). Point any game that declares `multiplayer: convexPresence({ topology: "shared" })` (or `convex({ topology: "shared", authority: "server" })` for a shared sim) at the same deployment. No Convex Cloud account is needed — `examples/convex-host/docker-compose.yml` runs the open-source backend anywhere Docker runs, with `CONVEX_SELF_HOSTED_URL`/`CONVEX_SELF_HOSTED_ADMIN_KEY` pointing the same CLI at it.
|
|
48
48
|
|
|
49
|
-
Part of [JGengine](https://github.com/Noisemaker111/jgengine).
|
|
49
|
+
Part of [JGengine](https://github.com/Noisemaker111/jgengine). Apache-2.0.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ConvexReactClient } from "convex/react";
|
|
2
|
+
import type { FunctionReference } from "convex/server";
|
|
3
|
+
import { type SaveBackend } from "@jgengine/core/game/saveStore";
|
|
4
|
+
/** The three Convex functions a save backend calls — a `query` that returns the stored string (or `null`) and two `mutation`s. Point them at your app's own module, or lean on the default `saves.*` convention. */
|
|
5
|
+
export interface ConvexSaveFunctions {
|
|
6
|
+
read: FunctionReference<"query", "public", {
|
|
7
|
+
key: string;
|
|
8
|
+
}, string | null>;
|
|
9
|
+
write: FunctionReference<"mutation", "public", {
|
|
10
|
+
key: string;
|
|
11
|
+
value: string;
|
|
12
|
+
}, unknown>;
|
|
13
|
+
remove: FunctionReference<"mutation", "public", {
|
|
14
|
+
key: string;
|
|
15
|
+
}, unknown>;
|
|
16
|
+
}
|
|
17
|
+
/** Default `saves.read` / `saves.write` / `saves.remove` refs for apps that follow the convention — pass your own `functions` to override. */
|
|
18
|
+
export declare function defaultConvexSaveFunctions(): ConvexSaveFunctions;
|
|
19
|
+
/** Config for {@link createConvexSaveBackend} — the Convex client, optional function refs (defaults to the `saves.*` convention), and an optional key `namespace`. */
|
|
20
|
+
export interface ConvexSaveBackendOptions {
|
|
21
|
+
client: ConvexReactClient;
|
|
22
|
+
/** The read/write/remove function refs; defaults to the `saves.*` convention. */
|
|
23
|
+
functions?: ConvexSaveFunctions;
|
|
24
|
+
/** Prefix every key so multiple games or users can share one `saves` table. */
|
|
25
|
+
namespace?: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* A cloud {@link SaveBackend} backed by Convex — reads through a query and
|
|
29
|
+
* writes through mutations, so a {@link createSaveStore} configured with it
|
|
30
|
+
* saves to the server instead of `localStorage`. The only change from an offline
|
|
31
|
+
* game is swapping the backend; autosave, slots, and migration behave the same.
|
|
32
|
+
*
|
|
33
|
+
* @capability convex-save save a game to Convex (cloud) through the SaveBackend seam
|
|
34
|
+
*/
|
|
35
|
+
export declare function createConvexSaveBackend(options: ConvexSaveBackendOptions): SaveBackend;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { anyApi } from "convex/server";
|
|
2
|
+
import { remoteSaveBackend } from "@jgengine/core/game/saveStore";
|
|
3
|
+
/** Default `saves.read` / `saves.write` / `saves.remove` refs for apps that follow the convention — pass your own `functions` to override. */
|
|
4
|
+
export function defaultConvexSaveFunctions() {
|
|
5
|
+
return anyApi.saves;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* A cloud {@link SaveBackend} backed by Convex — reads through a query and
|
|
9
|
+
* writes through mutations, so a {@link createSaveStore} configured with it
|
|
10
|
+
* saves to the server instead of `localStorage`. The only change from an offline
|
|
11
|
+
* game is swapping the backend; autosave, slots, and migration behave the same.
|
|
12
|
+
*
|
|
13
|
+
* @capability convex-save save a game to Convex (cloud) through the SaveBackend seam
|
|
14
|
+
*/
|
|
15
|
+
export function createConvexSaveBackend(options) {
|
|
16
|
+
const functions = options.functions ?? defaultConvexSaveFunctions();
|
|
17
|
+
const prefix = options.namespace === undefined ? "" : `${options.namespace}:`;
|
|
18
|
+
const scope = (key) => `${prefix}${key}`;
|
|
19
|
+
return remoteSaveBackend({
|
|
20
|
+
read: (key) => options.client.query(functions.read, { key: scope(key) }),
|
|
21
|
+
write: (key, value) => options.client.mutation(functions.write, { key: scope(key), value }).then(() => undefined),
|
|
22
|
+
remove: (key) => options.client.mutation(functions.remove, { key: scope(key) }).then(() => undefined),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { GameDefinition } from "@jgengine/core/game/defineGame";
|
|
2
|
+
import type { GameContextContent } from "@jgengine/core/runtime/gameContext";
|
|
3
|
+
import { type InputFrame } from "@jgengine/core/runtime/hostedGameRunner";
|
|
4
|
+
import { type HostedWorldSession, type HostedWorldStore } from "@jgengine/core/runtime/hostedWorldSession";
|
|
5
|
+
import type { ModelAssetRef } from "@jgengine/core/scene/assetCatalog";
|
|
6
|
+
import { type JgAuthMode } from "./server.js";
|
|
7
|
+
/** One game the hosted path can serve, bound per `gameId`: its definition plus content lookup. */
|
|
8
|
+
export interface HostedGameConfig {
|
|
9
|
+
definition: GameDefinition<ModelAssetRef, unknown>;
|
|
10
|
+
content: GameContextContent;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Everything one stateless invocation reconstructs a hosted world from: the persisted record via `store`,
|
|
14
|
+
* plus the member roster and held inputs the snapshot doesn't carry, and the op to apply to the fresh session.
|
|
15
|
+
*/
|
|
16
|
+
export interface HostedWorldInvocation<T> {
|
|
17
|
+
game: HostedGameConfig;
|
|
18
|
+
store: HostedWorldStore;
|
|
19
|
+
members?: readonly string[];
|
|
20
|
+
inputs?: Readonly<Record<string, InputFrame>>;
|
|
21
|
+
now?: () => number;
|
|
22
|
+
op: (session: HostedWorldSession) => T;
|
|
23
|
+
}
|
|
24
|
+
/** What one stateless invocation produced: the op's value, the resulting revision, whether the world changed (and was saved back), and the post-op roster. */
|
|
25
|
+
export interface HostedWorldOutcome<T> {
|
|
26
|
+
value: T;
|
|
27
|
+
revision: number;
|
|
28
|
+
changed: boolean;
|
|
29
|
+
members: readonly string[];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The reconstruct → op → save core of the stateless hosted path, Convex-agnostic so it unit-tests against
|
|
33
|
+
* `memoryWorldStore`: build a fresh {@link createHostedWorldSession} from `store.load()`, resume persisted
|
|
34
|
+
* members and replay held inputs, run the op, then persist the snapshot at `revision + 1` iff the world changed.
|
|
35
|
+
* @internal
|
|
36
|
+
*/
|
|
37
|
+
export declare function invokeHostedWorld<T>(invocation: HostedWorldInvocation<T>): HostedWorldOutcome<T>;
|
|
38
|
+
/** Default minimum elapsed ms before `tickHostedWorlds` advances a world — override via the factory's `tickMs`. */
|
|
39
|
+
export declare const JG_HOSTED_TICK_MS = 1000;
|
|
40
|
+
/**
|
|
41
|
+
* The Convex counterpart of ws's `createWorldGameHost`: serves GameContext worlds statelessly by
|
|
42
|
+
* reconstructing a hosted session from the persisted `jgHostedWorlds` row on every invocation via
|
|
43
|
+
* {@link invokeHostedWorld}. Destructure + re-export the returned functions from a `convex/` module and
|
|
44
|
+
* drive `tickHostedWorlds` from a cron. Requires {@link jgengineHostedTables} in the deployment schema.
|
|
45
|
+
* @internal
|
|
46
|
+
*/
|
|
47
|
+
export declare function createHostedGameServerFunctions(options: {
|
|
48
|
+
games: Record<string, HostedGameConfig>;
|
|
49
|
+
auth?: JgAuthMode;
|
|
50
|
+
tickMs?: number;
|
|
51
|
+
}): {
|
|
52
|
+
getHostedServer: import("convex/server").RegisteredQuery<"public", {
|
|
53
|
+
externalId?: string | undefined;
|
|
54
|
+
gameId: string;
|
|
55
|
+
serverId?: string | undefined;
|
|
56
|
+
}, Promise<{
|
|
57
|
+
serverId: string;
|
|
58
|
+
gameId: string;
|
|
59
|
+
revision: number;
|
|
60
|
+
serverState: any;
|
|
61
|
+
memberUserIds: string[];
|
|
62
|
+
} | null>>;
|
|
63
|
+
joinHostedServer: import("convex/server").RegisteredMutation<"public", {
|
|
64
|
+
externalId?: string | undefined;
|
|
65
|
+
gameId: string;
|
|
66
|
+
serverId?: string | undefined;
|
|
67
|
+
}, Promise<{
|
|
68
|
+
serverId: string;
|
|
69
|
+
isNew: boolean;
|
|
70
|
+
}>>;
|
|
71
|
+
leaveHostedServer: import("convex/server").RegisteredMutation<"public", {
|
|
72
|
+
externalId?: string | undefined;
|
|
73
|
+
gameId: string;
|
|
74
|
+
serverId?: string | undefined;
|
|
75
|
+
}, Promise<null>>;
|
|
76
|
+
runHostedCommand: import("convex/server").RegisteredMutation<"public", {
|
|
77
|
+
command: string;
|
|
78
|
+
externalId?: string | undefined;
|
|
79
|
+
gameId: string;
|
|
80
|
+
input: any;
|
|
81
|
+
serverId?: string | undefined;
|
|
82
|
+
}, Promise<{
|
|
83
|
+
ok: false;
|
|
84
|
+
reason: string;
|
|
85
|
+
} | {
|
|
86
|
+
ok: true;
|
|
87
|
+
}>>;
|
|
88
|
+
submitHostedInput: import("convex/server").RegisteredMutation<"public", {
|
|
89
|
+
externalId?: string | undefined;
|
|
90
|
+
frame: any;
|
|
91
|
+
gameId: string;
|
|
92
|
+
serverId?: string | undefined;
|
|
93
|
+
}, Promise<null>>;
|
|
94
|
+
tickHostedWorlds: import("convex/server").RegisteredMutation<"internal", {}, Promise<{
|
|
95
|
+
ticked: number;
|
|
96
|
+
saved: number;
|
|
97
|
+
}>>;
|
|
98
|
+
};
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { defineSchema, internalMutationGeneric, mutationGeneric, queryGeneric, } from "convex/server";
|
|
2
|
+
import { ConvexError, v } from "convex/values";
|
|
3
|
+
import { INPUT_COMMAND } from "@jgengine/core/runtime/hostedGameRunner";
|
|
4
|
+
import { createHostedWorldSession, } from "@jgengine/core/runtime/hostedWorldSession";
|
|
5
|
+
import { commitIfRevisionMatch } from "./occ.js";
|
|
6
|
+
import { jgengineHostedTables, resolveActor } from "./server.js";
|
|
7
|
+
/**
|
|
8
|
+
* The reconstruct → op → save core of the stateless hosted path, Convex-agnostic so it unit-tests against
|
|
9
|
+
* `memoryWorldStore`: build a fresh {@link createHostedWorldSession} from `store.load()`, resume persisted
|
|
10
|
+
* members and replay held inputs, run the op, then persist the snapshot at `revision + 1` iff the world changed.
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
13
|
+
export function invokeHostedWorld(invocation) {
|
|
14
|
+
const { game, store, members = [], inputs = {}, now, op } = invocation;
|
|
15
|
+
const loaded = store.load();
|
|
16
|
+
const loadedJson = loaded === null ? null : JSON.stringify(loaded.snapshot);
|
|
17
|
+
const session = createHostedWorldSession({
|
|
18
|
+
definition: game.definition,
|
|
19
|
+
content: game.content,
|
|
20
|
+
store: { load: () => loaded, save: () => { } },
|
|
21
|
+
...(now === undefined ? {} : { now }),
|
|
22
|
+
});
|
|
23
|
+
for (const userId of members)
|
|
24
|
+
session.runner().resume(userId);
|
|
25
|
+
for (const [userId, frame] of Object.entries(inputs))
|
|
26
|
+
session.input(userId, frame);
|
|
27
|
+
const value = op(session);
|
|
28
|
+
const snapshot = session.runner().snapshot();
|
|
29
|
+
const baseRevision = loaded?.revision ?? 0;
|
|
30
|
+
if (loadedJson !== null && JSON.stringify(snapshot) === loadedJson) {
|
|
31
|
+
return { value, revision: baseRevision, changed: false, members: session.members() };
|
|
32
|
+
}
|
|
33
|
+
const record = { snapshot, revision: baseRevision + 1 };
|
|
34
|
+
store.save(record);
|
|
35
|
+
return { value, revision: record.revision, changed: true, members: session.members() };
|
|
36
|
+
}
|
|
37
|
+
const hostedSchemaForTypes = defineSchema(jgengineHostedTables());
|
|
38
|
+
const query = queryGeneric;
|
|
39
|
+
const mutation = mutationGeneric;
|
|
40
|
+
const internalMutation = internalMutationGeneric;
|
|
41
|
+
/** Default minimum elapsed ms before `tickHostedWorlds` advances a world — override via the factory's `tickMs`. */
|
|
42
|
+
export const JG_HOSTED_TICK_MS = 1_000;
|
|
43
|
+
async function getWorldRow(ctx, gameId, serverId) {
|
|
44
|
+
return await ctx.db
|
|
45
|
+
.query("jgHostedWorlds")
|
|
46
|
+
.withIndex("by_game_and_server", (q) => q.eq("gameId", gameId).eq("serverId", serverId))
|
|
47
|
+
.unique();
|
|
48
|
+
}
|
|
49
|
+
function heldInputsOf(row) {
|
|
50
|
+
return (row?.inputs ?? {});
|
|
51
|
+
}
|
|
52
|
+
function worldStoreForRow(row) {
|
|
53
|
+
const loaded = row === null ? null : { snapshot: row.snapshot, revision: row.revision };
|
|
54
|
+
let captured = null;
|
|
55
|
+
return {
|
|
56
|
+
store: {
|
|
57
|
+
load: () => loaded,
|
|
58
|
+
save(record) {
|
|
59
|
+
captured = record;
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
captured: () => captured,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
async function persistWorldRow(ctx, args) {
|
|
66
|
+
const nowMs = Date.now();
|
|
67
|
+
if (args.row === null) {
|
|
68
|
+
if (args.record === null)
|
|
69
|
+
return;
|
|
70
|
+
await ctx.db.insert("jgHostedWorlds", {
|
|
71
|
+
gameId: args.gameId,
|
|
72
|
+
serverId: args.serverId,
|
|
73
|
+
snapshot: args.record.snapshot,
|
|
74
|
+
revision: args.record.revision,
|
|
75
|
+
memberUserIds: [...args.memberUserIds],
|
|
76
|
+
inputs: args.inputs,
|
|
77
|
+
tickAnchorMs: args.tickAnchorMs ?? nowMs,
|
|
78
|
+
createdAt: nowMs,
|
|
79
|
+
updatedAt: nowMs,
|
|
80
|
+
});
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
await ctx.db.patch(args.row._id, {
|
|
84
|
+
...(args.record === null
|
|
85
|
+
? {}
|
|
86
|
+
: { snapshot: args.record.snapshot, revision: args.record.revision }),
|
|
87
|
+
memberUserIds: [...args.memberUserIds],
|
|
88
|
+
inputs: args.inputs,
|
|
89
|
+
...(args.tickAnchorMs === undefined ? {} : { tickAnchorMs: args.tickAnchorMs }),
|
|
90
|
+
updatedAt: nowMs,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The Convex counterpart of ws's `createWorldGameHost`: serves GameContext worlds statelessly by
|
|
95
|
+
* reconstructing a hosted session from the persisted `jgHostedWorlds` row on every invocation via
|
|
96
|
+
* {@link invokeHostedWorld}. Destructure + re-export the returned functions from a `convex/` module and
|
|
97
|
+
* drive `tickHostedWorlds` from a cron. Requires {@link jgengineHostedTables} in the deployment schema.
|
|
98
|
+
* @internal
|
|
99
|
+
*/
|
|
100
|
+
export function createHostedGameServerFunctions(options) {
|
|
101
|
+
const mode = options.auth ?? "anonymous";
|
|
102
|
+
const tickMs = options.tickMs ?? JG_HOSTED_TICK_MS;
|
|
103
|
+
function resolveGame(gameId) {
|
|
104
|
+
const game = options.games[gameId];
|
|
105
|
+
if (game === undefined)
|
|
106
|
+
throw new ConvexError(`Unknown hosted game "${gameId}"`);
|
|
107
|
+
return game;
|
|
108
|
+
}
|
|
109
|
+
const getHostedServer = query({
|
|
110
|
+
args: {
|
|
111
|
+
gameId: v.string(),
|
|
112
|
+
serverId: v.optional(v.string()),
|
|
113
|
+
externalId: v.optional(v.string()),
|
|
114
|
+
},
|
|
115
|
+
returns: v.union(v.object({
|
|
116
|
+
serverId: v.string(),
|
|
117
|
+
gameId: v.string(),
|
|
118
|
+
revision: v.number(),
|
|
119
|
+
serverState: v.any(),
|
|
120
|
+
memberUserIds: v.array(v.string()),
|
|
121
|
+
}), v.null()),
|
|
122
|
+
handler: async (ctx, args) => {
|
|
123
|
+
const actorUserId = await resolveActor(ctx, args.externalId, mode);
|
|
124
|
+
if (!actorUserId)
|
|
125
|
+
return null;
|
|
126
|
+
const row = await getWorldRow(ctx, args.gameId, args.serverId ?? args.gameId);
|
|
127
|
+
if (!row)
|
|
128
|
+
return null;
|
|
129
|
+
if (!row.memberUserIds.includes(actorUserId))
|
|
130
|
+
return null;
|
|
131
|
+
return {
|
|
132
|
+
serverId: row.serverId,
|
|
133
|
+
gameId: row.gameId,
|
|
134
|
+
revision: row.revision,
|
|
135
|
+
serverState: row.snapshot,
|
|
136
|
+
memberUserIds: row.memberUserIds,
|
|
137
|
+
};
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
const joinHostedServer = mutation({
|
|
141
|
+
args: {
|
|
142
|
+
gameId: v.string(),
|
|
143
|
+
serverId: v.optional(v.string()),
|
|
144
|
+
externalId: v.optional(v.string()),
|
|
145
|
+
},
|
|
146
|
+
returns: v.object({ serverId: v.string(), isNew: v.boolean() }),
|
|
147
|
+
handler: async (ctx, args) => {
|
|
148
|
+
const actorUserId = await resolveActor(ctx, args.externalId, mode);
|
|
149
|
+
if (!actorUserId) {
|
|
150
|
+
throw new ConvexError("Not authenticated");
|
|
151
|
+
}
|
|
152
|
+
const game = resolveGame(args.gameId);
|
|
153
|
+
const serverId = args.serverId ?? args.gameId;
|
|
154
|
+
const row = await getWorldRow(ctx, args.gameId, serverId);
|
|
155
|
+
const isNew = !(row?.memberUserIds ?? []).includes(actorUserId);
|
|
156
|
+
const { store, captured } = worldStoreForRow(row);
|
|
157
|
+
const outcome = invokeHostedWorld({
|
|
158
|
+
game,
|
|
159
|
+
store,
|
|
160
|
+
members: row?.memberUserIds ?? [],
|
|
161
|
+
inputs: heldInputsOf(row),
|
|
162
|
+
op: (session) => session.join(actorUserId, isNew),
|
|
163
|
+
});
|
|
164
|
+
await persistWorldRow(ctx, {
|
|
165
|
+
gameId: args.gameId,
|
|
166
|
+
serverId,
|
|
167
|
+
row,
|
|
168
|
+
record: captured(),
|
|
169
|
+
memberUserIds: outcome.members,
|
|
170
|
+
inputs: heldInputsOf(row),
|
|
171
|
+
});
|
|
172
|
+
return { serverId, isNew };
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
const leaveHostedServer = mutation({
|
|
176
|
+
args: {
|
|
177
|
+
gameId: v.string(),
|
|
178
|
+
serverId: v.optional(v.string()),
|
|
179
|
+
externalId: v.optional(v.string()),
|
|
180
|
+
},
|
|
181
|
+
returns: v.null(),
|
|
182
|
+
handler: async (ctx, args) => {
|
|
183
|
+
const actorUserId = await resolveActor(ctx, args.externalId, mode);
|
|
184
|
+
if (!actorUserId) {
|
|
185
|
+
throw new ConvexError("Not authenticated");
|
|
186
|
+
}
|
|
187
|
+
const serverId = args.serverId ?? args.gameId;
|
|
188
|
+
const row = await getWorldRow(ctx, args.gameId, serverId);
|
|
189
|
+
if (!row)
|
|
190
|
+
return null;
|
|
191
|
+
if (!row.memberUserIds.includes(actorUserId))
|
|
192
|
+
return null;
|
|
193
|
+
const game = resolveGame(args.gameId);
|
|
194
|
+
const { store, captured } = worldStoreForRow(row);
|
|
195
|
+
const outcome = invokeHostedWorld({
|
|
196
|
+
game,
|
|
197
|
+
store,
|
|
198
|
+
members: row.memberUserIds,
|
|
199
|
+
inputs: heldInputsOf(row),
|
|
200
|
+
op: (session) => session.leave(actorUserId),
|
|
201
|
+
});
|
|
202
|
+
const inputs = heldInputsOf(row);
|
|
203
|
+
delete inputs[actorUserId];
|
|
204
|
+
await persistWorldRow(ctx, {
|
|
205
|
+
gameId: args.gameId,
|
|
206
|
+
serverId,
|
|
207
|
+
row,
|
|
208
|
+
record: captured(),
|
|
209
|
+
memberUserIds: outcome.members,
|
|
210
|
+
inputs,
|
|
211
|
+
});
|
|
212
|
+
return null;
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
const runHostedCommand = mutation({
|
|
216
|
+
args: {
|
|
217
|
+
gameId: v.string(),
|
|
218
|
+
serverId: v.optional(v.string()),
|
|
219
|
+
command: v.string(),
|
|
220
|
+
input: v.any(),
|
|
221
|
+
externalId: v.optional(v.string()),
|
|
222
|
+
},
|
|
223
|
+
returns: v.union(v.object({ ok: v.literal(true) }), v.object({ ok: v.literal(false), reason: v.string() })),
|
|
224
|
+
handler: async (ctx, args) => {
|
|
225
|
+
const actorUserId = await resolveActor(ctx, args.externalId, mode);
|
|
226
|
+
if (!actorUserId) {
|
|
227
|
+
throw new ConvexError("Not authenticated");
|
|
228
|
+
}
|
|
229
|
+
const serverId = args.serverId ?? args.gameId;
|
|
230
|
+
const row = await getWorldRow(ctx, args.gameId, serverId);
|
|
231
|
+
if (!row) {
|
|
232
|
+
return { ok: false, reason: "Server not found" };
|
|
233
|
+
}
|
|
234
|
+
if (!row.memberUserIds.includes(actorUserId)) {
|
|
235
|
+
return { ok: false, reason: "Not a member of this server" };
|
|
236
|
+
}
|
|
237
|
+
if (args.command === INPUT_COMMAND) {
|
|
238
|
+
await ctx.db.patch(row._id, {
|
|
239
|
+
inputs: { ...heldInputsOf(row), [actorUserId]: args.input },
|
|
240
|
+
updatedAt: Date.now(),
|
|
241
|
+
});
|
|
242
|
+
return { ok: true };
|
|
243
|
+
}
|
|
244
|
+
const game = resolveGame(args.gameId);
|
|
245
|
+
const loadedRevision = row.revision;
|
|
246
|
+
const { store, captured } = worldStoreForRow(row);
|
|
247
|
+
const outcome = invokeHostedWorld({
|
|
248
|
+
game,
|
|
249
|
+
store,
|
|
250
|
+
members: row.memberUserIds,
|
|
251
|
+
inputs: heldInputsOf(row),
|
|
252
|
+
op: (session) => session.command(actorUserId, args.command, args.input),
|
|
253
|
+
});
|
|
254
|
+
if (outcome.value.status === "rejected") {
|
|
255
|
+
return { ok: false, reason: outcome.value.reason };
|
|
256
|
+
}
|
|
257
|
+
if (outcome.value.status === "unknown-command") {
|
|
258
|
+
return { ok: false, reason: "unknown-command" };
|
|
259
|
+
}
|
|
260
|
+
const latest = await getWorldRow(ctx, args.gameId, serverId);
|
|
261
|
+
if (!latest) {
|
|
262
|
+
return { ok: false, reason: "Server not found" };
|
|
263
|
+
}
|
|
264
|
+
const commit = commitIfRevisionMatch(loadedRevision, latest.revision);
|
|
265
|
+
if (!commit.ok) {
|
|
266
|
+
return commit;
|
|
267
|
+
}
|
|
268
|
+
await persistWorldRow(ctx, {
|
|
269
|
+
gameId: args.gameId,
|
|
270
|
+
serverId,
|
|
271
|
+
row: latest,
|
|
272
|
+
record: captured(),
|
|
273
|
+
memberUserIds: outcome.members,
|
|
274
|
+
inputs: heldInputsOf(latest),
|
|
275
|
+
});
|
|
276
|
+
return { ok: true };
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
const submitHostedInput = mutation({
|
|
280
|
+
args: {
|
|
281
|
+
gameId: v.string(),
|
|
282
|
+
serverId: v.optional(v.string()),
|
|
283
|
+
frame: v.any(),
|
|
284
|
+
externalId: v.optional(v.string()),
|
|
285
|
+
},
|
|
286
|
+
returns: v.null(),
|
|
287
|
+
handler: async (ctx, args) => {
|
|
288
|
+
const actorUserId = await resolveActor(ctx, args.externalId, mode);
|
|
289
|
+
if (!actorUserId) {
|
|
290
|
+
throw new ConvexError("Not authenticated");
|
|
291
|
+
}
|
|
292
|
+
const row = await getWorldRow(ctx, args.gameId, args.serverId ?? args.gameId);
|
|
293
|
+
if (!row || !row.memberUserIds.includes(actorUserId)) {
|
|
294
|
+
throw new ConvexError("Not a member of this server");
|
|
295
|
+
}
|
|
296
|
+
await ctx.db.patch(row._id, {
|
|
297
|
+
inputs: { ...heldInputsOf(row), [actorUserId]: args.frame },
|
|
298
|
+
updatedAt: Date.now(),
|
|
299
|
+
});
|
|
300
|
+
return null;
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
const tickHostedWorlds = internalMutation({
|
|
304
|
+
args: {},
|
|
305
|
+
handler: async (ctx) => {
|
|
306
|
+
const nowMs = Date.now();
|
|
307
|
+
const rows = await ctx.db
|
|
308
|
+
.query("jgHostedWorlds")
|
|
309
|
+
.withIndex("by_tick_anchor", (q) => q.lte("tickAnchorMs", nowMs - tickMs))
|
|
310
|
+
.collect();
|
|
311
|
+
let ticked = 0;
|
|
312
|
+
let saved = 0;
|
|
313
|
+
for (const row of rows) {
|
|
314
|
+
const game = options.games[row.gameId];
|
|
315
|
+
if (game === undefined)
|
|
316
|
+
continue;
|
|
317
|
+
if (row.memberUserIds.length === 0)
|
|
318
|
+
continue;
|
|
319
|
+
const elapsedMs = nowMs - row.tickAnchorMs;
|
|
320
|
+
const { store, captured } = worldStoreForRow(row);
|
|
321
|
+
const outcome = invokeHostedWorld({
|
|
322
|
+
game,
|
|
323
|
+
store,
|
|
324
|
+
members: row.memberUserIds,
|
|
325
|
+
inputs: heldInputsOf(row),
|
|
326
|
+
op: (session) => session.tick(elapsedMs / 1_000),
|
|
327
|
+
});
|
|
328
|
+
ticked += 1;
|
|
329
|
+
const record = captured();
|
|
330
|
+
if (record !== null)
|
|
331
|
+
saved += 1;
|
|
332
|
+
await persistWorldRow(ctx, {
|
|
333
|
+
gameId: row.gameId,
|
|
334
|
+
serverId: row.serverId,
|
|
335
|
+
row,
|
|
336
|
+
record,
|
|
337
|
+
memberUserIds: outcome.members,
|
|
338
|
+
inputs: heldInputsOf(row),
|
|
339
|
+
tickAnchorMs: nowMs,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
return { ticked, saved };
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
return {
|
|
346
|
+
getHostedServer,
|
|
347
|
+
joinHostedServer,
|
|
348
|
+
leaveHostedServer,
|
|
349
|
+
runHostedCommand,
|
|
350
|
+
submitHostedInput,
|
|
351
|
+
tickHostedWorlds,
|
|
352
|
+
};
|
|
353
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
export * from "./convexChatTransport.js";
|
|
2
2
|
export * from "./convexPresenceTransport.js";
|
|
3
3
|
export * from "./createConvexBackend.js";
|
|
4
|
+
export * from "./convexSaveBackend.js";
|
|
4
5
|
export * from "./createConvexGameTransport.js";
|
|
6
|
+
export * from "./hostedServer.js";
|
|
5
7
|
export * from "./occ.js";
|
|
6
8
|
export * from "./resolveConvexMultiplayer.js";
|
|
7
9
|
export * from "./server.js";
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
export * from "./convexChatTransport.js";
|
|
2
2
|
export * from "./convexPresenceTransport.js";
|
|
3
3
|
export * from "./createConvexBackend.js";
|
|
4
|
+
export * from "./convexSaveBackend.js";
|
|
4
5
|
export * from "./createConvexGameTransport.js";
|
|
6
|
+
export * from "./hostedServer.js";
|
|
5
7
|
export * from "./occ.js";
|
|
6
8
|
export * from "./resolveConvexMultiplayer.js";
|
|
7
9
|
export * from "./server.js";
|
package/dist/occ.d.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import type { GameRuntime } from "@jgengine/core/runtime/gameRuntime";
|
|
2
2
|
import type { GameRuntimeSnapshot } from "@jgengine/core/runtime/snapshot";
|
|
3
3
|
export declare const REVISION_CONFLICT_REASON = "Revision conflict";
|
|
4
|
+
/** @internal */
|
|
4
5
|
export declare function commitIfRevisionMatch(loadedRevision: number, currentRevision: number): {
|
|
5
6
|
ok: true;
|
|
6
7
|
} | {
|
|
7
8
|
ok: false;
|
|
8
9
|
reason: string;
|
|
9
10
|
};
|
|
11
|
+
/** @internal */
|
|
10
12
|
export declare function applyCommandWithOcc(args: {
|
|
11
13
|
loadedRevision: number;
|
|
12
14
|
currentRevision: number;
|
package/dist/occ.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
export const REVISION_CONFLICT_REASON = "Revision conflict";
|
|
2
|
+
/** @internal */
|
|
2
3
|
export function commitIfRevisionMatch(loadedRevision, currentRevision) {
|
|
3
4
|
if (currentRevision !== loadedRevision) {
|
|
4
5
|
return { ok: false, reason: REVISION_CONFLICT_REASON };
|
|
5
6
|
}
|
|
6
7
|
return { ok: true };
|
|
7
8
|
}
|
|
9
|
+
/** @internal */
|
|
8
10
|
export function applyCommandWithOcc(args) {
|
|
9
11
|
const gate = commitIfRevisionMatch(args.loadedRevision, args.currentRevision);
|
|
10
12
|
if (!gate.ok)
|