@jgengine/convex 0.18.1 → 0.21.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.
@@ -20,10 +20,12 @@ export type ConvexGameApi = {
20
20
  visibility?: "public" | "private";
21
21
  joinCode?: string;
22
22
  externalId?: string;
23
+ sessionId?: string;
23
24
  }, JoinServerOutcome>;
24
25
  leaveServer: FunctionReference<"mutation", "public", {
25
26
  serverId: string;
26
27
  externalId?: string;
28
+ sessionId?: string;
27
29
  }, null>;
28
30
  runCommand: FunctionReference<"mutation", "public", {
29
31
  serverId: string;
@@ -5,10 +5,12 @@ export function defaultConvexGameApi() {
5
5
  return anyApi;
6
6
  }
7
7
  export function createConvexGameTransport(client, api, config) {
8
- const joinedServers = new Set();
8
+ const joinedServers = new Map();
9
9
  const onPageHide = () => {
10
- for (const serverId of joinedServers) {
11
- void client.mutation(api.runtime.leaveServer, { serverId, externalId: config.userId }).catch(() => undefined);
10
+ for (const [serverId, sessions] of joinedServers) {
11
+ for (const sessionId of sessions) {
12
+ void client.mutation(api.runtime.leaveServer, { serverId, externalId: config.userId, ...(sessionId === undefined ? {} : { sessionId }) }).catch(() => undefined);
13
+ }
12
14
  }
13
15
  joinedServers.clear();
14
16
  globalThis.removeEventListener?.("pagehide", onPageHide);
@@ -19,16 +21,26 @@ export function createConvexGameTransport(client, api, config) {
19
21
  gameId: config.gameId,
20
22
  serverId: args.serverId,
21
23
  externalId: config.userId,
24
+ ...(args.sessionId === undefined ? {} : { sessionId: args.sessionId }),
22
25
  });
23
26
  if (result.ok) {
24
- joinedServers.add(result.serverId);
27
+ const sessions = joinedServers.get(result.serverId) ?? new Set();
28
+ sessions.add(args.sessionId);
29
+ joinedServers.set(result.serverId, sessions);
25
30
  globalThis.addEventListener?.("pagehide", onPageHide);
26
31
  }
27
32
  return result;
28
33
  },
29
34
  async leaveServer(args) {
30
35
  await client.mutation(api.runtime.leaveServer, { ...args, externalId: config.userId });
31
- joinedServers.delete(args.serverId);
36
+ const sessions = joinedServers.get(args.serverId);
37
+ if (args.sessionId === undefined)
38
+ joinedServers.delete(args.serverId);
39
+ else {
40
+ sessions?.delete(args.sessionId);
41
+ if (sessions?.size === 0)
42
+ joinedServers.delete(args.serverId);
43
+ }
32
44
  if (joinedServers.size === 0)
33
45
  globalThis.removeEventListener?.("pagehide", onPageHide);
34
46
  },
@@ -1,7 +1,8 @@
1
1
  import type { GameDefinition } from "@jgengine/core/game/defineGame";
2
2
  import type { GameContextContent, GameContextModels } from "@jgengine/core/runtime/gameContext";
3
3
  import { type InputFrame } from "@jgengine/core/runtime/hostedGameRunner";
4
- import { type HostedWorldSession, type HostedWorldStore } from "@jgengine/core/runtime/hostedWorldSession";
4
+ import { type HostedWorldSession, type HostedWorldStore, type SyncHostedWorldStore } from "@jgengine/core/runtime/hostedWorldSession";
5
+ import type { WorldSnapshot } from "@jgengine/core/runtime/worldSnapshot";
5
6
  import type { ModelAssetRef } from "@jgengine/core/scene/assetCatalog";
6
7
  import { type JgAuthMode } from "./server.js";
7
8
  /** One game the hosted path can serve, bound per `gameId`: its definition plus content lookup. */
@@ -17,7 +18,7 @@ export interface HostedGameConfig {
17
18
  */
18
19
  export interface HostedWorldInvocation<T> {
19
20
  game: HostedGameConfig;
20
- store: HostedWorldStore;
21
+ store: HostedWorldStore | SyncHostedWorldStore;
21
22
  members?: readonly string[];
22
23
  inputs?: Readonly<Record<string, InputFrame>>;
23
24
  now?: () => number;
@@ -59,7 +60,7 @@ export declare function createHostedGameServerFunctions(options: {
59
60
  serverId: string;
60
61
  gameId: string;
61
62
  revision: number;
62
- serverState: any;
63
+ serverState: WorldSnapshot;
63
64
  memberUserIds: string[];
64
65
  } | null>>;
65
66
  joinHostedServer: import("convex/server").RegisteredMutation<"public", {
@@ -1,6 +1,6 @@
1
1
  import { defineSchema, internalMutationGeneric, mutationGeneric, queryGeneric, } from "convex/server";
2
2
  import { ConvexError, v } from "convex/values";
3
- import { INPUT_COMMAND } from "@jgengine/core/runtime/hostedGameRunner";
3
+ import { createHostedGameRunner, INPUT_COMMAND } from "@jgengine/core/runtime/hostedGameRunner";
4
4
  import { createHostedWorldSessionAsync, } from "@jgengine/core/runtime/hostedWorldSession";
5
5
  import { commitIfRevisionMatch } from "./occ.js";
6
6
  import { jgengineHostedTables, resolveActor } from "./server.js";
@@ -26,7 +26,7 @@ export async function invokeHostedWorld(invocation) {
26
26
  for (const [userId, frame] of Object.entries(inputs))
27
27
  session.input(userId, frame);
28
28
  const value = op(session);
29
- const snapshot = session.runner().snapshot();
29
+ const snapshot = session.runner().state();
30
30
  const baseRevision = loaded?.revision ?? 0;
31
31
  if (loadedJson !== null && JSON.stringify(snapshot) === loadedJson) {
32
32
  return { value, revision: baseRevision, changed: false, members: session.members() };
@@ -129,11 +129,15 @@ export function createHostedGameServerFunctions(options) {
129
129
  return null;
130
130
  if (!row.memberUserIds.includes(actorUserId))
131
131
  return null;
132
+ const game = resolveGame(row.gameId);
133
+ const runner = createHostedGameRunner({ ...game, restore: row.snapshot });
134
+ for (const userId of row.memberUserIds)
135
+ runner.resume(userId);
132
136
  return {
133
137
  serverId: row.serverId,
134
138
  gameId: row.gameId,
135
139
  revision: row.revision,
136
- serverState: row.snapshot,
140
+ serverState: runner.snapshot({ userId: actorUserId }),
137
141
  memberUserIds: row.memberUserIds,
138
142
  };
139
143
  },
@@ -153,14 +157,17 @@ export function createHostedGameServerFunctions(options) {
153
157
  const game = resolveGame(args.gameId);
154
158
  const serverId = args.serverId ?? args.gameId;
155
159
  const row = await getWorldRow(ctx, args.gameId, serverId);
156
- const isNew = !(row?.memberUserIds ?? []).includes(actorUserId);
157
160
  const { store, captured } = worldStoreForRow(row);
158
161
  const outcome = await invokeHostedWorld({
159
162
  game,
160
163
  store,
161
164
  members: row?.memberUserIds ?? [],
162
165
  inputs: heldInputsOf(row),
163
- op: (session) => session.join(actorUserId, isNew),
166
+ op: (session) => {
167
+ const isNew = !session.hasPlayer(actorUserId) && !session.members().includes(actorUserId);
168
+ session.join(actorUserId, isNew);
169
+ return isNew;
170
+ },
164
171
  });
165
172
  await persistWorldRow(ctx, {
166
173
  gameId: args.gameId,
@@ -170,7 +177,7 @@ export function createHostedGameServerFunctions(options) {
170
177
  memberUserIds: outcome.members,
171
178
  inputs: heldInputsOf(row),
172
179
  });
173
- return { serverId, isNew };
180
+ return { serverId, isNew: outcome.value };
174
181
  },
175
182
  });
176
183
  const leaveHostedServer = mutation({
package/dist/server.d.ts CHANGED
@@ -91,12 +91,15 @@ export declare function jgengineTables(): {
91
91
  jgServerMembers: import("convex/server").TableDefinition<import("convex/values").VObject<{
92
92
  joinedAt: number;
93
93
  serverId: GenericId<"jgGameServers">;
94
+ sessionIds?: string[] | undefined;
94
95
  userId: string;
95
96
  }, {
96
97
  serverId: import("convex/values").VId<GenericId<"jgGameServers">, "required">;
97
98
  userId: import("convex/values").VString<string, "required">;
98
99
  joinedAt: import("convex/values").VFloat64<number, "required">;
99
- }, "required", "joinedAt" | "serverId" | "userId">, {
100
+ /** Live client sessions holding this membership; absent on legacy and host-driven joins. */
101
+ sessionIds: import("convex/values").VArray<string[] | undefined, import("convex/values").VString<string, "required">, "optional">;
102
+ }, "required", "joinedAt" | "serverId" | "sessionIds" | "userId">, {
100
103
  by_server: ["serverId", "_creationTime"];
101
104
  by_server_and_user: ["serverId", "userId", "_creationTime"];
102
105
  }, {}, {}>;
@@ -379,12 +382,15 @@ declare const schemaForTypes: import("convex/server").SchemaDefinition<{
379
382
  jgServerMembers: import("convex/server").TableDefinition<import("convex/values").VObject<{
380
383
  joinedAt: number;
381
384
  serverId: GenericId<"jgGameServers">;
385
+ sessionIds?: string[] | undefined;
382
386
  userId: string;
383
387
  }, {
384
388
  serverId: import("convex/values").VId<GenericId<"jgGameServers">, "required">;
385
389
  userId: import("convex/values").VString<string, "required">;
386
390
  joinedAt: import("convex/values").VFloat64<number, "required">;
387
- }, "required", "joinedAt" | "serverId" | "userId">, {
391
+ /** Live client sessions holding this membership; absent on legacy and host-driven joins. */
392
+ sessionIds: import("convex/values").VArray<string[] | undefined, import("convex/values").VString<string, "required">, "optional">;
393
+ }, "required", "joinedAt" | "serverId" | "sessionIds" | "userId">, {
388
394
  by_server: ["serverId", "_creationTime"];
389
395
  by_server_and_user: ["serverId", "userId", "_creationTime"];
390
396
  }, {}, {}>;
@@ -573,6 +579,10 @@ export declare const DEFAULT_CONVEX_POSE_RULES: PoseSyncRules;
573
579
  export declare function resolveActor(ctx: {
574
580
  auth: Auth;
575
581
  }, claimedExternalId: string | undefined, mode: JgAuthMode): Promise<string | null>;
582
+ /** Most live client sessions one shared-topology membership remembers; the oldest drop first. */
583
+ export declare const MAX_MEMBER_SESSIONS = 16;
584
+ /** Longest accepted client `sessionId`. */
585
+ export declare const MAX_SESSION_ID_LENGTH = 128;
576
586
  /**
577
587
  * How much of a server to hydrate. Both fields default to "everything", which is a document read per
578
588
  * member plus one per chunk — the cost that makes a large shared world unaffordable per mutation.
@@ -626,10 +636,23 @@ export type RunCommandArgs = {
626
636
  * {@link CommandDef} declares through its own `scope`; omit both to hydrate the whole world.
627
637
  */
628
638
  scope?: LoadSnapshotScope;
629
- /** Trusted host override after game-specific authorization; never forward a client-supplied actor. */
639
+ /**
640
+ * Trusted host override after game-specific authorization; never forward a client-supplied actor.
641
+ * The actor must be a member; when they might have no live session (a closed tab, an agent), call
642
+ * {@link GameServerHelpers.ensureJoined} first.
643
+ */
630
644
  actorUserId?: string;
631
645
  beforePersist?: (ctx: JGMutationCtx, loaded: LoadedServerSnapshot) => Promise<void>;
632
646
  };
647
+ /** Outcome of a join: the server joined and whether the player's profile was created, or why it was refused. */
648
+ export type EnsureJoinedOutcome = {
649
+ ok: true;
650
+ serverId: GenericId<"jgGameServers">;
651
+ isNew: boolean;
652
+ } | {
653
+ ok: false;
654
+ reason: "full" | "closed" | "unauthorized";
655
+ };
633
656
  /** A server resolved for runtime work: its row, its registered runtime, and its hydrated snapshot. */
634
657
  export type LoadedServerSnapshot = {
635
658
  server: ServerDoc;
@@ -660,7 +683,21 @@ export type GameServerHelpers = {
660
683
  reason: string;
661
684
  };
662
685
  persistSnapshot: (ctx: JGMutationCtx, server: ServerDoc, snapshot: GameRuntimeSnapshot, save?: SaveConfig) => Promise<boolean>;
686
+ /**
687
+ * Apply a command as the actor. Refuses non-members with "Not a member of this server"; host
688
+ * mutations acting for a user who might have no live session call {@link ensureJoined} first.
689
+ */
663
690
  runCommand: (ctx: JGMutationCtx, args: RunCommandArgs) => Promise<RunCommandOutcome>;
691
+ /**
692
+ * Join a trusted, already-authorized actor through the same path as the `joinServer` mutation.
693
+ * Idempotent: an existing member of `serverId` returns at once. Pins no client session, so it never
694
+ * keeps a membership alive past the user's own session leaves.
695
+ */
696
+ ensureJoined: (ctx: JGMutationCtx, args: {
697
+ gameId: string;
698
+ userId: string;
699
+ serverId?: string;
700
+ }) => Promise<EnsureJoinedOutcome>;
664
701
  resetPlayerProfile: (ctx: JGMutationCtx, serverId: string, userId: string) => Promise<RuntimePlayerRow>;
665
702
  };
666
703
  /** How many running servers one `tickActiveServers` transaction may hydrate, tick, and persist. */
@@ -696,31 +733,13 @@ export declare function createGameServerFunctions(options?: {
696
733
  mode?: string | undefined;
697
734
  modeConfig?: any;
698
735
  serverId?: GenericId<"jgGameServers"> | undefined;
736
+ sessionId?: string | undefined;
699
737
  visibility?: "private" | "public" | undefined;
700
- }, Promise<{
701
- ok: false;
702
- reason: "unauthorized";
703
- serverId?: undefined;
704
- isNew?: undefined;
705
- } | {
706
- ok: false;
707
- reason: "closed";
708
- serverId?: undefined;
709
- isNew?: undefined;
710
- } | {
711
- ok: false;
712
- reason: "full";
713
- serverId?: undefined;
714
- isNew?: undefined;
715
- } | {
716
- reason?: undefined;
717
- ok: true;
718
- serverId: GenericId<"jgGameServers">;
719
- isNew: boolean;
720
- }>>;
738
+ }, Promise<EnsureJoinedOutcome>>;
721
739
  leaveServer: import("convex/server").RegisteredMutation<"public", {
722
740
  externalId?: string | undefined;
723
741
  serverId: GenericId<"jgGameServers">;
742
+ sessionId?: string | undefined;
724
743
  }, Promise<null>>;
725
744
  runCommand: import("convex/server").RegisteredMutation<"public", {
726
745
  command: string;