@jgengine/convex 0.10.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 +13 -2
- 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 +4 -2
- package/dist/hostedServer.js +8 -5
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/occ.d.ts +2 -0
- package/dist/occ.js +2 -0
- package/dist/server.d.ts +30 -3
- package/dist/server.js +55 -14
- 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 -974
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
|
+
}
|
package/dist/hostedServer.d.ts
CHANGED
|
@@ -32,7 +32,8 @@ export interface HostedWorldOutcome<T> {
|
|
|
32
32
|
* The reconstruct → op → save core of the stateless hosted path, Convex-agnostic so it unit-tests against
|
|
33
33
|
* `memoryWorldStore`: build a fresh {@link createHostedWorldSession} from `store.load()`, resume persisted
|
|
34
34
|
* members and replay held inputs, run the op, then persist the snapshot at `revision + 1` iff the world changed.
|
|
35
|
-
|
|
35
|
+
* @internal
|
|
36
|
+
*/
|
|
36
37
|
export declare function invokeHostedWorld<T>(invocation: HostedWorldInvocation<T>): HostedWorldOutcome<T>;
|
|
37
38
|
/** Default minimum elapsed ms before `tickHostedWorlds` advances a world — override via the factory's `tickMs`. */
|
|
38
39
|
export declare const JG_HOSTED_TICK_MS = 1000;
|
|
@@ -41,7 +42,8 @@ export declare const JG_HOSTED_TICK_MS = 1000;
|
|
|
41
42
|
* reconstructing a hosted session from the persisted `jgHostedWorlds` row on every invocation via
|
|
42
43
|
* {@link invokeHostedWorld}. Destructure + re-export the returned functions from a `convex/` module and
|
|
43
44
|
* drive `tickHostedWorlds` from a cron. Requires {@link jgengineHostedTables} in the deployment schema.
|
|
44
|
-
|
|
45
|
+
* @internal
|
|
46
|
+
*/
|
|
45
47
|
export declare function createHostedGameServerFunctions(options: {
|
|
46
48
|
games: Record<string, HostedGameConfig>;
|
|
47
49
|
auth?: JgAuthMode;
|
package/dist/hostedServer.js
CHANGED
|
@@ -8,7 +8,8 @@ import { jgengineHostedTables, resolveActor } from "./server.js";
|
|
|
8
8
|
* The reconstruct → op → save core of the stateless hosted path, Convex-agnostic so it unit-tests against
|
|
9
9
|
* `memoryWorldStore`: build a fresh {@link createHostedWorldSession} from `store.load()`, resume persisted
|
|
10
10
|
* members and replay held inputs, run the op, then persist the snapshot at `revision + 1` iff the world changed.
|
|
11
|
-
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
12
13
|
export function invokeHostedWorld(invocation) {
|
|
13
14
|
const { game, store, members = [], inputs = {}, now, op } = invocation;
|
|
14
15
|
const loaded = store.load();
|
|
@@ -94,7 +95,8 @@ async function persistWorldRow(ctx, args) {
|
|
|
94
95
|
* reconstructing a hosted session from the persisted `jgHostedWorlds` row on every invocation via
|
|
95
96
|
* {@link invokeHostedWorld}. Destructure + re-export the returned functions from a `convex/` module and
|
|
96
97
|
* drive `tickHostedWorlds` from a cron. Requires {@link jgengineHostedTables} in the deployment schema.
|
|
97
|
-
|
|
98
|
+
* @internal
|
|
99
|
+
*/
|
|
98
100
|
export function createHostedGameServerFunctions(options) {
|
|
99
101
|
const mode = options.auth ?? "anonymous";
|
|
100
102
|
const tickMs = options.tickMs ?? JG_HOSTED_TICK_MS;
|
|
@@ -302,7 +304,10 @@ export function createHostedGameServerFunctions(options) {
|
|
|
302
304
|
args: {},
|
|
303
305
|
handler: async (ctx) => {
|
|
304
306
|
const nowMs = Date.now();
|
|
305
|
-
const rows = await ctx.db
|
|
307
|
+
const rows = await ctx.db
|
|
308
|
+
.query("jgHostedWorlds")
|
|
309
|
+
.withIndex("by_tick_anchor", (q) => q.lte("tickAnchorMs", nowMs - tickMs))
|
|
310
|
+
.collect();
|
|
306
311
|
let ticked = 0;
|
|
307
312
|
let saved = 0;
|
|
308
313
|
for (const row of rows) {
|
|
@@ -312,8 +317,6 @@ export function createHostedGameServerFunctions(options) {
|
|
|
312
317
|
if (row.memberUserIds.length === 0)
|
|
313
318
|
continue;
|
|
314
319
|
const elapsedMs = nowMs - row.tickAnchorMs;
|
|
315
|
-
if (elapsedMs < tickMs)
|
|
316
|
-
continue;
|
|
317
320
|
const { store, captured } = worldStoreForRow(row);
|
|
318
321
|
const outcome = invokeHostedWorld({
|
|
319
322
|
game,
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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";
|
|
5
6
|
export * from "./hostedServer.js";
|
|
6
7
|
export * from "./occ.js";
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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";
|
|
5
6
|
export * from "./hostedServer.js";
|
|
6
7
|
export * from "./occ.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)
|
package/dist/server.d.ts
CHANGED
|
@@ -4,7 +4,8 @@ import type { ChatMessage } from "@jgengine/core/game/chat";
|
|
|
4
4
|
import type { ChatSendOutcome } from "@jgengine/core/multiplayer/chatContract";
|
|
5
5
|
import { type PoseSyncRules } from "@jgengine/core/multiplayer/presenceModel";
|
|
6
6
|
import type { GameRuntime } from "@jgengine/core/runtime/gameRuntime";
|
|
7
|
-
import type { LeaderboardIncrement } from "@jgengine/core/runtime/hostPersistence";
|
|
7
|
+
import type { LeaderboardIncrement, SessionVisibility } from "@jgengine/core/runtime/hostPersistence";
|
|
8
|
+
/** @internal */
|
|
8
9
|
export declare function jgengineTables(): {
|
|
9
10
|
jgGameServers: import("convex/server").TableDefinition<import("convex/values").VObject<{
|
|
10
11
|
createdAt: number;
|
|
@@ -55,7 +56,9 @@ export declare function jgengineTables(): {
|
|
|
55
56
|
createdAt: import("convex/values").VFloat64<number, "required">;
|
|
56
57
|
updatedAt: import("convex/values").VFloat64<number, "required">;
|
|
57
58
|
}, "required", "createdAt" | "dirtyAt" | "gameId" | "joinCode" | "lastSavedAt" | "memberUserIds" | "mode" | "modeConfig" | "revision" | "save" | "save.auto" | "save.scope" | "serverState" | "sessionPlayers" | "slotsPerServer" | "status" | "tickAnchorMs" | "updatedAt" | "visibility" | `modeConfig.${string}` | `serverState.${string}` | `sessionPlayers.${string}`>, {
|
|
59
|
+
by_dirty: ["dirtyAt", "_creationTime"];
|
|
58
60
|
by_game_and_status: ["gameId", "status", "_creationTime"];
|
|
61
|
+
by_status: ["status", "_creationTime"];
|
|
59
62
|
}, {}, {}>;
|
|
60
63
|
jgPlayerProfiles: import("convex/server").TableDefinition<import("convex/values").VObject<{
|
|
61
64
|
createdAt: number;
|
|
@@ -167,7 +170,8 @@ export declare function jgengineTables(): {
|
|
|
167
170
|
* Convex table for the hosted GameContext-world path: one row per (gameId, serverId) holding the full
|
|
168
171
|
* `WorldSnapshot` blob, its revision, the member roster, and each member's held input. A sibling of
|
|
169
172
|
* {@link jgengineTables} — spread either (or both) into `defineSchema`.
|
|
170
|
-
|
|
173
|
+
* @internal
|
|
174
|
+
*/
|
|
171
175
|
export declare function jgengineHostedTables(): {
|
|
172
176
|
jgHostedWorlds: import("convex/server").TableDefinition<import("convex/values").VObject<{
|
|
173
177
|
createdAt: number;
|
|
@@ -191,6 +195,7 @@ export declare function jgengineHostedTables(): {
|
|
|
191
195
|
updatedAt: import("convex/values").VFloat64<number, "required">;
|
|
192
196
|
}, "required", "createdAt" | "gameId" | "inputs" | "memberUserIds" | "revision" | "serverId" | "snapshot" | "tickAnchorMs" | "updatedAt" | `inputs.${string}` | `snapshot.${string}`>, {
|
|
193
197
|
by_game_and_server: ["gameId", "serverId", "_creationTime"];
|
|
198
|
+
by_tick_anchor: ["tickAnchorMs", "_creationTime"];
|
|
194
199
|
}, {}, {}>;
|
|
195
200
|
};
|
|
196
201
|
declare const schemaForTypes: import("convex/server").SchemaDefinition<{
|
|
@@ -243,7 +248,9 @@ declare const schemaForTypes: import("convex/server").SchemaDefinition<{
|
|
|
243
248
|
createdAt: import("convex/values").VFloat64<number, "required">;
|
|
244
249
|
updatedAt: import("convex/values").VFloat64<number, "required">;
|
|
245
250
|
}, "required", "createdAt" | "dirtyAt" | "gameId" | "joinCode" | "lastSavedAt" | "memberUserIds" | "mode" | "modeConfig" | "revision" | "save" | "save.auto" | "save.scope" | "serverState" | "sessionPlayers" | "slotsPerServer" | "status" | "tickAnchorMs" | "updatedAt" | "visibility" | `modeConfig.${string}` | `serverState.${string}` | `sessionPlayers.${string}`>, {
|
|
251
|
+
by_dirty: ["dirtyAt", "_creationTime"];
|
|
246
252
|
by_game_and_status: ["gameId", "status", "_creationTime"];
|
|
253
|
+
by_status: ["status", "_creationTime"];
|
|
247
254
|
}, {}, {}>;
|
|
248
255
|
jgPlayerProfiles: import("convex/server").TableDefinition<import("convex/values").VObject<{
|
|
249
256
|
createdAt: number;
|
|
@@ -355,12 +362,28 @@ type JGDataModel = DataModelFromSchemaDefinition<typeof schemaForTypes>;
|
|
|
355
362
|
type JGMutationCtx = GenericMutationCtx<JGDataModel>;
|
|
356
363
|
export type JgAuthMode = "anonymous" | "required";
|
|
357
364
|
export declare const DEFAULT_CONVEX_POSE_RULES: PoseSyncRules;
|
|
358
|
-
/** Resolve the acting user id under a {@link JgAuthMode}: the Convex identity when signed in (rejecting a mismatched claim), the claimed external id under `"anonymous"`, else `null`.
|
|
365
|
+
/** Resolve the acting user id under a {@link JgAuthMode}: the Convex identity when signed in (rejecting a mismatched claim), the claimed external id under `"anonymous"`, else `null`.
|
|
366
|
+
* @internal
|
|
367
|
+
*/
|
|
359
368
|
export declare function resolveActor(ctx: {
|
|
360
369
|
auth: Auth;
|
|
361
370
|
}, claimedExternalId: string | undefined, mode: JgAuthMode): Promise<string | null>;
|
|
371
|
+
/** True when a server's `visibility` should surface in public listings/browse results.
|
|
372
|
+
* @internal
|
|
373
|
+
*/
|
|
374
|
+
export declare function isListablePublicly(visibility: SessionVisibility | undefined): boolean;
|
|
375
|
+
/** A non-member may enter a private server only by presenting its `joinCode`; members always pass.
|
|
376
|
+
* @internal
|
|
377
|
+
*/
|
|
378
|
+
export declare function canJoinPrivateServer(args: {
|
|
379
|
+
isMember: boolean;
|
|
380
|
+
joinCode: string | undefined;
|
|
381
|
+
suppliedCode: string | undefined;
|
|
382
|
+
}): boolean;
|
|
383
|
+
/** @internal */
|
|
362
384
|
export declare function applyLeaderboardIncrements(ctx: JGMutationCtx, gameId: string, entries: LeaderboardIncrement[]): Promise<void>;
|
|
363
385
|
export declare const JG_RUNTIME_TICK_MS = 1000;
|
|
386
|
+
/** @internal */
|
|
364
387
|
export declare function createGameServerFunctions(options?: {
|
|
365
388
|
runtimes?: GameRuntime[];
|
|
366
389
|
auth?: JgAuthMode;
|
|
@@ -460,6 +483,7 @@ export declare function createGameServerFunctions(options?: {
|
|
|
460
483
|
saved: number;
|
|
461
484
|
}>>;
|
|
462
485
|
};
|
|
486
|
+
/** @internal */
|
|
463
487
|
export declare function createLeaderboardFunctions(options?: {
|
|
464
488
|
auth?: JgAuthMode;
|
|
465
489
|
}): {
|
|
@@ -490,6 +514,7 @@ export declare function createLeaderboardFunctions(options?: {
|
|
|
490
514
|
gameId: string;
|
|
491
515
|
}, Promise<null>>;
|
|
492
516
|
};
|
|
517
|
+
/** @internal */
|
|
493
518
|
export declare function createPresenceFunctions(options?: {
|
|
494
519
|
auth?: JgAuthMode;
|
|
495
520
|
freshWindowMs?: number;
|
|
@@ -525,6 +550,7 @@ export declare function createPresenceFunctions(options?: {
|
|
|
525
550
|
serverId: string;
|
|
526
551
|
}, Promise<null>>;
|
|
527
552
|
};
|
|
553
|
+
/** @internal */
|
|
528
554
|
export declare function createChatFunctions(options?: {
|
|
529
555
|
auth?: JgAuthMode;
|
|
530
556
|
historyLimit?: number;
|
|
@@ -548,5 +574,6 @@ export type JgCronSpec = {
|
|
|
548
574
|
intervalSeconds: number;
|
|
549
575
|
functionKey: "tickActiveServers" | "flushDirtyServers";
|
|
550
576
|
};
|
|
577
|
+
/** @internal */
|
|
551
578
|
export declare function jgengineCronSpecs(): readonly JgCronSpec[];
|
|
552
579
|
export {};
|
package/dist/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineSchema, defineTable, internalMutationGeneric, mutationGeneric, queryGeneric, } from "convex/server";
|
|
2
2
|
import { ConvexError, v } from "convex/values";
|
|
3
3
|
import { createFeedWriteGate, validateFeedWrite, } from "@jgengine/core/multiplayer/feedWriteGate";
|
|
4
|
+
import { normalizeJoinCode } from "@jgengine/core/multiplayer/matchmaking";
|
|
4
5
|
import { decidePoseSync, spawnPresenceState, } from "@jgengine/core/multiplayer/presenceModel";
|
|
5
6
|
import { createGameRuntime } from "@jgengine/core/runtime/gameRuntime";
|
|
6
7
|
import { buildHydratePlayers, planServerPersist, shouldAutoSave, trimFeedEntries } from "@jgengine/core/runtime/hostPersistence";
|
|
@@ -10,6 +11,7 @@ const saveConfigValidator = v.union(v.literal("none"), v.object({
|
|
|
10
11
|
auto: v.string(),
|
|
11
12
|
scope: v.union(v.literal("player"), v.literal("chunks"), v.literal("player+chunks")),
|
|
12
13
|
}));
|
|
14
|
+
/** @internal */
|
|
13
15
|
export function jgengineTables() {
|
|
14
16
|
return {
|
|
15
17
|
jgGameServers: defineTable({
|
|
@@ -30,7 +32,10 @@ export function jgengineTables() {
|
|
|
30
32
|
dirtyAt: v.optional(v.number()),
|
|
31
33
|
createdAt: v.number(),
|
|
32
34
|
updatedAt: v.number(),
|
|
33
|
-
})
|
|
35
|
+
})
|
|
36
|
+
.index("by_game_and_status", ["gameId", "status"])
|
|
37
|
+
.index("by_status", ["status"])
|
|
38
|
+
.index("by_dirty", ["dirtyAt"]),
|
|
34
39
|
jgPlayerProfiles: defineTable({
|
|
35
40
|
userId: v.string(),
|
|
36
41
|
gameId: v.string(),
|
|
@@ -91,7 +96,8 @@ export function jgengineTables() {
|
|
|
91
96
|
* Convex table for the hosted GameContext-world path: one row per (gameId, serverId) holding the full
|
|
92
97
|
* `WorldSnapshot` blob, its revision, the member roster, and each member's held input. A sibling of
|
|
93
98
|
* {@link jgengineTables} — spread either (or both) into `defineSchema`.
|
|
94
|
-
|
|
99
|
+
* @internal
|
|
100
|
+
*/
|
|
95
101
|
export function jgengineHostedTables() {
|
|
96
102
|
return {
|
|
97
103
|
jgHostedWorlds: defineTable({
|
|
@@ -104,7 +110,9 @@ export function jgengineHostedTables() {
|
|
|
104
110
|
tickAnchorMs: v.number(),
|
|
105
111
|
createdAt: v.number(),
|
|
106
112
|
updatedAt: v.number(),
|
|
107
|
-
})
|
|
113
|
+
})
|
|
114
|
+
.index("by_game_and_server", ["gameId", "serverId"])
|
|
115
|
+
.index("by_tick_anchor", ["tickAnchorMs"]),
|
|
108
116
|
};
|
|
109
117
|
}
|
|
110
118
|
const schemaForTypes = defineSchema(jgengineTables());
|
|
@@ -118,7 +126,9 @@ export const DEFAULT_CONVEX_POSE_RULES = {
|
|
|
118
126
|
maxElapsedSec: 0.5,
|
|
119
127
|
keepAliveRefreshMs: 10_000,
|
|
120
128
|
};
|
|
121
|
-
/** Resolve the acting user id under a {@link JgAuthMode}: the Convex identity when signed in (rejecting a mismatched claim), the claimed external id under `"anonymous"`, else `null`.
|
|
129
|
+
/** Resolve the acting user id under a {@link JgAuthMode}: the Convex identity when signed in (rejecting a mismatched claim), the claimed external id under `"anonymous"`, else `null`.
|
|
130
|
+
* @internal
|
|
131
|
+
*/
|
|
122
132
|
export async function resolveActor(ctx, claimedExternalId, mode) {
|
|
123
133
|
const identity = await ctx.auth.getUserIdentity();
|
|
124
134
|
if (identity !== null) {
|
|
@@ -134,6 +144,22 @@ export async function resolveActor(ctx, claimedExternalId, mode) {
|
|
|
134
144
|
}
|
|
135
145
|
return null;
|
|
136
146
|
}
|
|
147
|
+
/** True when a server's `visibility` should surface in public listings/browse results.
|
|
148
|
+
* @internal
|
|
149
|
+
*/
|
|
150
|
+
export function isListablePublicly(visibility) {
|
|
151
|
+
return (visibility ?? "public") !== "private";
|
|
152
|
+
}
|
|
153
|
+
/** A non-member may enter a private server only by presenting its `joinCode`; members always pass.
|
|
154
|
+
* @internal
|
|
155
|
+
*/
|
|
156
|
+
export function canJoinPrivateServer(args) {
|
|
157
|
+
if (args.isMember)
|
|
158
|
+
return true;
|
|
159
|
+
if (args.joinCode === undefined || args.suppliedCode === undefined)
|
|
160
|
+
return false;
|
|
161
|
+
return normalizeJoinCode(args.suppliedCode) === normalizeJoinCode(args.joinCode);
|
|
162
|
+
}
|
|
137
163
|
async function requireServerMember(ctx, serverId, actorUserId) {
|
|
138
164
|
const server = await ctx.db.get("jgGameServers", serverId);
|
|
139
165
|
if (!server)
|
|
@@ -223,6 +249,7 @@ async function loadServerSnapshot(ctx, server, runtime) {
|
|
|
223
249
|
revision: server.revision,
|
|
224
250
|
});
|
|
225
251
|
}
|
|
252
|
+
/** @internal */
|
|
226
253
|
export async function applyLeaderboardIncrements(ctx, gameId, entries) {
|
|
227
254
|
const now = Date.now();
|
|
228
255
|
for (const entry of entries) {
|
|
@@ -324,6 +351,7 @@ async function flushServerIfDue(ctx, server, now, runtime) {
|
|
|
324
351
|
return true;
|
|
325
352
|
}
|
|
326
353
|
export const JG_RUNTIME_TICK_MS = 1_000;
|
|
354
|
+
/** @internal */
|
|
327
355
|
export function createGameServerFunctions(options) {
|
|
328
356
|
const mode = options?.auth ?? "required";
|
|
329
357
|
const hostSlotsPerServer = options?.slotsPerServer ?? 16;
|
|
@@ -404,8 +432,11 @@ export function createGameServerFunctions(options) {
|
|
|
404
432
|
throw new ConvexError("Server is full");
|
|
405
433
|
}
|
|
406
434
|
if ((server.visibility ?? "public") === "private" &&
|
|
407
|
-
!
|
|
408
|
-
|
|
435
|
+
!canJoinPrivateServer({
|
|
436
|
+
isMember: server.memberUserIds.includes(actorUserId),
|
|
437
|
+
joinCode: server.joinCode,
|
|
438
|
+
suppliedCode: args.joinCode,
|
|
439
|
+
})) {
|
|
409
440
|
throw new ConvexError("Server is private");
|
|
410
441
|
}
|
|
411
442
|
const profile = await ctx.db
|
|
@@ -691,11 +722,15 @@ export function createGameServerFunctions(options) {
|
|
|
691
722
|
updatedAt: v.number(),
|
|
692
723
|
})),
|
|
693
724
|
handler: async (ctx, args) => {
|
|
725
|
+
const limit = args.limit ?? 20;
|
|
694
726
|
const rows = await ctx.db
|
|
695
727
|
.query("jgGameServers")
|
|
696
728
|
.withIndex("by_game_and_status", (q) => q.eq("gameId", args.gameId).eq("status", "running"))
|
|
697
|
-
.take(
|
|
698
|
-
return rows
|
|
729
|
+
.take(Math.min(limit * 4, 200));
|
|
730
|
+
return rows
|
|
731
|
+
.filter((row) => isListablePublicly(row.visibility))
|
|
732
|
+
.slice(0, limit)
|
|
733
|
+
.map((row) => ({
|
|
699
734
|
_id: row._id,
|
|
700
735
|
status: row.status,
|
|
701
736
|
memberCount: row.memberUserIds.length,
|
|
@@ -709,12 +744,13 @@ export function createGameServerFunctions(options) {
|
|
|
709
744
|
args: {},
|
|
710
745
|
handler: async (ctx) => {
|
|
711
746
|
const now = Date.now();
|
|
712
|
-
const servers = await ctx.db
|
|
747
|
+
const servers = await ctx.db
|
|
748
|
+
.query("jgGameServers")
|
|
749
|
+
.withIndex("by_status", (q) => q.eq("status", "running"))
|
|
750
|
+
.collect();
|
|
713
751
|
let ticked = 0;
|
|
714
752
|
let saved = 0;
|
|
715
753
|
for (const server of servers) {
|
|
716
|
-
if (server.status !== "running")
|
|
717
|
-
continue;
|
|
718
754
|
if (server.memberUserIds.length === 0)
|
|
719
755
|
continue;
|
|
720
756
|
const elapsedMs = now - server.tickAnchorMs;
|
|
@@ -738,11 +774,12 @@ export function createGameServerFunctions(options) {
|
|
|
738
774
|
args: {},
|
|
739
775
|
handler: async (ctx) => {
|
|
740
776
|
const now = Date.now();
|
|
741
|
-
const servers = await ctx.db
|
|
777
|
+
const servers = await ctx.db
|
|
778
|
+
.query("jgGameServers")
|
|
779
|
+
.withIndex("by_dirty", (q) => q.gt("dirtyAt", 0))
|
|
780
|
+
.collect();
|
|
742
781
|
let saved = 0;
|
|
743
782
|
for (const server of servers) {
|
|
744
|
-
if (server.dirtyAt === undefined)
|
|
745
|
-
continue;
|
|
746
783
|
const runtime = resolveRuntime(registry, server.gameId);
|
|
747
784
|
const snapshot = await loadServerSnapshot(ctx, server, runtime);
|
|
748
785
|
await persistServerSnapshot(ctx, server, clearDirtyFlags(snapshot), server.save);
|
|
@@ -768,6 +805,7 @@ export function createGameServerFunctions(options) {
|
|
|
768
805
|
}
|
|
769
806
|
const leaderboardScopeValidator = v.union(v.literal("global"), v.literal("server"), v.literal("profile"));
|
|
770
807
|
const MAX_LEADERBOARD_TOP_LIMIT = 100;
|
|
808
|
+
/** @internal */
|
|
771
809
|
export function createLeaderboardFunctions(options) {
|
|
772
810
|
void options;
|
|
773
811
|
const getTop = query({
|
|
@@ -831,6 +869,7 @@ export function createLeaderboardFunctions(options) {
|
|
|
831
869
|
});
|
|
832
870
|
return { getTop, getProfile, incrementMany };
|
|
833
871
|
}
|
|
872
|
+
/** @internal */
|
|
834
873
|
export function createPresenceFunctions(options) {
|
|
835
874
|
const mode = options?.auth ?? "required";
|
|
836
875
|
const freshWindowMs = options?.freshWindowMs ?? 10_000;
|
|
@@ -946,6 +985,7 @@ export function createPresenceFunctions(options) {
|
|
|
946
985
|
});
|
|
947
986
|
return { list, sync, leave };
|
|
948
987
|
}
|
|
988
|
+
/** @internal */
|
|
949
989
|
export function createChatFunctions(options) {
|
|
950
990
|
const mode = options?.auth ?? "required";
|
|
951
991
|
const historyLimit = options?.historyLimit ?? 100;
|
|
@@ -1041,6 +1081,7 @@ const JG_CRON_SPECS = [
|
|
|
1041
1081
|
{ name: "jg tick", intervalSeconds: 1, functionKey: "tickActiveServers" },
|
|
1042
1082
|
{ name: "jg flush", intervalSeconds: 60, functionKey: "flushDirtyServers" },
|
|
1043
1083
|
];
|
|
1084
|
+
/** @internal */
|
|
1044
1085
|
export function jgengineCronSpecs() {
|
|
1045
1086
|
return JG_CRON_SPECS;
|
|
1046
1087
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jgengine/convex",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Convex adapters for JGengine: game transport, presence transport, and backend wiring over @jgengine/core.",
|
|
5
|
-
"license": "
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"sideEffects": false,
|
|
8
8
|
"repository": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"files": [
|
|
14
14
|
"dist",
|
|
15
15
|
"CHANGELOG.md",
|
|
16
|
-
"
|
|
16
|
+
"skills"
|
|
17
17
|
],
|
|
18
18
|
"exports": {
|
|
19
19
|
".": {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"check-types": "tsgo --noEmit -p tsconfig.json"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@jgengine/core": "^0.
|
|
33
|
+
"@jgengine/core": "^0.11.0"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
36
|
"convex": "^1.31.2",
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: jgengine
|
|
3
|
+
description: Route game work to the smallest useful JGengine guidance set.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# JGengine intake and router
|
|
7
|
+
|
|
8
|
+
Read the repository [README](../../../README.md) for package truth, commands, layering, and license. This skill decides what to load; it is not an engine manual.
|
|
9
|
+
|
|
10
|
+
## 1. Establish the target
|
|
11
|
+
|
|
12
|
+
Capture only decisions that change architecture:
|
|
13
|
+
|
|
14
|
+
- player point of view and controls
|
|
15
|
+
- world shape and authored content
|
|
16
|
+
- core loop, interaction, combat, and progression
|
|
17
|
+
- solo, local, hosted, or persistent multiplayer
|
|
18
|
+
- HUD/menu states and art direction
|
|
19
|
+
- one observable completion scenario
|
|
20
|
+
|
|
21
|
+
For a new in-repo game, scaffold with `bun run new:game <id> --name "Title"`. Outside this monorepo, start with `npx jgengine create`; never copy `Games/*`.
|
|
22
|
+
|
|
23
|
+
## 2. Select domains
|
|
24
|
+
|
|
25
|
+
Load only rows the target needs. Do not preload every domain.
|
|
26
|
+
|
|
27
|
+
| Need | Skill | Owner |
|
|
28
|
+
| --- | --- | --- |
|
|
29
|
+
| scene placement, terrain painting, paths, asset authoring | `jgengine-editor` | editor document and authoring tools |
|
|
30
|
+
| runtime world, movement, input, interaction, AI, navigation, audio | `jgengine-world` | spatial simulation and world runtime |
|
|
31
|
+
| game state, systems, inventory, crafting, quests, economy, turns | `jgengine-gameplay` | non-combat rules and state |
|
|
32
|
+
| targeting, stats, damage, effects, abilities, projectiles, loot | `jgengine-combat` | combat resolution |
|
|
33
|
+
| HUD, menus, layout, accessibility, touch presentation | `jgengine-ui` | player-facing interface |
|
|
34
|
+
| models, sprites, materials, audio files, source licensing | `jgengine-assets` | asset discovery and catalogs |
|
|
35
|
+
| authority, transport, replication, sessions, host persistence | `jgengine-multiplayer` | network topology and persistence adapters |
|
|
36
|
+
|
|
37
|
+
Scenes normally require both `jgengine-editor` for authoring and `jgengine-world` for runtime consumption. Combat and multiplayer are opt-in, not default intake.
|
|
38
|
+
|
|
39
|
+
## 3. Discover before designing
|
|
40
|
+
|
|
41
|
+
Within each selected domain:
|
|
42
|
+
|
|
43
|
+
1. Search `capabilities.md` by intent; it maps needs to imports.
|
|
44
|
+
2. Open `api.md` only when exact signatures or export inventory are needed.
|
|
45
|
+
3. Open the linked reference only for a deeper recipe or trap.
|
|
46
|
+
|
|
47
|
+
If no capability fits, identify the upstream package seam before writing game-local code. In this repository, implement a reusable primitive with its first adopter; if editor-owned content cannot be expressed, file the editor gap first.
|
|
48
|
+
|
|
49
|
+
## 4. Build and finish
|
|
50
|
+
|
|
51
|
+
Keep scene coordinates in the editor document, state serializable, randomness injected, and hot-path work bounded. Use `jgengine-verify` for evidence appropriate to the change. Inside this monorepo, `workflow` owns issue/PR completion. Visual polish work also uses the `jgengine-ui` quality reference.
|