@jgengine/convex 0.10.0 → 0.12.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 +39 -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 +7 -3
- package/dist/hostedServer.js +9 -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 +19 -3
- package/dist/server.js +120 -122
- package/package.json +4 -4
- package/skills/jgengine/SKILL.md +54 -0
- package/skills/jgengine/api.md +527 -0
- package/skills/jgengine/capabilities.md +131 -0
- package/skills/jgengine/recipes/minimal-game.md +88 -0
- package/skills/jgengine-multiplayer/SKILL.md +47 -0
- package/skills/jgengine-multiplayer/api.md +620 -0
- package/skills/jgengine-multiplayer/capabilities.md +17 -0
- package/skills/jgengine-multiplayer/reference.md +71 -0
- package/llms.txt +0 -974
package/dist/server.js
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
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 { decidePoseSync, spawnPresenceState, } from "@jgengine/core/multiplayer/presenceModel";
|
|
4
|
+
import { DEFAULT_POSE_SYNC_RULES, decidePoseSync, spawnPresenceState, } from "@jgengine/core/multiplayer/presenceModel";
|
|
5
5
|
import { createGameRuntime } from "@jgengine/core/runtime/gameRuntime";
|
|
6
6
|
import { buildHydratePlayers, planServerPersist, shouldAutoSave, trimFeedEntries } from "@jgengine/core/runtime/hostPersistence";
|
|
7
|
+
import { isAutoJoinCandidate, isListablePublicly, isPrivateJoinBlocked, isServerFull, isServerMember, statusAfterLeave, withJoinedMember, withoutMember, } from "@jgengine/core/runtime/hostPolicy";
|
|
7
8
|
import { clearDirtyFlags, createEmptyServerRow } from "@jgengine/core/runtime/snapshot";
|
|
8
9
|
import { applyCommandWithOcc, commitIfRevisionMatch } from "./occ.js";
|
|
10
|
+
/** @internal Re-export shared host-policy pure helpers so existing convex imports keep working. */
|
|
11
|
+
export { canJoinPrivateServer, isListablePublicly, } from "@jgengine/core/runtime/hostPolicy";
|
|
9
12
|
const saveConfigValidator = v.union(v.literal("none"), v.object({
|
|
10
13
|
auto: v.string(),
|
|
11
14
|
scope: v.union(v.literal("player"), v.literal("chunks"), v.literal("player+chunks")),
|
|
12
15
|
}));
|
|
16
|
+
/** @internal */
|
|
13
17
|
export function jgengineTables() {
|
|
14
18
|
return {
|
|
15
19
|
jgGameServers: defineTable({
|
|
@@ -30,7 +34,10 @@ export function jgengineTables() {
|
|
|
30
34
|
dirtyAt: v.optional(v.number()),
|
|
31
35
|
createdAt: v.number(),
|
|
32
36
|
updatedAt: v.number(),
|
|
33
|
-
})
|
|
37
|
+
})
|
|
38
|
+
.index("by_game_and_status", ["gameId", "status"])
|
|
39
|
+
.index("by_status", ["status"])
|
|
40
|
+
.index("by_dirty", ["dirtyAt"]),
|
|
34
41
|
jgPlayerProfiles: defineTable({
|
|
35
42
|
userId: v.string(),
|
|
36
43
|
gameId: v.string(),
|
|
@@ -91,7 +98,8 @@ export function jgengineTables() {
|
|
|
91
98
|
* Convex table for the hosted GameContext-world path: one row per (gameId, serverId) holding the full
|
|
92
99
|
* `WorldSnapshot` blob, its revision, the member roster, and each member's held input. A sibling of
|
|
93
100
|
* {@link jgengineTables} — spread either (or both) into `defineSchema`.
|
|
94
|
-
|
|
101
|
+
* @internal
|
|
102
|
+
*/
|
|
95
103
|
export function jgengineHostedTables() {
|
|
96
104
|
return {
|
|
97
105
|
jgHostedWorlds: defineTable({
|
|
@@ -104,21 +112,19 @@ export function jgengineHostedTables() {
|
|
|
104
112
|
tickAnchorMs: v.number(),
|
|
105
113
|
createdAt: v.number(),
|
|
106
114
|
updatedAt: v.number(),
|
|
107
|
-
})
|
|
115
|
+
})
|
|
116
|
+
.index("by_game_and_server", ["gameId", "serverId"])
|
|
117
|
+
.index("by_tick_anchor", ["tickAnchorMs"]),
|
|
108
118
|
};
|
|
109
119
|
}
|
|
110
120
|
const schemaForTypes = defineSchema(jgengineTables());
|
|
111
121
|
const query = queryGeneric;
|
|
112
122
|
const mutation = mutationGeneric;
|
|
113
123
|
const internalMutation = internalMutationGeneric;
|
|
114
|
-
export const DEFAULT_CONVEX_POSE_RULES =
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
maxElapsedSec: 0.5,
|
|
119
|
-
keepAliveRefreshMs: 10_000,
|
|
120
|
-
};
|
|
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`. */
|
|
124
|
+
export const DEFAULT_CONVEX_POSE_RULES = DEFAULT_POSE_SYNC_RULES;
|
|
125
|
+
/** 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`.
|
|
126
|
+
* @internal
|
|
127
|
+
*/
|
|
122
128
|
export async function resolveActor(ctx, claimedExternalId, mode) {
|
|
123
129
|
const identity = await ctx.auth.getUserIdentity();
|
|
124
130
|
if (identity !== null) {
|
|
@@ -138,10 +144,34 @@ async function requireServerMember(ctx, serverId, actorUserId) {
|
|
|
138
144
|
const server = await ctx.db.get("jgGameServers", serverId);
|
|
139
145
|
if (!server)
|
|
140
146
|
return null;
|
|
141
|
-
if (!server.memberUserIds
|
|
147
|
+
if (!isServerMember(server.memberUserIds, actorUserId))
|
|
142
148
|
return null;
|
|
143
149
|
return server;
|
|
144
150
|
}
|
|
151
|
+
/** Resolve the acting user for a mutation, throwing the standard error when unauthenticated.
|
|
152
|
+
* @internal
|
|
153
|
+
*/
|
|
154
|
+
async function requireActor(ctx, externalId, mode) {
|
|
155
|
+
const actorUserId = await resolveActor(ctx, externalId, mode);
|
|
156
|
+
if (!actorUserId)
|
|
157
|
+
throw new ConvexError("Not authenticated");
|
|
158
|
+
return actorUserId;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Resolve the actor and confirm server membership, invoking `fn` only when both hold and yielding
|
|
162
|
+
* `deniedResult` for every denial (unauthenticated or non-member). The uniform-denial path shared by
|
|
163
|
+
* the read handlers whose not-authenticated and not-member cases return the same value.
|
|
164
|
+
* @internal
|
|
165
|
+
*/
|
|
166
|
+
async function withMember(ctx, mode, args, deniedResult, fn) {
|
|
167
|
+
const actorUserId = await resolveActor(ctx, args.externalId, mode);
|
|
168
|
+
if (!actorUserId)
|
|
169
|
+
return deniedResult;
|
|
170
|
+
const server = await requireServerMember(ctx, args.serverId, actorUserId);
|
|
171
|
+
if (server === null)
|
|
172
|
+
return deniedResult;
|
|
173
|
+
return fn(server, actorUserId);
|
|
174
|
+
}
|
|
145
175
|
const builtinRuntimeCommands = {
|
|
146
176
|
"engine.ping": {
|
|
147
177
|
validate: (_snapshot) => null,
|
|
@@ -223,6 +253,7 @@ async function loadServerSnapshot(ctx, server, runtime) {
|
|
|
223
253
|
revision: server.revision,
|
|
224
254
|
});
|
|
225
255
|
}
|
|
256
|
+
/** @internal */
|
|
226
257
|
export async function applyLeaderboardIncrements(ctx, gameId, entries) {
|
|
227
258
|
const now = Date.now();
|
|
228
259
|
for (const entry of entries) {
|
|
@@ -324,6 +355,7 @@ async function flushServerIfDue(ctx, server, now, runtime) {
|
|
|
324
355
|
return true;
|
|
325
356
|
}
|
|
326
357
|
export const JG_RUNTIME_TICK_MS = 1_000;
|
|
358
|
+
/** @internal */
|
|
327
359
|
export function createGameServerFunctions(options) {
|
|
328
360
|
const mode = options?.auth ?? "required";
|
|
329
361
|
const hostSlotsPerServer = options?.slotsPerServer ?? 16;
|
|
@@ -344,10 +376,7 @@ export function createGameServerFunctions(options) {
|
|
|
344
376
|
isNew: v.boolean(),
|
|
345
377
|
}),
|
|
346
378
|
handler: async (ctx, args) => {
|
|
347
|
-
const actorUserId = await
|
|
348
|
-
if (!actorUserId) {
|
|
349
|
-
throw new ConvexError("Not authenticated");
|
|
350
|
-
}
|
|
379
|
+
const actorUserId = await requireActor(ctx, args.externalId, mode);
|
|
351
380
|
const now = Date.now();
|
|
352
381
|
const runtime = resolveRuntime(registry, args.gameId);
|
|
353
382
|
const save = runtime.save;
|
|
@@ -369,13 +398,12 @@ export function createGameServerFunctions(options) {
|
|
|
369
398
|
joinable.push(...rows);
|
|
370
399
|
}
|
|
371
400
|
server =
|
|
372
|
-
joinable.find((row) => {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
}) ?? null;
|
|
401
|
+
joinable.find((row) => isAutoJoinCandidate({
|
|
402
|
+
memberUserIds: row.memberUserIds,
|
|
403
|
+
slotsPerServer: row.slotsPerServer,
|
|
404
|
+
visibility: row.visibility,
|
|
405
|
+
userId: actorUserId,
|
|
406
|
+
})) ?? null;
|
|
379
407
|
}
|
|
380
408
|
if (!server) {
|
|
381
409
|
const serverId = await ctx.db.insert("jgGameServers", {
|
|
@@ -399,13 +427,16 @@ export function createGameServerFunctions(options) {
|
|
|
399
427
|
if (!server)
|
|
400
428
|
throw new ConvexError("Failed to create server");
|
|
401
429
|
}
|
|
402
|
-
if (server.memberUserIds
|
|
403
|
-
!server.memberUserIds.includes(actorUserId)) {
|
|
430
|
+
if (isServerFull(server.memberUserIds, server.slotsPerServer, actorUserId)) {
|
|
404
431
|
throw new ConvexError("Server is full");
|
|
405
432
|
}
|
|
406
|
-
if ((
|
|
407
|
-
|
|
408
|
-
|
|
433
|
+
if (isPrivateJoinBlocked({
|
|
434
|
+
visibility: server.visibility,
|
|
435
|
+
memberUserIds: server.memberUserIds,
|
|
436
|
+
userId: 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
|
|
@@ -413,9 +444,7 @@ export function createGameServerFunctions(options) {
|
|
|
413
444
|
.withIndex("by_user_and_game", (q) => q.eq("userId", actorUserId).eq("gameId", args.gameId))
|
|
414
445
|
.unique();
|
|
415
446
|
const isNew = profile === null;
|
|
416
|
-
const memberUserIds = server.memberUserIds
|
|
417
|
-
? server.memberUserIds
|
|
418
|
-
: [...server.memberUserIds, actorUserId];
|
|
447
|
+
const memberUserIds = withJoinedMember(server.memberUserIds, actorUserId);
|
|
419
448
|
await ctx.db.patch(server._id, {
|
|
420
449
|
memberUserIds,
|
|
421
450
|
status: "running",
|
|
@@ -438,20 +467,15 @@ export function createGameServerFunctions(options) {
|
|
|
438
467
|
},
|
|
439
468
|
returns: v.null(),
|
|
440
469
|
handler: async (ctx, args) => {
|
|
441
|
-
const actorUserId = await
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
}
|
|
445
|
-
const server = await ctx.db.get("jgGameServers", args.serverId);
|
|
446
|
-
if (!server)
|
|
447
|
-
return null;
|
|
448
|
-
if (!server.memberUserIds.includes(actorUserId))
|
|
470
|
+
const actorUserId = await requireActor(ctx, args.externalId, mode);
|
|
471
|
+
const server = await requireServerMember(ctx, args.serverId, actorUserId);
|
|
472
|
+
if (server === null)
|
|
449
473
|
return null;
|
|
450
474
|
const now = Date.now();
|
|
451
475
|
const runtime = resolveRuntime(registry, server.gameId);
|
|
452
476
|
const snapshot = await loadServerSnapshot(ctx, server, runtime);
|
|
453
477
|
await persistServerSnapshot(ctx, server, snapshot, server.save);
|
|
454
|
-
const memberUserIds = server.memberUserIds
|
|
478
|
+
const memberUserIds = withoutMember(server.memberUserIds, actorUserId);
|
|
455
479
|
const sessionPlayers = { ...server.sessionPlayers };
|
|
456
480
|
delete sessionPlayers[actorUserId];
|
|
457
481
|
await ctx.db.patch(server._id, {
|
|
@@ -459,7 +483,7 @@ export function createGameServerFunctions(options) {
|
|
|
459
483
|
sessionPlayers,
|
|
460
484
|
updatedAt: now,
|
|
461
485
|
dirtyAt: now,
|
|
462
|
-
status: memberUserIds.length
|
|
486
|
+
status: statusAfterLeave(memberUserIds.length, server.status),
|
|
463
487
|
});
|
|
464
488
|
const pose = await ctx.db
|
|
465
489
|
.query("jgPoses")
|
|
@@ -479,15 +503,12 @@ export function createGameServerFunctions(options) {
|
|
|
479
503
|
},
|
|
480
504
|
returns: v.union(v.object({ ok: v.literal(true) }), v.object({ ok: v.literal(false), reason: v.string() })),
|
|
481
505
|
handler: async (ctx, args) => {
|
|
482
|
-
const actorUserId = await
|
|
483
|
-
if (!actorUserId) {
|
|
484
|
-
throw new ConvexError("Not authenticated");
|
|
485
|
-
}
|
|
506
|
+
const actorUserId = await requireActor(ctx, args.externalId, mode);
|
|
486
507
|
const server = await ctx.db.get("jgGameServers", args.serverId);
|
|
487
508
|
if (!server) {
|
|
488
509
|
return { ok: false, reason: "Server not found" };
|
|
489
510
|
}
|
|
490
|
-
if (!server.memberUserIds
|
|
511
|
+
if (!isServerMember(server.memberUserIds, actorUserId)) {
|
|
491
512
|
return { ok: false, reason: "Not a member of this server" };
|
|
492
513
|
}
|
|
493
514
|
const loadedRevision = server.revision;
|
|
@@ -524,14 +545,9 @@ export function createGameServerFunctions(options) {
|
|
|
524
545
|
},
|
|
525
546
|
returns: v.boolean(),
|
|
526
547
|
handler: async (ctx, args) => {
|
|
527
|
-
const actorUserId = await
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
}
|
|
531
|
-
const server = await ctx.db.get("jgGameServers", args.serverId);
|
|
532
|
-
if (!server)
|
|
533
|
-
return false;
|
|
534
|
-
if (!server.memberUserIds.includes(actorUserId))
|
|
548
|
+
const actorUserId = await requireActor(ctx, args.externalId, mode);
|
|
549
|
+
const server = await requireServerMember(ctx, args.serverId, actorUserId);
|
|
550
|
+
if (server === null)
|
|
535
551
|
return false;
|
|
536
552
|
const runtime = resolveRuntime(registry, server.gameId);
|
|
537
553
|
const snapshot = await loadServerSnapshot(ctx, server, runtime);
|
|
@@ -557,15 +573,7 @@ export function createGameServerFunctions(options) {
|
|
|
557
573
|
sessionPlayers: v.any(),
|
|
558
574
|
updatedAt: v.number(),
|
|
559
575
|
}), v.null()),
|
|
560
|
-
handler:
|
|
561
|
-
const actorUserId = await resolveActor(ctx, args.externalId, mode);
|
|
562
|
-
if (!actorUserId)
|
|
563
|
-
return null;
|
|
564
|
-
const server = await ctx.db.get("jgGameServers", args.serverId);
|
|
565
|
-
if (!server)
|
|
566
|
-
return null;
|
|
567
|
-
if (!server.memberUserIds.includes(actorUserId))
|
|
568
|
-
return null;
|
|
576
|
+
handler: (ctx, args) => withMember(ctx, mode, args, null, (server, actorUserId) => {
|
|
569
577
|
const sessionPlayers = server.sessionPlayers;
|
|
570
578
|
const ownSession = sessionPlayers[actorUserId];
|
|
571
579
|
return {
|
|
@@ -581,7 +589,7 @@ export function createGameServerFunctions(options) {
|
|
|
581
589
|
sessionPlayers: ownSession === undefined ? {} : { [actorUserId]: ownSession },
|
|
582
590
|
updatedAt: server.updatedAt,
|
|
583
591
|
};
|
|
584
|
-
},
|
|
592
|
+
}),
|
|
585
593
|
});
|
|
586
594
|
const getPlayerProfile = query({
|
|
587
595
|
args: {
|
|
@@ -622,17 +630,13 @@ export function createGameServerFunctions(options) {
|
|
|
622
630
|
},
|
|
623
631
|
returns: v.array(v.any()),
|
|
624
632
|
handler: async (ctx, args) => {
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
return [];
|
|
631
|
-
|
|
632
|
-
.query("jgFeedBuffers")
|
|
633
|
-
.withIndex("by_server_and_action", (q) => q.eq("serverId", args.serverId).eq("action", args.action))
|
|
634
|
-
.unique();
|
|
635
|
-
return row?.entries ?? [];
|
|
633
|
+
return withMember(ctx, mode, args, [], async () => {
|
|
634
|
+
const row = await ctx.db
|
|
635
|
+
.query("jgFeedBuffers")
|
|
636
|
+
.withIndex("by_server_and_action", (q) => q.eq("serverId", args.serverId).eq("action", args.action))
|
|
637
|
+
.unique();
|
|
638
|
+
return row?.entries ?? [];
|
|
639
|
+
});
|
|
636
640
|
},
|
|
637
641
|
});
|
|
638
642
|
const pushFeedEntry = mutation({
|
|
@@ -644,16 +648,13 @@ export function createGameServerFunctions(options) {
|
|
|
644
648
|
},
|
|
645
649
|
returns: v.null(),
|
|
646
650
|
handler: async (ctx, args) => {
|
|
647
|
-
const actorUserId = await
|
|
648
|
-
if (!actorUserId) {
|
|
649
|
-
throw new ConvexError("Not authenticated");
|
|
650
|
-
}
|
|
651
|
+
const actorUserId = await requireActor(ctx, args.externalId, mode);
|
|
651
652
|
const allowed = validateFeedWrite(feedWriteGate, args.action);
|
|
652
653
|
if (!allowed.ok) {
|
|
653
654
|
throw new ConvexError(allowed.reason);
|
|
654
655
|
}
|
|
655
656
|
const server = await ctx.db.get("jgGameServers", args.serverId);
|
|
656
|
-
if (!server || !server.memberUserIds
|
|
657
|
+
if (!server || !isServerMember(server.memberUserIds, actorUserId)) {
|
|
657
658
|
throw new ConvexError("Not a member of this server");
|
|
658
659
|
}
|
|
659
660
|
const now = Date.now();
|
|
@@ -691,11 +692,15 @@ export function createGameServerFunctions(options) {
|
|
|
691
692
|
updatedAt: v.number(),
|
|
692
693
|
})),
|
|
693
694
|
handler: async (ctx, args) => {
|
|
695
|
+
const limit = args.limit ?? 20;
|
|
694
696
|
const rows = await ctx.db
|
|
695
697
|
.query("jgGameServers")
|
|
696
698
|
.withIndex("by_game_and_status", (q) => q.eq("gameId", args.gameId).eq("status", "running"))
|
|
697
|
-
.take(
|
|
698
|
-
return rows
|
|
699
|
+
.take(Math.min(limit * 4, 200));
|
|
700
|
+
return rows
|
|
701
|
+
.filter((row) => isListablePublicly(row.visibility))
|
|
702
|
+
.slice(0, limit)
|
|
703
|
+
.map((row) => ({
|
|
699
704
|
_id: row._id,
|
|
700
705
|
status: row.status,
|
|
701
706
|
memberCount: row.memberUserIds.length,
|
|
@@ -709,12 +714,13 @@ export function createGameServerFunctions(options) {
|
|
|
709
714
|
args: {},
|
|
710
715
|
handler: async (ctx) => {
|
|
711
716
|
const now = Date.now();
|
|
712
|
-
const servers = await ctx.db
|
|
717
|
+
const servers = await ctx.db
|
|
718
|
+
.query("jgGameServers")
|
|
719
|
+
.withIndex("by_status", (q) => q.eq("status", "running"))
|
|
720
|
+
.collect();
|
|
713
721
|
let ticked = 0;
|
|
714
722
|
let saved = 0;
|
|
715
723
|
for (const server of servers) {
|
|
716
|
-
if (server.status !== "running")
|
|
717
|
-
continue;
|
|
718
724
|
if (server.memberUserIds.length === 0)
|
|
719
725
|
continue;
|
|
720
726
|
const elapsedMs = now - server.tickAnchorMs;
|
|
@@ -738,11 +744,12 @@ export function createGameServerFunctions(options) {
|
|
|
738
744
|
args: {},
|
|
739
745
|
handler: async (ctx) => {
|
|
740
746
|
const now = Date.now();
|
|
741
|
-
const servers = await ctx.db
|
|
747
|
+
const servers = await ctx.db
|
|
748
|
+
.query("jgGameServers")
|
|
749
|
+
.withIndex("by_dirty", (q) => q.gt("dirtyAt", 0))
|
|
750
|
+
.collect();
|
|
742
751
|
let saved = 0;
|
|
743
752
|
for (const server of servers) {
|
|
744
|
-
if (server.dirtyAt === undefined)
|
|
745
|
-
continue;
|
|
746
753
|
const runtime = resolveRuntime(registry, server.gameId);
|
|
747
754
|
const snapshot = await loadServerSnapshot(ctx, server, runtime);
|
|
748
755
|
await persistServerSnapshot(ctx, server, clearDirtyFlags(snapshot), server.save);
|
|
@@ -768,6 +775,7 @@ export function createGameServerFunctions(options) {
|
|
|
768
775
|
}
|
|
769
776
|
const leaderboardScopeValidator = v.union(v.literal("global"), v.literal("server"), v.literal("profile"));
|
|
770
777
|
const MAX_LEADERBOARD_TOP_LIMIT = 100;
|
|
778
|
+
/** @internal */
|
|
771
779
|
export function createLeaderboardFunctions(options) {
|
|
772
780
|
void options;
|
|
773
781
|
const getTop = query({
|
|
@@ -831,6 +839,7 @@ export function createLeaderboardFunctions(options) {
|
|
|
831
839
|
});
|
|
832
840
|
return { getTop, getProfile, incrementMany };
|
|
833
841
|
}
|
|
842
|
+
/** @internal */
|
|
834
843
|
export function createPresenceFunctions(options) {
|
|
835
844
|
const mode = options?.auth ?? "required";
|
|
836
845
|
const freshWindowMs = options?.freshWindowMs ?? 10_000;
|
|
@@ -848,25 +857,22 @@ export function createPresenceFunctions(options) {
|
|
|
848
857
|
lastSeenAt: v.number(),
|
|
849
858
|
})),
|
|
850
859
|
handler: async (ctx, args) => {
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
rotationPitch: row.rotationPitch,
|
|
868
|
-
lastSeenAt: row.updatedAt,
|
|
869
|
-
}));
|
|
860
|
+
return withMember(ctx, mode, args, [], async () => {
|
|
861
|
+
const threshold = Date.now() - freshWindowMs;
|
|
862
|
+
const rows = await ctx.db
|
|
863
|
+
.query("jgPoses")
|
|
864
|
+
.withIndex("by_server", (q) => q.eq("serverId", args.serverId))
|
|
865
|
+
.collect();
|
|
866
|
+
return rows
|
|
867
|
+
.filter((row) => row.updatedAt >= threshold)
|
|
868
|
+
.map((row) => ({
|
|
869
|
+
userId: row.userId,
|
|
870
|
+
position: { x: row.x, y: row.y, z: row.z },
|
|
871
|
+
rotationY: row.rotationY,
|
|
872
|
+
rotationPitch: row.rotationPitch,
|
|
873
|
+
lastSeenAt: row.updatedAt,
|
|
874
|
+
}));
|
|
875
|
+
});
|
|
870
876
|
},
|
|
871
877
|
});
|
|
872
878
|
const sync = mutation({
|
|
@@ -882,12 +888,7 @@ export function createPresenceFunctions(options) {
|
|
|
882
888
|
externalId: v.optional(v.string()),
|
|
883
889
|
},
|
|
884
890
|
returns: v.null(),
|
|
885
|
-
handler:
|
|
886
|
-
const actorUserId = await resolveActor(ctx, args.externalId, mode);
|
|
887
|
-
if (!actorUserId)
|
|
888
|
-
return null;
|
|
889
|
-
if ((await requireServerMember(ctx, args.serverId, actorUserId)) === null)
|
|
890
|
-
return null;
|
|
891
|
+
handler: (ctx, args) => withMember(ctx, mode, args, null, async (_server, actorUserId) => {
|
|
891
892
|
const now = Date.now();
|
|
892
893
|
const existing = await ctx.db
|
|
893
894
|
.query("jgPoses")
|
|
@@ -923,7 +924,7 @@ export function createPresenceFunctions(options) {
|
|
|
923
924
|
await ctx.db.insert("jgPoses", { serverId: args.serverId, userId: actorUserId, ...patch });
|
|
924
925
|
}
|
|
925
926
|
return null;
|
|
926
|
-
},
|
|
927
|
+
}),
|
|
927
928
|
});
|
|
928
929
|
const leave = mutation({
|
|
929
930
|
args: {
|
|
@@ -946,6 +947,7 @@ export function createPresenceFunctions(options) {
|
|
|
946
947
|
});
|
|
947
948
|
return { list, sync, leave };
|
|
948
949
|
}
|
|
950
|
+
/** @internal */
|
|
949
951
|
export function createChatFunctions(options) {
|
|
950
952
|
const mode = options?.auth ?? "required";
|
|
951
953
|
const historyLimit = options?.historyLimit ?? 100;
|
|
@@ -964,12 +966,7 @@ export function createChatFunctions(options) {
|
|
|
964
966
|
body: v.string(),
|
|
965
967
|
at: v.number(),
|
|
966
968
|
})),
|
|
967
|
-
handler:
|
|
968
|
-
const actorUserId = await resolveActor(ctx, args.externalId, mode);
|
|
969
|
-
if (!actorUserId)
|
|
970
|
-
return [];
|
|
971
|
-
if ((await requireServerMember(ctx, args.serverId, actorUserId)) === null)
|
|
972
|
-
return [];
|
|
969
|
+
handler: (ctx, args) => withMember(ctx, mode, args, [], async () => {
|
|
973
970
|
const rows = await ctx.db
|
|
974
971
|
.query("jgChatMessages")
|
|
975
972
|
.withIndex("by_server_channel", (q) => q.eq("serverId", args.serverId).eq("channelId", args.channelId))
|
|
@@ -985,7 +982,7 @@ export function createChatFunctions(options) {
|
|
|
985
982
|
body: row.body,
|
|
986
983
|
at: row.at,
|
|
987
984
|
}));
|
|
988
|
-
},
|
|
985
|
+
}),
|
|
989
986
|
});
|
|
990
987
|
const sendMessage = mutation({
|
|
991
988
|
args: {
|
|
@@ -1041,6 +1038,7 @@ const JG_CRON_SPECS = [
|
|
|
1041
1038
|
{ name: "jg tick", intervalSeconds: 1, functionKey: "tickActiveServers" },
|
|
1042
1039
|
{ name: "jg flush", intervalSeconds: 60, functionKey: "flushDirtyServers" },
|
|
1043
1040
|
];
|
|
1041
|
+
/** @internal */
|
|
1044
1042
|
export function jgengineCronSpecs() {
|
|
1045
1043
|
return JG_CRON_SPECS;
|
|
1046
1044
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jgengine/convex",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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.12.0"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
36
|
"convex": "^1.31.2",
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
Treat the pitch as a unique composition of needs, not a genre to fill in — never reach for a genre kit, preset, archetype, or class template ("default sports car", "default RPG", "default FPS") in the SDK or skills (see [AGENTS.md](../../../AGENTS.md)). Use `game-design` to turn the pitch into a testable player promise, verbs, loops, progression, failure, and completion scenario. Use `level-design` when play depends on authored spaces, routes, encounters, exploration, or spatial pacing. 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 **custom** UI art direction (every game owns its presentation — no stock drop-in face)
|
|
19
|
+
- one observable completion scenario
|
|
20
|
+
|
|
21
|
+
For a new in-repo game, scaffold with `bun run new:game "<id-or-title>"` (thin script alias for `jgengine create`, auto-detects the in-repo variant). 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
|
+
| player promise, pillars, verbs, loops, economy, progression, balance | `game-design` | experience and rule intent |
|
|
30
|
+
| metrics, topology, encounters, navigation, spatial pacing | `level-design` | playable space intent |
|
|
31
|
+
| scene placement, terrain painting, paths, asset authoring | `jgengine-editor` | editor document and authoring tools |
|
|
32
|
+
| runtime world, movement, input, interaction, AI, navigation, audio | `jgengine-world` | spatial simulation and world runtime |
|
|
33
|
+
| game state, systems, inventory, crafting, quests, economy, turns | `jgengine-gameplay` | non-combat rules and state |
|
|
34
|
+
| targeting, stats, damage, effects, abilities, projectiles, loot | `jgengine-combat` | combat resolution |
|
|
35
|
+
| HUD, menus, layout, accessibility, touch presentation | `jgengine-ui` | player-facing interface |
|
|
36
|
+
| models, sprites, materials, audio files, source licensing | `jgengine-assets` | asset discovery and catalogs |
|
|
37
|
+
| authority, transport, replication, sessions, host persistence | `jgengine-multiplayer` | network topology and persistence adapters |
|
|
38
|
+
|
|
39
|
+
World content routes editor-first: any request that adds, moves, restyles, or removes something visible in the world — however phrased ("design this world", "place the enemies", "make it look better") — starts in `jgengine-editor`; the other domains consume the authored document. Scenes normally require both `jgengine-editor` for authoring and `jgengine-world` for runtime consumption. Combat and multiplayer are opt-in, not default intake.
|
|
40
|
+
|
|
41
|
+
## 3. Discover before designing
|
|
42
|
+
|
|
43
|
+
Within each selected domain:
|
|
44
|
+
|
|
45
|
+
1. Search `capabilities.md` by intent; it maps needs to imports.
|
|
46
|
+
2. Open `api.md` only when exact signatures or export inventory are needed.
|
|
47
|
+
3. Open the linked reference only for a deeper recipe or trap.
|
|
48
|
+
4. For how several primitives wire into a running loop, read the domain's `recipes/` — connected, genre-free walkthroughs organized by composition seam; this skill's own [recipes/minimal-game.md](recipes/minimal-game.md) is the default whole-game walkthrough. Prefer a recipe over reading a game's source; the games under `Games/*` are not templates or references, and a hybrid game is just a different composition of the same primitives.
|
|
49
|
+
|
|
50
|
+
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.
|
|
51
|
+
|
|
52
|
+
## 4. Build and finish
|
|
53
|
+
|
|
54
|
+
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.
|