@jgengine/convex 0.18.1 → 0.19.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 +158 -7
- package/dist/createConvexGameTransport.d.ts +2 -0
- package/dist/createConvexGameTransport.js +17 -5
- package/dist/server.d.ts +43 -24
- package/dist/server.js +201 -135
- package/package.json +2 -2
- package/skills/ce-handoff/SKILL.md +1 -1
- package/skills/jgengine/SKILL.md +7 -5
- package/skills/jgengine/api.md +118 -89
- package/skills/jgengine/capabilities.md +28 -0
- package/skills/jgengine/recipes/minimal-game.md +3 -0
- package/skills/jgengine-multiplayer/SKILL.md +2 -2
- package/skills/jgengine-multiplayer/api.md +85 -77
package/dist/server.js
CHANGED
|
@@ -3,7 +3,7 @@ import { ConvexError, v } from "convex/values";
|
|
|
3
3
|
import { validateChatMessage } from "@jgengine/core/game/chat";
|
|
4
4
|
import { createFeedWriteGate, validateFeedWrite, } from "@jgengine/core/multiplayer/feedWriteGate";
|
|
5
5
|
import { DEFAULT_POSE_SYNC_RULES, decidePoseSync, isPresenceExpired, pickReusablePresence, resolveActivePresence, spawnPresenceState, } from "@jgengine/core/multiplayer/presenceModel";
|
|
6
|
-
import { commandScopeEscape, scopeWithActor } from "@jgengine/core/runtime/commandRunner";
|
|
6
|
+
import { SERVER_ONLY_COMMAND_REASON, commandScopeEscape, scopeWithActor } from "@jgengine/core/runtime/commandRunner";
|
|
7
7
|
import { createGameRuntime, initialPlayerState } from "@jgengine/core/runtime/gameRuntime";
|
|
8
8
|
import { buildHydratePlayers, planServerPersist, shouldAutoSave, trimFeedEntries, } from "@jgengine/core/runtime/hostPersistence";
|
|
9
9
|
import { JG_MAX_MEMBERS_PER_SERVER, isListablePublicly, isPrivateJoinBlocked, isServerFull, isServerMember, selectJoinTarget, statusAfterLeave, validateSlotsPerServer, withJoinedMember, withoutMember, } from "@jgengine/core/runtime/hostPolicy";
|
|
@@ -56,6 +56,8 @@ export function jgengineTables() {
|
|
|
56
56
|
serverId: v.id("jgGameServers"),
|
|
57
57
|
userId: v.string(),
|
|
58
58
|
joinedAt: v.number(),
|
|
59
|
+
/** Live client sessions holding this membership; absent on legacy and host-driven joins. */
|
|
60
|
+
sessionIds: v.optional(v.array(v.string())),
|
|
59
61
|
}).index("by_server", ["serverId"]).index("by_server_and_user", ["serverId", "userId"]),
|
|
60
62
|
jgRateLimits: defineTable({
|
|
61
63
|
key: v.string(), startedAt: v.number(), count: v.number(), expiresAt: v.number(),
|
|
@@ -196,6 +198,21 @@ async function hasMember(ctx, server, userId) {
|
|
|
196
198
|
return (await ctx.db.query("jgServerMembers")
|
|
197
199
|
.withIndex("by_server_and_user", q => q.eq("serverId", server._id).eq("userId", userId)).unique()) !== null;
|
|
198
200
|
}
|
|
201
|
+
async function memberRow(ctx, serverId, userId) {
|
|
202
|
+
return ctx.db.query("jgServerMembers").withIndex("by_server_and_user", q => q.eq("serverId", serverId).eq("userId", userId)).unique();
|
|
203
|
+
}
|
|
204
|
+
/** Most live client sessions one shared-topology membership remembers; the oldest drop first. */
|
|
205
|
+
export const MAX_MEMBER_SESSIONS = 16;
|
|
206
|
+
/** Longest accepted client `sessionId`. */
|
|
207
|
+
export const MAX_SESSION_ID_LENGTH = 128;
|
|
208
|
+
function withSession(sessionIds, sessionId) {
|
|
209
|
+
return [...(sessionIds ?? []).filter((id) => id !== sessionId), sessionId].slice(-MAX_MEMBER_SESSIONS);
|
|
210
|
+
}
|
|
211
|
+
function assertSessionId(sessionId) {
|
|
212
|
+
if (sessionId !== undefined && (sessionId.length === 0 || sessionId.length > MAX_SESSION_ID_LENGTH)) {
|
|
213
|
+
throw new ConvexError("Invalid sessionId");
|
|
214
|
+
}
|
|
215
|
+
}
|
|
199
216
|
async function requireServerMember(ctx, serverId, actorUserId) {
|
|
200
217
|
const server = await ctx.db.get("jgGameServers", serverId);
|
|
201
218
|
if (!server)
|
|
@@ -525,6 +542,145 @@ export function createGameServerFunctions(options) {
|
|
|
525
542
|
const tickableGameIds = (options?.runtimes ?? [])
|
|
526
543
|
.filter((runtime) => runtime.hasTick)
|
|
527
544
|
.map((runtime) => runtime.gameId);
|
|
545
|
+
const joinAs = async (ctx, actorUserId, args, sessionId) => {
|
|
546
|
+
const now = Date.now();
|
|
547
|
+
const runtime = resolveRuntime(registry, args.gameId);
|
|
548
|
+
const save = runtime.save;
|
|
549
|
+
const slotsPerServer = hostSlotsPerServer;
|
|
550
|
+
let server = args.serverId !== undefined ? await ctx.db.get("jgGameServers", args.serverId) : null;
|
|
551
|
+
if (args.serverId !== undefined && !server) {
|
|
552
|
+
return { ok: false, reason: "closed" };
|
|
553
|
+
}
|
|
554
|
+
if (server && server.gameId !== args.gameId) {
|
|
555
|
+
return { ok: false, reason: "unauthorized" };
|
|
556
|
+
}
|
|
557
|
+
if (server?.status === "closed")
|
|
558
|
+
return { ok: false, reason: "closed" };
|
|
559
|
+
let mayCreate = true;
|
|
560
|
+
if (!server) {
|
|
561
|
+
const joinable = [];
|
|
562
|
+
for (const status of ["running", "open"]) {
|
|
563
|
+
const rows = await ctx.db
|
|
564
|
+
.query("jgGameServers")
|
|
565
|
+
.withIndex("by_game_and_status", (q) => q.eq("gameId", args.gameId).eq("status", status))
|
|
566
|
+
.take(100);
|
|
567
|
+
joinable.push(...rows);
|
|
568
|
+
}
|
|
569
|
+
// Two concurrent first-joins can both see an empty list here and both insert. Convex's OCC
|
|
570
|
+
// resolves it — the loser's read set is invalidated and its mutation retries against the
|
|
571
|
+
// winner's row — so the engine adds no lock. A `HostPersistence` backend without
|
|
572
|
+
// serializable-snapshot retry must supply that guarantee itself.
|
|
573
|
+
const target = selectJoinTarget(joinable, {
|
|
574
|
+
userId: actorUserId,
|
|
575
|
+
mode: matchmaking,
|
|
576
|
+
slotsPerServer,
|
|
577
|
+
});
|
|
578
|
+
if (target.kind === "refuse")
|
|
579
|
+
return { ok: false, reason: "full" };
|
|
580
|
+
server = target.kind === "join" ? target.row : null;
|
|
581
|
+
mayCreate = target.kind === "create";
|
|
582
|
+
}
|
|
583
|
+
if (!server && mayCreate) {
|
|
584
|
+
const serverId = await ctx.db.insert("jgGameServers", {
|
|
585
|
+
gameId: args.gameId,
|
|
586
|
+
status: "running",
|
|
587
|
+
mode: args.mode,
|
|
588
|
+
modeConfig: args.modeConfig,
|
|
589
|
+
visibility: args.visibility ?? "public",
|
|
590
|
+
joinCode: args.joinCode,
|
|
591
|
+
topology: shared ? "shared" : "rooms",
|
|
592
|
+
memberCount: 0,
|
|
593
|
+
memberUserIds: [],
|
|
594
|
+
slotsPerServer,
|
|
595
|
+
save,
|
|
596
|
+
serverState: defaultServerStateForGame(args.gameId),
|
|
597
|
+
sessionPlayers: {},
|
|
598
|
+
revision: 0,
|
|
599
|
+
tickAnchorMs: now,
|
|
600
|
+
createdAt: now,
|
|
601
|
+
updatedAt: now,
|
|
602
|
+
});
|
|
603
|
+
server = await ctx.db.get("jgGameServers", serverId);
|
|
604
|
+
if (!server)
|
|
605
|
+
throw new ConvexError("Failed to create server");
|
|
606
|
+
}
|
|
607
|
+
if (!server)
|
|
608
|
+
throw new ConvexError("No joinable server");
|
|
609
|
+
// Capacity and save policy live in host options, not in the row. Without this a long-lived
|
|
610
|
+
// world keeps whatever it was created with and raising the host option silently does nothing.
|
|
611
|
+
if (server.slotsPerServer !== slotsPerServer || JSON.stringify(server.save) !== JSON.stringify(save)) {
|
|
612
|
+
await ctx.db.patch(server._id, { slotsPerServer, save, updatedAt: now });
|
|
613
|
+
const reconciled = await ctx.db.get("jgGameServers", server._id);
|
|
614
|
+
if (!reconciled)
|
|
615
|
+
throw new ConvexError("Server missing after reconcile");
|
|
616
|
+
server = reconciled;
|
|
617
|
+
}
|
|
618
|
+
if (!shared && isServerFull(server.memberUserIds, server.slotsPerServer, actorUserId)) {
|
|
619
|
+
return { ok: false, reason: "full" };
|
|
620
|
+
}
|
|
621
|
+
if (isPrivateJoinBlocked({
|
|
622
|
+
visibility: server.visibility,
|
|
623
|
+
memberUserIds: await hasMember(ctx, server, actorUserId) ? [actorUserId] : [],
|
|
624
|
+
userId: actorUserId,
|
|
625
|
+
joinCode: server.joinCode,
|
|
626
|
+
suppliedCode: args.joinCode,
|
|
627
|
+
})) {
|
|
628
|
+
return { ok: false, reason: "unauthorized" };
|
|
629
|
+
}
|
|
630
|
+
let profile = await ctx.db
|
|
631
|
+
.query("jgPlayerProfiles")
|
|
632
|
+
.withIndex("by_user_and_game", (q) => q.eq("userId", actorUserId).eq("gameId", args.gameId))
|
|
633
|
+
.unique();
|
|
634
|
+
if (shared && server.topology !== "shared") {
|
|
635
|
+
for (const userId of server.memberUserIds) {
|
|
636
|
+
const member = await ctx.db.query("jgServerMembers").withIndex("by_server_and_user", q => q.eq("serverId", server._id).eq("userId", userId)).unique();
|
|
637
|
+
if (!member)
|
|
638
|
+
await ctx.db.insert("jgServerMembers", { serverId: server._id, userId, joinedAt: now });
|
|
639
|
+
const state = server.sessionPlayers[userId];
|
|
640
|
+
if (!state)
|
|
641
|
+
continue;
|
|
642
|
+
const stored = await ctx.db.query("jgPlayerProfiles").withIndex("by_user_and_game", q => q.eq("userId", userId).eq("gameId", server.gameId)).unique();
|
|
643
|
+
const patch = { sessionState: state.session ?? {}, sessionServerId: server._id };
|
|
644
|
+
if (stored)
|
|
645
|
+
await ctx.db.patch(stored._id, patch);
|
|
646
|
+
else
|
|
647
|
+
await ctx.db.insert("jgPlayerProfiles", { userId, gameId: server.gameId, playerState: state, revision: server.revision, createdAt: now, updatedAt: now, ...patch });
|
|
648
|
+
}
|
|
649
|
+
await ctx.db.patch(server._id, { topology: "shared", memberCount: server.memberUserIds.length, memberUserIds: [], sessionPlayers: {} });
|
|
650
|
+
server = (await ctx.db.get("jgGameServers", server._id));
|
|
651
|
+
profile = await ctx.db.query("jgPlayerProfiles").withIndex("by_user_and_game", q => q.eq("userId", actorUserId).eq("gameId", args.gameId)).unique();
|
|
652
|
+
}
|
|
653
|
+
const isNew = profile === null;
|
|
654
|
+
const memberUserIds = withJoinedMember(server.memberUserIds, actorUserId);
|
|
655
|
+
if (shared) {
|
|
656
|
+
const capacity = await ensureServerCapacity(ctx, server);
|
|
657
|
+
const member = await memberRow(ctx, server._id, actorUserId);
|
|
658
|
+
const isMember = member !== null || server.memberUserIds.includes(actorUserId);
|
|
659
|
+
await ctx.db.patch(capacity._id, {
|
|
660
|
+
memberCount: capacity.memberCount + (isMember ? 0 : 1), status: "running", slotsPerServer,
|
|
661
|
+
});
|
|
662
|
+
if (!isMember) {
|
|
663
|
+
await ctx.db.insert("jgServerMembers", {
|
|
664
|
+
serverId: server._id, userId: actorUserId, joinedAt: now, ...(sessionId === undefined ? {} : { sessionIds: [sessionId] }),
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
else if (member !== null && sessionId !== undefined) {
|
|
668
|
+
await ctx.db.patch(member._id, { sessionIds: withSession(member.sessionIds, sessionId) });
|
|
669
|
+
}
|
|
670
|
+
if (server.status !== "running")
|
|
671
|
+
await ctx.db.patch(server._id, { status: "running" });
|
|
672
|
+
}
|
|
673
|
+
else {
|
|
674
|
+
await ctx.db.patch(server._id, { memberUserIds, status: "running", updatedAt: now });
|
|
675
|
+
}
|
|
676
|
+
const refreshed = await ctx.db.get("jgGameServers", server._id);
|
|
677
|
+
if (!refreshed)
|
|
678
|
+
throw new ConvexError("Server missing after join");
|
|
679
|
+
let snapshot = await loadServerSnapshot(ctx, refreshed, runtime, scopeWithActor(runtime.joinScope(actorUserId, isNew), actorUserId));
|
|
680
|
+
snapshot = runtime.joinPlayer(snapshot, actorUserId, isNew, now);
|
|
681
|
+
await persistServerSnapshot(ctx, refreshed, snapshot, save);
|
|
682
|
+
return { ok: true, serverId: refreshed._id, isNew };
|
|
683
|
+
};
|
|
528
684
|
const joinServer = mutation({
|
|
529
685
|
args: {
|
|
530
686
|
gameId: v.string(),
|
|
@@ -534,155 +690,45 @@ export function createGameServerFunctions(options) {
|
|
|
534
690
|
visibility: v.optional(v.union(v.literal("public"), v.literal("private"))),
|
|
535
691
|
joinCode: v.optional(v.string()),
|
|
536
692
|
externalId: v.optional(v.string()),
|
|
693
|
+
sessionId: v.optional(v.string()),
|
|
537
694
|
},
|
|
538
695
|
returns: v.union(v.object({ ok: v.literal(true), serverId: v.id("jgGameServers"), isNew: v.boolean() }), v.object({ ok: v.literal(false), reason: v.union(v.literal("full"), v.literal("closed"), v.literal("unauthorized")) })),
|
|
539
696
|
handler: async (ctx, args) => {
|
|
540
697
|
const actorUserId = await resolveActor(ctx, args.externalId, mode);
|
|
541
698
|
if (!actorUserId)
|
|
542
699
|
return { ok: false, reason: "unauthorized" };
|
|
543
|
-
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
const slotsPerServer = hostSlotsPerServer;
|
|
547
|
-
let server = args.serverId !== undefined ? await ctx.db.get("jgGameServers", args.serverId) : null;
|
|
548
|
-
if (args.serverId !== undefined && !server) {
|
|
549
|
-
return { ok: false, reason: "closed" };
|
|
550
|
-
}
|
|
551
|
-
if (server && server.gameId !== args.gameId) {
|
|
552
|
-
return { ok: false, reason: "unauthorized" };
|
|
553
|
-
}
|
|
554
|
-
if (server?.status === "closed")
|
|
555
|
-
return { ok: false, reason: "closed" };
|
|
556
|
-
let mayCreate = true;
|
|
557
|
-
if (!server) {
|
|
558
|
-
const joinable = [];
|
|
559
|
-
for (const status of ["running", "open"]) {
|
|
560
|
-
const rows = await ctx.db
|
|
561
|
-
.query("jgGameServers")
|
|
562
|
-
.withIndex("by_game_and_status", (q) => q.eq("gameId", args.gameId).eq("status", status))
|
|
563
|
-
.take(100);
|
|
564
|
-
joinable.push(...rows);
|
|
565
|
-
}
|
|
566
|
-
// Two concurrent first-joins can both see an empty list here and both insert. Convex's OCC
|
|
567
|
-
// resolves it — the loser's read set is invalidated and its mutation retries against the
|
|
568
|
-
// winner's row — so the engine adds no lock. A `HostPersistence` backend without
|
|
569
|
-
// serializable-snapshot retry must supply that guarantee itself.
|
|
570
|
-
const target = selectJoinTarget(joinable, {
|
|
571
|
-
userId: actorUserId,
|
|
572
|
-
mode: matchmaking,
|
|
573
|
-
slotsPerServer,
|
|
574
|
-
});
|
|
575
|
-
if (target.kind === "refuse")
|
|
576
|
-
return { ok: false, reason: "full" };
|
|
577
|
-
server = target.kind === "join" ? target.row : null;
|
|
578
|
-
mayCreate = target.kind === "create";
|
|
579
|
-
}
|
|
580
|
-
if (!server && mayCreate) {
|
|
581
|
-
const serverId = await ctx.db.insert("jgGameServers", {
|
|
582
|
-
gameId: args.gameId,
|
|
583
|
-
status: "running",
|
|
584
|
-
mode: args.mode,
|
|
585
|
-
modeConfig: args.modeConfig,
|
|
586
|
-
visibility: args.visibility ?? "public",
|
|
587
|
-
joinCode: args.joinCode,
|
|
588
|
-
topology: shared ? "shared" : "rooms",
|
|
589
|
-
memberCount: 0,
|
|
590
|
-
memberUserIds: [],
|
|
591
|
-
slotsPerServer,
|
|
592
|
-
save,
|
|
593
|
-
serverState: defaultServerStateForGame(args.gameId),
|
|
594
|
-
sessionPlayers: {},
|
|
595
|
-
revision: 0,
|
|
596
|
-
tickAnchorMs: now,
|
|
597
|
-
createdAt: now,
|
|
598
|
-
updatedAt: now,
|
|
599
|
-
});
|
|
600
|
-
server = await ctx.db.get("jgGameServers", serverId);
|
|
601
|
-
if (!server)
|
|
602
|
-
throw new ConvexError("Failed to create server");
|
|
603
|
-
}
|
|
604
|
-
if (!server)
|
|
605
|
-
throw new ConvexError("No joinable server");
|
|
606
|
-
// Capacity and save policy live in host options, not in the row. Without this a long-lived
|
|
607
|
-
// world keeps whatever it was created with and raising the host option silently does nothing.
|
|
608
|
-
if (server.slotsPerServer !== slotsPerServer || JSON.stringify(server.save) !== JSON.stringify(save)) {
|
|
609
|
-
await ctx.db.patch(server._id, { slotsPerServer, save, updatedAt: now });
|
|
610
|
-
const reconciled = await ctx.db.get("jgGameServers", server._id);
|
|
611
|
-
if (!reconciled)
|
|
612
|
-
throw new ConvexError("Server missing after reconcile");
|
|
613
|
-
server = reconciled;
|
|
614
|
-
}
|
|
615
|
-
if (!shared && isServerFull(server.memberUserIds, server.slotsPerServer, actorUserId)) {
|
|
616
|
-
return { ok: false, reason: "full" };
|
|
617
|
-
}
|
|
618
|
-
if (isPrivateJoinBlocked({
|
|
619
|
-
visibility: server.visibility,
|
|
620
|
-
memberUserIds: await hasMember(ctx, server, actorUserId) ? [actorUserId] : [],
|
|
621
|
-
userId: actorUserId,
|
|
622
|
-
joinCode: server.joinCode,
|
|
623
|
-
suppliedCode: args.joinCode,
|
|
624
|
-
})) {
|
|
625
|
-
return { ok: false, reason: "unauthorized" };
|
|
626
|
-
}
|
|
627
|
-
let profile = await ctx.db
|
|
628
|
-
.query("jgPlayerProfiles")
|
|
629
|
-
.withIndex("by_user_and_game", (q) => q.eq("userId", actorUserId).eq("gameId", args.gameId))
|
|
630
|
-
.unique();
|
|
631
|
-
if (shared && server.topology !== "shared") {
|
|
632
|
-
for (const userId of server.memberUserIds) {
|
|
633
|
-
const member = await ctx.db.query("jgServerMembers").withIndex("by_server_and_user", q => q.eq("serverId", server._id).eq("userId", userId)).unique();
|
|
634
|
-
if (!member)
|
|
635
|
-
await ctx.db.insert("jgServerMembers", { serverId: server._id, userId, joinedAt: now });
|
|
636
|
-
const state = server.sessionPlayers[userId];
|
|
637
|
-
if (!state)
|
|
638
|
-
continue;
|
|
639
|
-
const stored = await ctx.db.query("jgPlayerProfiles").withIndex("by_user_and_game", q => q.eq("userId", userId).eq("gameId", server.gameId)).unique();
|
|
640
|
-
const patch = { sessionState: state.session ?? {}, sessionServerId: server._id };
|
|
641
|
-
if (stored)
|
|
642
|
-
await ctx.db.patch(stored._id, patch);
|
|
643
|
-
else
|
|
644
|
-
await ctx.db.insert("jgPlayerProfiles", { userId, gameId: server.gameId, playerState: state, revision: server.revision, createdAt: now, updatedAt: now, ...patch });
|
|
645
|
-
}
|
|
646
|
-
await ctx.db.patch(server._id, { topology: "shared", memberCount: server.memberUserIds.length, memberUserIds: [], sessionPlayers: {} });
|
|
647
|
-
server = (await ctx.db.get("jgGameServers", server._id));
|
|
648
|
-
profile = await ctx.db.query("jgPlayerProfiles").withIndex("by_user_and_game", q => q.eq("userId", actorUserId).eq("gameId", args.gameId)).unique();
|
|
649
|
-
}
|
|
650
|
-
const isNew = profile === null;
|
|
651
|
-
const memberUserIds = withJoinedMember(server.memberUserIds, actorUserId);
|
|
652
|
-
if (shared) {
|
|
653
|
-
const capacity = await ensureServerCapacity(ctx, server);
|
|
654
|
-
const isMember = await hasMember(ctx, server, actorUserId);
|
|
655
|
-
await ctx.db.patch(capacity._id, {
|
|
656
|
-
memberCount: capacity.memberCount + (isMember ? 0 : 1), status: "running", slotsPerServer,
|
|
657
|
-
});
|
|
658
|
-
if (!isMember)
|
|
659
|
-
await ctx.db.insert("jgServerMembers", { serverId: server._id, userId: actorUserId, joinedAt: now });
|
|
660
|
-
if (server.status !== "running")
|
|
661
|
-
await ctx.db.patch(server._id, { status: "running" });
|
|
662
|
-
}
|
|
663
|
-
else {
|
|
664
|
-
await ctx.db.patch(server._id, { memberUserIds, status: "running", updatedAt: now });
|
|
665
|
-
}
|
|
666
|
-
const refreshed = await ctx.db.get("jgGameServers", server._id);
|
|
667
|
-
if (!refreshed)
|
|
668
|
-
throw new ConvexError("Server missing after join");
|
|
669
|
-
let snapshot = await loadServerSnapshot(ctx, refreshed, runtime, scopeWithActor(runtime.joinScope(actorUserId, isNew), actorUserId));
|
|
670
|
-
snapshot = runtime.joinPlayer(snapshot, actorUserId, isNew, now);
|
|
671
|
-
await persistServerSnapshot(ctx, refreshed, snapshot, save);
|
|
672
|
-
return { ok: true, serverId: refreshed._id, isNew };
|
|
700
|
+
assertSessionId(args.sessionId);
|
|
701
|
+
const { externalId: _externalId, sessionId, ...joinArgs } = args;
|
|
702
|
+
return joinAs(ctx, actorUserId, joinArgs, sessionId);
|
|
673
703
|
},
|
|
674
704
|
});
|
|
675
705
|
const leaveServer = mutation({
|
|
676
706
|
args: {
|
|
677
707
|
serverId: v.id("jgGameServers"),
|
|
678
708
|
externalId: v.optional(v.string()),
|
|
709
|
+
sessionId: v.optional(v.string()),
|
|
679
710
|
},
|
|
680
711
|
returns: v.null(),
|
|
681
712
|
handler: async (ctx, args) => {
|
|
682
713
|
const actorUserId = await requireActor(ctx, args.externalId, mode);
|
|
714
|
+
assertSessionId(args.sessionId);
|
|
683
715
|
const server = await requireServerMember(ctx, args.serverId, actorUserId);
|
|
684
716
|
if (server === null)
|
|
685
717
|
return null;
|
|
718
|
+
if (args.sessionId !== undefined && server.topology === "shared") {
|
|
719
|
+
const member = await memberRow(ctx, server._id, actorUserId);
|
|
720
|
+
const live = member?.sessionIds ?? [];
|
|
721
|
+
// A leave for a session that is not live (a disposed join, or one trimmed by the bound) must not evict the sessions that are.
|
|
722
|
+
if (live.length > 0) {
|
|
723
|
+
const remaining = live.filter((id) => id !== args.sessionId);
|
|
724
|
+
if (remaining.length === live.length)
|
|
725
|
+
return null;
|
|
726
|
+
if (remaining.length > 0) {
|
|
727
|
+
await ctx.db.patch(member._id, { sessionIds: remaining });
|
|
728
|
+
return null;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
686
732
|
const now = Date.now();
|
|
687
733
|
const runtime = resolveRuntime(registry, server.gameId);
|
|
688
734
|
// Flushes the leaver's own state before the roster drops them; nobody else's rows are touched.
|
|
@@ -718,6 +764,16 @@ export function createGameServerFunctions(options) {
|
|
|
718
764
|
},
|
|
719
765
|
});
|
|
720
766
|
const helpers = {
|
|
767
|
+
async ensureJoined(ctx, args) {
|
|
768
|
+
const serverId = args.serverId;
|
|
769
|
+
if (serverId !== undefined) {
|
|
770
|
+
const server = await ctx.db.get("jgGameServers", serverId);
|
|
771
|
+
if (server && server.gameId === args.gameId && server.status !== "closed" && await hasMember(ctx, server, args.userId)) {
|
|
772
|
+
return { ok: true, serverId: server._id, isNew: false };
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
return joinAs(ctx, args.userId, { gameId: args.gameId, serverId }, undefined);
|
|
776
|
+
},
|
|
721
777
|
async ensureServer(ctx, gameId) {
|
|
722
778
|
if (!shared || matchmaking !== "singleton")
|
|
723
779
|
throw new Error("ensureServer requires a shared singleton host");
|
|
@@ -738,18 +794,22 @@ export function createGameServerFunctions(options) {
|
|
|
738
794
|
return server;
|
|
739
795
|
},
|
|
740
796
|
async resetPlayerProfile(ctx, serverId, userId) {
|
|
741
|
-
const server = await
|
|
797
|
+
const server = await ctx.db.get("jgGameServers", serverId);
|
|
742
798
|
if (!server)
|
|
799
|
+
throw new ConvexError("Server not found");
|
|
800
|
+
const profile = await ctx.db.query("jgPlayerProfiles").withIndex("by_user_and_game", q => q.eq("userId", userId).eq("gameId", server.gameId)).unique();
|
|
801
|
+
const member = await hasMember(ctx, server, userId);
|
|
802
|
+
// A player who left keeps a profile that host code (a scheduled wipe, an insolvency reset) must still be able to reset.
|
|
803
|
+
if (!member && profile === null)
|
|
743
804
|
throw new ConvexError("Not a member of this server");
|
|
744
805
|
const state = initialPlayerState(resolveRuntime(registry, server.gameId), userId);
|
|
745
|
-
const profile = await ctx.db.query("jgPlayerProfiles").withIndex("by_user_and_game", q => q.eq("userId", userId).eq("gameId", server.gameId)).unique();
|
|
746
806
|
const now = Date.now();
|
|
747
807
|
const patch = { playerState: state, sessionState: state.session, sessionServerId: server._id, revision: (profile?.revision ?? 0) + 1, updatedAt: now };
|
|
748
808
|
if (profile)
|
|
749
809
|
await ctx.db.patch(profile._id, patch);
|
|
750
810
|
else
|
|
751
811
|
await ctx.db.insert("jgPlayerProfiles", { userId, gameId: server.gameId, createdAt: now, ...patch });
|
|
752
|
-
if (server.topology !== "shared")
|
|
812
|
+
if (server.topology !== "shared" && member)
|
|
753
813
|
await ctx.db.patch(server._id, { sessionPlayers: { ...server.sessionPlayers, [userId]: state } });
|
|
754
814
|
return state;
|
|
755
815
|
},
|
|
@@ -824,7 +884,13 @@ export function createGameServerFunctions(options) {
|
|
|
824
884
|
})),
|
|
825
885
|
},
|
|
826
886
|
returns: v.union(v.object({ ok: v.literal(true) }), v.object({ ok: v.literal(false), reason: v.string() })),
|
|
827
|
-
handler: (ctx, args) =>
|
|
887
|
+
handler: async (ctx, args) => {
|
|
888
|
+
const server = await ctx.db.get("jgGameServers", args.serverId);
|
|
889
|
+
if (server !== null && resolveRuntime(registry, server.gameId).commandAccess(args.command) === "server") {
|
|
890
|
+
return { ok: false, reason: SERVER_ONLY_COMMAND_REASON };
|
|
891
|
+
}
|
|
892
|
+
return helpers.runCommand(ctx, { serverId: args.serverId, command: args.command, input: args.input, externalId: args.externalId });
|
|
893
|
+
},
|
|
828
894
|
});
|
|
829
895
|
const flushSave = mutation({
|
|
830
896
|
args: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jgengine/convex",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Convex adapters for JGengine: game transport, presence transport, and backend wiring over @jgengine/core.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"check-types": "bun ../../scripts/check-types-preflight.ts && tsgo --noEmit -p tsconfig.json"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@jgengine/core": "^0.
|
|
33
|
+
"@jgengine/core": "^0.19.0"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
36
|
"convex": "^1.31.2",
|
|
@@ -143,7 +143,7 @@ When sufficient, return a concise orientation: objective, progress, decisions, c
|
|
|
143
143
|
|
|
144
144
|
## JGengine notes
|
|
145
145
|
|
|
146
|
-
- Multi-session engine work
|
|
146
|
+
- Multi-session engine work spans GitHub issues and PRs — point at those; do not paste full plan bodies.
|
|
147
147
|
- Prefer `bun run agent:bootstrap --check` status, branch names, and PR links as state anchors.
|
|
148
148
|
- Local parallelism uses `.claude/worktrees/`; never invent `C:\tmp\…` Codex-shared trees.
|
|
149
149
|
- Durable process changes belong in skills, not only the handoff.
|
package/skills/jgengine/SKILL.md
CHANGED
|
@@ -5,7 +5,9 @@ description: Route game work to the smallest useful JGengine guidance set.
|
|
|
5
5
|
|
|
6
6
|
# JGengine intake and router
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
This skill decides what to load; it is not an engine manual. Package map, commands, layering, and license live in the JGengine README (repo-root [`README.md`](../../../README.md) in the monorepo, [jgengine.com](https://jgengine.com) from a created project); open it when you need one of those facts.
|
|
9
|
+
|
|
10
|
+
A created project installs only `jgengine`, `jgengine-editor`, `jgengine-verify`, `game-design`, and `jgengine-ui`. Run `npx jgengine skills -p --all` before loading any other domain named below. Those projects do not ship `api.md`; read exact signatures from the `.d.ts` files under `node_modules/@jgengine/<pkg>/dist/`.
|
|
9
11
|
|
|
10
12
|
## 1. Establish the target
|
|
11
13
|
|
|
@@ -20,7 +22,7 @@ Treat the pitch as a unique composition of needs, not a genre to fill in — nev
|
|
|
20
22
|
- HUD/menu states and **custom** UI art direction (compose shipped building blocks + HudTheme; no unskinned stock face)
|
|
21
23
|
- one observable completion scenario
|
|
22
24
|
|
|
23
|
-
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/*`.
|
|
25
|
+
Pick the dimension first: `create` defaults to a 3D world (terrain, sky, player model, editor scene); a grid, card, tile, or other board game with no 3D scene takes `create --2d` (a `board` world drawn by GameUI over `@jgengine/core/puzzle/cellGrid`). 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/*`.
|
|
24
26
|
|
|
25
27
|
For an existing game asked to pick up a new engine release ("jgengine published a new version, grab it"), run `npx jgengine upgrade` in the project first: it diffs installed `@jgengine/*` versions against the latest release and prints every Migrate step and Adopt-worthy addition in between (`--json` for structured output). Bump, migrate oldest-first, then work the Adopt lists instead of reconstructing the changelog by hand.
|
|
26
28
|
|
|
@@ -46,9 +48,9 @@ World content routes editor-first: any request that adds, moves, restyles, or re
|
|
|
46
48
|
|
|
47
49
|
Within each selected domain:
|
|
48
50
|
|
|
49
|
-
0. Fastest start for a common composition: `npx jgengine recipe <name>` prints a vetted, SDK-typechecked snippet (imports + wired code)
|
|
50
|
-
1. Search
|
|
51
|
-
2. Open `api.md` only when exact signatures
|
|
51
|
+
0. Fastest start for a common composition: `npx jgengine recipe <name>` prints a vetted, SDK-typechecked snippet (imports + wired code) (`combat-loop`, `loot`, `world-drops`, `click-target`, `ability-bar`, `hitscan-weapon`, `rolled-gear`, `enter-vehicle` and more); `npx jgengine recipe` lists them. Copy that instead of reconning per-file.
|
|
52
|
+
1. Search capabilities by intent; do not read `capabilities.md` top to bottom. `npx jgengine find <intent>` searches every domain's index and every recipe at once, installed or not, and prints the primitive and its import (or the recipe to open); in the monorepo, `grep -i "<intent>" .claude/skills/*/capabilities.md` does the same. Search the moment you are tempted to hand-roll a HUD window, inventory grid, paperdoll, hotkey listener, minimap, stat bar, or character motion. A `z-index`, a `keydown` for a panel, a `<div>` inventory, or a limb bob almost certainly has a shipped drop-in (`usePanels`/`PanelHost`, `InventoryGrid`, `CharacterSheet`/`Paperdoll`, `PartMotionRig`, `EntityPreview`); games own the *skin*, not the re-derivation.
|
|
53
|
+
2. Open `api.md` (monorepo) or the package `.d.ts` only when exact signatures are needed; grep it, never read it whole.
|
|
52
54
|
3. Open the linked reference only for a deeper recipe or trap.
|
|
53
55
|
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.
|
|
54
56
|
|