@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/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: "voxel-mine",
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) and `Games/voxel-mine` for a game that declares `multiplayer: convex({ topology: "shared" })`. 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.
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). AGPL-3.0-only.
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)