@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.
- package/CHANGELOG.md +504 -7
- package/dist/createConvexGameTransport.d.ts +2 -0
- package/dist/createConvexGameTransport.js +17 -5
- package/dist/hostedServer.d.ts +4 -3
- package/dist/hostedServer.js +13 -6
- package/dist/server.d.ts +43 -24
- package/dist/server.js +201 -135
- package/package.json +2 -2
- package/skills/ce-handoff/SKILL.md +2 -2
- package/skills/jgengine/SKILL.md +28 -6
- package/skills/jgengine/api.md +139 -99
- package/skills/jgengine/capabilities.md +28 -0
- package/skills/jgengine/recipes/minimal-game.md +10 -2
- package/skills/jgengine-multiplayer/SKILL.md +3 -3
- package/skills/jgengine-multiplayer/api.md +91 -83
- package/skills/jgengine-multiplayer/reference.md +27 -3
- package/skills/jgengine-multiplayer/references/peer-worlds.md +62 -0
|
@@ -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
|
|
8
|
+
const joinedServers = new Map();
|
|
9
9
|
const onPageHide = () => {
|
|
10
|
-
for (const serverId of joinedServers) {
|
|
11
|
-
|
|
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.
|
|
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.
|
|
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
|
},
|
package/dist/hostedServer.d.ts
CHANGED
|
@@ -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:
|
|
63
|
+
serverState: WorldSnapshot;
|
|
63
64
|
memberUserIds: string[];
|
|
64
65
|
} | null>>;
|
|
65
66
|
joinHostedServer: import("convex/server").RegisteredMutation<"public", {
|
package/dist/hostedServer.js
CHANGED
|
@@ -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().
|
|
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:
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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;
|