@jgengine/convex 0.8.0 → 0.9.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 +16 -0
- package/dist/createConvexGameTransport.d.ts +3 -2
- package/dist/createConvexGameTransport.js +1 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/occ.d.ts +24 -0
- package/dist/occ.js +16 -0
- package/dist/server.d.ts +20 -8
- package/dist/server.js +119 -17
- package/llms.txt +174 -649
- package/package.json +6 -2
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,22 @@ Agents building on the published SDK can also read this programmatically:
|
|
|
11
11
|
same data as typed values, so an updater can diff its installed version against
|
|
12
12
|
the latest and surface the migration steps.
|
|
13
13
|
|
|
14
|
+
## Unreleased
|
|
15
|
+
|
|
16
|
+
## 0.9.0
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Mobile HUD fit — design-resolution UI scaling now applies to every game by default: `HudCanvas` measures the live viewport and scales the whole HUD from `PlayableGame.hudFit.designSize` (default 1600×900, clamps `minScale`/`maxScale` 0.4–1) so desktop-authored layouts shrink to fit a phone instead of overflowing it; `hudFit.mobile` overrides tune the phone fit separately. Pure math lives in `@jgengine/core/ui/hudScale` (`hudScaleForViewport`, `resolveHudFit`, `rectOverflow`, `overflowingPanels`); the shell mounts `@jgengine/react`'s `HudViewportProvider` around `GameUI` so games need no wiring.
|
|
21
|
+
- Graphics → UI scale — a player-facing `graphics.uiScale` slider (0.5–1.5, default 1) multiplies the computed HUD scale on every platform; the same resolution system drives desktop preference and mobile shrink.
|
|
22
|
+
- HUD overflow gate — `HudCanvas` measures every `HudPanel` against the viewport at runtime and reports offenders on a `data-hud-overflow` attribute (plus a console warning); `bun run shoot <game> --device mobile|both` now exits non-zero naming the panels that escape the viewport.
|
|
23
|
+
- Automatic visibility & streaming defaults — an engine-level `VisibilitySystem` (exported from `@jgengine/core`) gives every scene renderer-agnostic culling and asset-streaming policy with no per-object wiring: distance culling, preload margins, hysteresis, delayed unloading, multi-camera awareness (including cameras excluded from streaming), and per-object overrides (always-visible, never-unload, disabled culling/streaming, custom render distance and preload margin).
|
|
24
|
+
- Self-hosted asset mirror — `DEFAULT_RELEASE_BASE` now points at this repo's rolling `packs` release instead of the upstream host; a catalog-driven `mirror-assets` workflow (weekly cron + manual dispatch) keeps the mirror in sync with the asset catalog.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- Mobile HUD fit is on by default — design-resolution HUD scaling now applies to every game with no config change (previously opt-in via `platforms: ["web", "mobile"]`). A desktop-only game keeps the legacy fixed 0.85 compact zoom by declaring `platforms: ["web"]` (without `"mobile"`).
|
|
29
|
+
|
|
14
30
|
## 0.8.0
|
|
15
31
|
|
|
16
32
|
Transport-agnostic multiplayer (socket.io, WebRTC p2p, LAN/Fly adapters) plus grid/voxel
|
|
@@ -14,9 +14,9 @@ export type ConvexGameApi = {
|
|
|
14
14
|
joinServer: FunctionReference<"mutation", "public", {
|
|
15
15
|
gameId: string;
|
|
16
16
|
serverId?: string;
|
|
17
|
-
slotsPerServer?: number;
|
|
18
|
-
save?: unknown;
|
|
19
17
|
mode?: string;
|
|
18
|
+
visibility?: "public" | "private";
|
|
19
|
+
joinCode?: string;
|
|
20
20
|
externalId?: string;
|
|
21
21
|
}, {
|
|
22
22
|
serverId: string;
|
|
@@ -60,6 +60,7 @@ export type ConvexGameApi = {
|
|
|
60
60
|
presence: {
|
|
61
61
|
list: FunctionReference<"query", "public", {
|
|
62
62
|
serverId: string;
|
|
63
|
+
externalId?: string;
|
|
63
64
|
}, PresencePoseRow[]>;
|
|
64
65
|
sync: FunctionReference<"mutation", "public", {
|
|
65
66
|
serverId: string;
|
|
@@ -73,7 +73,7 @@ export function createConvexPresenceSync(client, api, config, tuning) {
|
|
|
73
73
|
const gate = createPoseSyncGate(tuning ?? DEFAULT_CONVEX_POSE_TUNING);
|
|
74
74
|
return {
|
|
75
75
|
subscribe(serverId, onChange) {
|
|
76
|
-
return watchConvexQuery(client, api.presence.list, { serverId }, (rows) => rows, onChange);
|
|
76
|
+
return watchConvexQuery(client, api.presence.list, { serverId, externalId: config.userId }, (rows) => rows, onChange);
|
|
77
77
|
},
|
|
78
78
|
syncPose(serverId, pose) {
|
|
79
79
|
if (!gate.evaluate(pose, Date.now()))
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export * from "./convexChatTransport.js";
|
|
2
|
+
export * from "./convexPresenceTransport.js";
|
|
3
|
+
export * from "./createConvexBackend.js";
|
|
4
|
+
export * from "./createConvexGameTransport.js";
|
|
5
|
+
export * from "./occ.js";
|
|
6
|
+
export * from "./resolveConvexMultiplayer.js";
|
|
7
|
+
export * from "./server.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export * from "./convexChatTransport.js";
|
|
2
|
+
export * from "./convexPresenceTransport.js";
|
|
3
|
+
export * from "./createConvexBackend.js";
|
|
4
|
+
export * from "./createConvexGameTransport.js";
|
|
5
|
+
export * from "./occ.js";
|
|
6
|
+
export * from "./resolveConvexMultiplayer.js";
|
|
7
|
+
export * from "./server.js";
|
package/dist/occ.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { GameRuntime } from "@jgengine/core/runtime/gameRuntime";
|
|
2
|
+
import type { GameRuntimeSnapshot } from "@jgengine/core/runtime/snapshot";
|
|
3
|
+
export declare const REVISION_CONFLICT_REASON = "Revision conflict";
|
|
4
|
+
export declare function commitIfRevisionMatch(loadedRevision: number, currentRevision: number): {
|
|
5
|
+
ok: true;
|
|
6
|
+
} | {
|
|
7
|
+
ok: false;
|
|
8
|
+
reason: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function applyCommandWithOcc(args: {
|
|
11
|
+
loadedRevision: number;
|
|
12
|
+
currentRevision: number;
|
|
13
|
+
snapshot: GameRuntimeSnapshot;
|
|
14
|
+
runtime: GameRuntime;
|
|
15
|
+
actorUserId: string;
|
|
16
|
+
command: string;
|
|
17
|
+
input: unknown;
|
|
18
|
+
}): {
|
|
19
|
+
ok: true;
|
|
20
|
+
snapshot: GameRuntimeSnapshot;
|
|
21
|
+
} | {
|
|
22
|
+
ok: false;
|
|
23
|
+
reason: string;
|
|
24
|
+
};
|
package/dist/occ.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const REVISION_CONFLICT_REASON = "Revision conflict";
|
|
2
|
+
export function commitIfRevisionMatch(loadedRevision, currentRevision) {
|
|
3
|
+
if (currentRevision !== loadedRevision) {
|
|
4
|
+
return { ok: false, reason: REVISION_CONFLICT_REASON };
|
|
5
|
+
}
|
|
6
|
+
return { ok: true };
|
|
7
|
+
}
|
|
8
|
+
export function applyCommandWithOcc(args) {
|
|
9
|
+
const gate = commitIfRevisionMatch(args.loadedRevision, args.currentRevision);
|
|
10
|
+
if (!gate.ok)
|
|
11
|
+
return gate;
|
|
12
|
+
if (args.snapshot.revision !== args.loadedRevision) {
|
|
13
|
+
return { ok: false, reason: REVISION_CONFLICT_REASON };
|
|
14
|
+
}
|
|
15
|
+
return args.runtime.runCommand(args.snapshot, args.actorUserId, args.command, args.input);
|
|
16
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { DataModelFromSchemaDefinition, GenericMutationCtx } from "convex/s
|
|
|
2
2
|
import type { GenericId } from "convex/values";
|
|
3
3
|
import type { ChatMessage } from "@jgengine/core/game/chat";
|
|
4
4
|
import type { ChatSendOutcome } from "@jgengine/core/multiplayer/chatContract";
|
|
5
|
+
import { type PoseSyncRules } from "@jgengine/core/multiplayer/presenceModel";
|
|
5
6
|
import type { GameRuntime } from "@jgengine/core/runtime/gameRuntime";
|
|
6
7
|
import type { LeaderboardIncrement } from "@jgengine/core/runtime/hostPersistence";
|
|
7
8
|
export declare function jgengineTables(): {
|
|
@@ -9,6 +10,7 @@ export declare function jgengineTables(): {
|
|
|
9
10
|
createdAt: number;
|
|
10
11
|
dirtyAt?: number | undefined;
|
|
11
12
|
gameId: string;
|
|
13
|
+
joinCode?: string | undefined;
|
|
12
14
|
lastSavedAt?: number | undefined;
|
|
13
15
|
memberUserIds: string[];
|
|
14
16
|
mode?: string | undefined;
|
|
@@ -24,11 +26,14 @@ export declare function jgengineTables(): {
|
|
|
24
26
|
status: "closed" | "open" | "running";
|
|
25
27
|
tickAnchorMs: number;
|
|
26
28
|
updatedAt: number;
|
|
29
|
+
visibility?: "private" | "public" | undefined;
|
|
27
30
|
}, {
|
|
28
31
|
gameId: import("convex/values").VString<string, "required">;
|
|
29
32
|
status: import("convex/values").VUnion<"closed" | "open" | "running", [import("convex/values").VLiteral<"open", "required">, import("convex/values").VLiteral<"running", "required">, import("convex/values").VLiteral<"closed", "required">], "required", never>;
|
|
30
33
|
mode: import("convex/values").VString<string | undefined, "optional">;
|
|
31
34
|
modeConfig: import("convex/values").VAny<any, "optional", string>;
|
|
35
|
+
visibility: import("convex/values").VUnion<"private" | "public" | undefined, [import("convex/values").VLiteral<"public", "required">, import("convex/values").VLiteral<"private", "required">], "optional", never>;
|
|
36
|
+
joinCode: import("convex/values").VString<string | undefined, "optional">;
|
|
32
37
|
memberUserIds: import("convex/values").VArray<string[], import("convex/values").VString<string, "required">, "required">;
|
|
33
38
|
slotsPerServer: import("convex/values").VFloat64<number, "required">;
|
|
34
39
|
save: import("convex/values").VUnion<"none" | {
|
|
@@ -49,7 +54,7 @@ export declare function jgengineTables(): {
|
|
|
49
54
|
dirtyAt: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
50
55
|
createdAt: import("convex/values").VFloat64<number, "required">;
|
|
51
56
|
updatedAt: import("convex/values").VFloat64<number, "required">;
|
|
52
|
-
}, "required", "createdAt" | "dirtyAt" | "gameId" | "lastSavedAt" | "memberUserIds" | "mode" | "modeConfig" | "revision" | "save" | "save.auto" | "save.scope" | "serverState" | "sessionPlayers" | "slotsPerServer" | "status" | "tickAnchorMs" | "updatedAt" | `modeConfig.${string}` | `serverState.${string}` | `sessionPlayers.${string}`>, {
|
|
57
|
+
}, "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}`>, {
|
|
53
58
|
by_game_and_status: ["gameId", "status", "_creationTime"];
|
|
54
59
|
}, {}, {}>;
|
|
55
60
|
jgPlayerProfiles: import("convex/server").TableDefinition<import("convex/values").VObject<{
|
|
@@ -163,6 +168,7 @@ declare const schemaForTypes: import("convex/server").SchemaDefinition<{
|
|
|
163
168
|
createdAt: number;
|
|
164
169
|
dirtyAt?: number | undefined;
|
|
165
170
|
gameId: string;
|
|
171
|
+
joinCode?: string | undefined;
|
|
166
172
|
lastSavedAt?: number | undefined;
|
|
167
173
|
memberUserIds: string[];
|
|
168
174
|
mode?: string | undefined;
|
|
@@ -178,11 +184,14 @@ declare const schemaForTypes: import("convex/server").SchemaDefinition<{
|
|
|
178
184
|
status: "closed" | "open" | "running";
|
|
179
185
|
tickAnchorMs: number;
|
|
180
186
|
updatedAt: number;
|
|
187
|
+
visibility?: "private" | "public" | undefined;
|
|
181
188
|
}, {
|
|
182
189
|
gameId: import("convex/values").VString<string, "required">;
|
|
183
190
|
status: import("convex/values").VUnion<"closed" | "open" | "running", [import("convex/values").VLiteral<"open", "required">, import("convex/values").VLiteral<"running", "required">, import("convex/values").VLiteral<"closed", "required">], "required", never>;
|
|
184
191
|
mode: import("convex/values").VString<string | undefined, "optional">;
|
|
185
192
|
modeConfig: import("convex/values").VAny<any, "optional", string>;
|
|
193
|
+
visibility: import("convex/values").VUnion<"private" | "public" | undefined, [import("convex/values").VLiteral<"public", "required">, import("convex/values").VLiteral<"private", "required">], "optional", never>;
|
|
194
|
+
joinCode: import("convex/values").VString<string | undefined, "optional">;
|
|
186
195
|
memberUserIds: import("convex/values").VArray<string[], import("convex/values").VString<string, "required">, "required">;
|
|
187
196
|
slotsPerServer: import("convex/values").VFloat64<number, "required">;
|
|
188
197
|
save: import("convex/values").VUnion<"none" | {
|
|
@@ -203,7 +212,7 @@ declare const schemaForTypes: import("convex/server").SchemaDefinition<{
|
|
|
203
212
|
dirtyAt: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
204
213
|
createdAt: import("convex/values").VFloat64<number, "required">;
|
|
205
214
|
updatedAt: import("convex/values").VFloat64<number, "required">;
|
|
206
|
-
}, "required", "createdAt" | "dirtyAt" | "gameId" | "lastSavedAt" | "memberUserIds" | "mode" | "modeConfig" | "revision" | "save" | "save.auto" | "save.scope" | "serverState" | "sessionPlayers" | "slotsPerServer" | "status" | "tickAnchorMs" | "updatedAt" | `modeConfig.${string}` | `serverState.${string}` | `sessionPlayers.${string}`>, {
|
|
215
|
+
}, "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}`>, {
|
|
207
216
|
by_game_and_status: ["gameId", "status", "_creationTime"];
|
|
208
217
|
}, {}, {}>;
|
|
209
218
|
jgPlayerProfiles: import("convex/server").TableDefinition<import("convex/values").VObject<{
|
|
@@ -315,23 +324,23 @@ declare const schemaForTypes: import("convex/server").SchemaDefinition<{
|
|
|
315
324
|
type JGDataModel = DataModelFromSchemaDefinition<typeof schemaForTypes>;
|
|
316
325
|
type JGMutationCtx = GenericMutationCtx<JGDataModel>;
|
|
317
326
|
export type JgAuthMode = "anonymous" | "required";
|
|
327
|
+
export declare const DEFAULT_CONVEX_POSE_RULES: PoseSyncRules;
|
|
318
328
|
export declare function applyLeaderboardIncrements(ctx: JGMutationCtx, gameId: string, entries: LeaderboardIncrement[]): Promise<void>;
|
|
319
329
|
export declare const JG_RUNTIME_TICK_MS = 1000;
|
|
320
330
|
export declare function createGameServerFunctions(options?: {
|
|
321
331
|
runtimes?: GameRuntime[];
|
|
322
332
|
auth?: JgAuthMode;
|
|
333
|
+
slotsPerServer?: number;
|
|
334
|
+
allowedFeedActions?: readonly string[];
|
|
323
335
|
}): {
|
|
324
336
|
joinServer: import("convex/server").RegisteredMutation<"public", {
|
|
325
337
|
externalId?: string | undefined;
|
|
326
338
|
gameId: string;
|
|
339
|
+
joinCode?: string | undefined;
|
|
327
340
|
mode?: string | undefined;
|
|
328
341
|
modeConfig?: any;
|
|
329
|
-
save?: "none" | {
|
|
330
|
-
auto: string;
|
|
331
|
-
scope: "chunks" | "player" | "player+chunks";
|
|
332
|
-
} | undefined;
|
|
333
342
|
serverId?: GenericId<"jgGameServers"> | undefined;
|
|
334
|
-
|
|
343
|
+
visibility?: "private" | "public" | undefined;
|
|
335
344
|
}, Promise<{
|
|
336
345
|
serverId: GenericId<"jgGameServers">;
|
|
337
346
|
isNew: boolean;
|
|
@@ -371,7 +380,9 @@ export declare function createGameServerFunctions(options?: {
|
|
|
371
380
|
};
|
|
372
381
|
revision: number;
|
|
373
382
|
serverState: any;
|
|
374
|
-
sessionPlayers:
|
|
383
|
+
sessionPlayers: {
|
|
384
|
+
[x: string]: {} | null;
|
|
385
|
+
};
|
|
375
386
|
updatedAt: number;
|
|
376
387
|
} | null>>;
|
|
377
388
|
getPlayerProfile: import("convex/server").RegisteredQuery<"public", {
|
|
@@ -448,6 +459,7 @@ export declare function createLeaderboardFunctions(options?: {
|
|
|
448
459
|
export declare function createPresenceFunctions(options?: {
|
|
449
460
|
auth?: JgAuthMode;
|
|
450
461
|
freshWindowMs?: number;
|
|
462
|
+
poseRules?: PoseSyncRules;
|
|
451
463
|
}): {
|
|
452
464
|
list: import("convex/server").RegisteredQuery<"public", {
|
|
453
465
|
externalId?: string | undefined;
|
package/dist/server.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { defineSchema, defineTable, internalMutationGeneric, mutationGeneric, queryGeneric, } from "convex/server";
|
|
2
2
|
import { ConvexError, v } from "convex/values";
|
|
3
|
+
import { createFeedWriteGate, validateFeedWrite, } from "@jgengine/core/multiplayer/feedWriteGate";
|
|
4
|
+
import { decidePoseSync, spawnPresenceState, } from "@jgengine/core/multiplayer/presenceModel";
|
|
3
5
|
import { createGameRuntime } from "@jgengine/core/runtime/gameRuntime";
|
|
4
6
|
import { buildHydratePlayers, planServerPersist, shouldAutoSave, trimFeedEntries } from "@jgengine/core/runtime/hostPersistence";
|
|
5
7
|
import { clearDirtyFlags, createEmptyServerRow } from "@jgengine/core/runtime/snapshot";
|
|
8
|
+
import { applyCommandWithOcc, commitIfRevisionMatch } from "./occ.js";
|
|
6
9
|
const saveConfigValidator = v.union(v.literal("none"), v.object({
|
|
7
10
|
auto: v.string(),
|
|
8
11
|
scope: v.union(v.literal("player"), v.literal("chunks"), v.literal("player+chunks")),
|
|
@@ -14,6 +17,8 @@ export function jgengineTables() {
|
|
|
14
17
|
status: v.union(v.literal("open"), v.literal("running"), v.literal("closed")),
|
|
15
18
|
mode: v.optional(v.string()),
|
|
16
19
|
modeConfig: v.optional(v.any()),
|
|
20
|
+
visibility: v.optional(v.union(v.literal("public"), v.literal("private"))),
|
|
21
|
+
joinCode: v.optional(v.string()),
|
|
17
22
|
memberUserIds: v.array(v.string()),
|
|
18
23
|
slotsPerServer: v.number(),
|
|
19
24
|
save: saveConfigValidator,
|
|
@@ -86,6 +91,13 @@ const schemaForTypes = defineSchema(jgengineTables());
|
|
|
86
91
|
const query = queryGeneric;
|
|
87
92
|
const mutation = mutationGeneric;
|
|
88
93
|
const internalMutation = internalMutationGeneric;
|
|
94
|
+
export const DEFAULT_CONVEX_POSE_RULES = {
|
|
95
|
+
maxSpeed: 12,
|
|
96
|
+
maxVerticalOffset: 3,
|
|
97
|
+
minElapsedSec: 0.05,
|
|
98
|
+
maxElapsedSec: 0.5,
|
|
99
|
+
keepAliveRefreshMs: 10_000,
|
|
100
|
+
};
|
|
89
101
|
async function resolveActor(ctx, claimedExternalId, mode) {
|
|
90
102
|
const identity = await ctx.auth.getUserIdentity();
|
|
91
103
|
if (identity !== null) {
|
|
@@ -101,6 +113,14 @@ async function resolveActor(ctx, claimedExternalId, mode) {
|
|
|
101
113
|
}
|
|
102
114
|
return null;
|
|
103
115
|
}
|
|
116
|
+
async function requireServerMember(ctx, serverId, actorUserId) {
|
|
117
|
+
const server = await ctx.db.get("jgGameServers", serverId);
|
|
118
|
+
if (!server)
|
|
119
|
+
return null;
|
|
120
|
+
if (!server.memberUserIds.includes(actorUserId))
|
|
121
|
+
return null;
|
|
122
|
+
return server;
|
|
123
|
+
}
|
|
104
124
|
const builtinRuntimeCommands = {
|
|
105
125
|
"engine.ping": {
|
|
106
126
|
validate: (_snapshot) => null,
|
|
@@ -132,6 +152,8 @@ function serverDocToRecord(server) {
|
|
|
132
152
|
status: server.status,
|
|
133
153
|
mode: server.mode,
|
|
134
154
|
modeConfig: server.modeConfig,
|
|
155
|
+
visibility: server.visibility,
|
|
156
|
+
joinCode: server.joinCode,
|
|
135
157
|
memberUserIds: server.memberUserIds,
|
|
136
158
|
slotsPerServer: server.slotsPerServer,
|
|
137
159
|
save: server.save,
|
|
@@ -177,6 +199,7 @@ async function loadServerSnapshot(ctx, server, runtime) {
|
|
|
177
199
|
serverRow: server.serverState,
|
|
178
200
|
playersByUserId,
|
|
179
201
|
chunksByKey,
|
|
202
|
+
revision: server.revision,
|
|
180
203
|
});
|
|
181
204
|
}
|
|
182
205
|
export async function applyLeaderboardIncrements(ctx, gameId, entries) {
|
|
@@ -254,6 +277,14 @@ async function persistServerSnapshot(ctx, server, snapshot, save) {
|
|
|
254
277
|
});
|
|
255
278
|
}
|
|
256
279
|
}
|
|
280
|
+
for (const chunkKey of plan.deletedChunks) {
|
|
281
|
+
const existing = await ctx.db
|
|
282
|
+
.query("jgWorldChunks")
|
|
283
|
+
.withIndex("by_server_and_chunk", (q) => q.eq("serverId", server._id).eq("chunkKey", chunkKey))
|
|
284
|
+
.unique();
|
|
285
|
+
if (existing)
|
|
286
|
+
await ctx.db.delete(existing._id);
|
|
287
|
+
}
|
|
257
288
|
await ctx.db.patch(server._id, {
|
|
258
289
|
serverState: plan.server.serverState,
|
|
259
290
|
sessionPlayers: plan.server.sessionPlayers,
|
|
@@ -273,16 +304,18 @@ async function flushServerIfDue(ctx, server, now, runtime) {
|
|
|
273
304
|
}
|
|
274
305
|
export const JG_RUNTIME_TICK_MS = 1_000;
|
|
275
306
|
export function createGameServerFunctions(options) {
|
|
276
|
-
const mode = options?.auth ?? "
|
|
307
|
+
const mode = options?.auth ?? "required";
|
|
308
|
+
const hostSlotsPerServer = options?.slotsPerServer ?? 16;
|
|
309
|
+
const feedWriteGate = createFeedWriteGate(options?.allowedFeedActions ?? []);
|
|
277
310
|
const registry = buildRuntimeRegistry(options?.runtimes);
|
|
278
311
|
const joinServer = mutation({
|
|
279
312
|
args: {
|
|
280
313
|
gameId: v.string(),
|
|
281
314
|
serverId: v.optional(v.id("jgGameServers")),
|
|
282
|
-
slotsPerServer: v.optional(v.number()),
|
|
283
|
-
save: v.optional(saveConfigValidator),
|
|
284
315
|
mode: v.optional(v.string()),
|
|
285
316
|
modeConfig: v.optional(v.any()),
|
|
317
|
+
visibility: v.optional(v.union(v.literal("public"), v.literal("private"))),
|
|
318
|
+
joinCode: v.optional(v.string()),
|
|
286
319
|
externalId: v.optional(v.string()),
|
|
287
320
|
},
|
|
288
321
|
returns: v.object({
|
|
@@ -296,9 +329,12 @@ export function createGameServerFunctions(options) {
|
|
|
296
329
|
}
|
|
297
330
|
const now = Date.now();
|
|
298
331
|
const runtime = resolveRuntime(registry, args.gameId);
|
|
299
|
-
const save =
|
|
300
|
-
const slotsPerServer =
|
|
332
|
+
const save = runtime.save;
|
|
333
|
+
const slotsPerServer = hostSlotsPerServer;
|
|
301
334
|
let server = args.serverId !== undefined ? await ctx.db.get("jgGameServers", args.serverId) : null;
|
|
335
|
+
if (args.serverId !== undefined && !server) {
|
|
336
|
+
throw new ConvexError("Server not found");
|
|
337
|
+
}
|
|
302
338
|
if (server && server.gameId !== args.gameId) {
|
|
303
339
|
throw new ConvexError("Server belongs to a different game");
|
|
304
340
|
}
|
|
@@ -312,8 +348,13 @@ export function createGameServerFunctions(options) {
|
|
|
312
348
|
joinable.push(...rows);
|
|
313
349
|
}
|
|
314
350
|
server =
|
|
315
|
-
joinable.find((row) =>
|
|
316
|
-
row.memberUserIds.
|
|
351
|
+
joinable.find((row) => {
|
|
352
|
+
if (row.memberUserIds.includes(actorUserId))
|
|
353
|
+
return true;
|
|
354
|
+
if (row.memberUserIds.length >= row.slotsPerServer)
|
|
355
|
+
return false;
|
|
356
|
+
return (row.visibility ?? "public") !== "private";
|
|
357
|
+
}) ?? null;
|
|
317
358
|
}
|
|
318
359
|
if (!server) {
|
|
319
360
|
const serverId = await ctx.db.insert("jgGameServers", {
|
|
@@ -321,6 +362,8 @@ export function createGameServerFunctions(options) {
|
|
|
321
362
|
status: "running",
|
|
322
363
|
mode: args.mode,
|
|
323
364
|
modeConfig: args.modeConfig,
|
|
365
|
+
visibility: args.visibility ?? "public",
|
|
366
|
+
joinCode: args.joinCode,
|
|
324
367
|
memberUserIds: [],
|
|
325
368
|
slotsPerServer,
|
|
326
369
|
save,
|
|
@@ -339,6 +382,11 @@ export function createGameServerFunctions(options) {
|
|
|
339
382
|
!server.memberUserIds.includes(actorUserId)) {
|
|
340
383
|
throw new ConvexError("Server is full");
|
|
341
384
|
}
|
|
385
|
+
if ((server.visibility ?? "public") === "private" &&
|
|
386
|
+
!server.memberUserIds.includes(actorUserId) &&
|
|
387
|
+
args.serverId === undefined) {
|
|
388
|
+
throw new ConvexError("Server is private");
|
|
389
|
+
}
|
|
342
390
|
const profile = await ctx.db
|
|
343
391
|
.query("jgPlayerProfiles")
|
|
344
392
|
.withIndex("by_user_and_game", (q) => q.eq("userId", actorUserId).eq("gameId", args.gameId))
|
|
@@ -421,13 +469,30 @@ export function createGameServerFunctions(options) {
|
|
|
421
469
|
if (!server.memberUserIds.includes(actorUserId)) {
|
|
422
470
|
return { ok: false, reason: "Not a member of this server" };
|
|
423
471
|
}
|
|
472
|
+
const loadedRevision = server.revision;
|
|
424
473
|
const runtime = resolveRuntime(registry, server.gameId);
|
|
425
474
|
const snapshot = await loadServerSnapshot(ctx, server, runtime);
|
|
426
|
-
const result =
|
|
475
|
+
const result = applyCommandWithOcc({
|
|
476
|
+
loadedRevision,
|
|
477
|
+
currentRevision: server.revision,
|
|
478
|
+
snapshot,
|
|
479
|
+
runtime,
|
|
480
|
+
actorUserId,
|
|
481
|
+
command: args.command,
|
|
482
|
+
input: args.input,
|
|
483
|
+
});
|
|
427
484
|
if (!result.ok) {
|
|
428
485
|
return result;
|
|
429
486
|
}
|
|
430
|
-
await
|
|
487
|
+
const latest = await ctx.db.get("jgGameServers", args.serverId);
|
|
488
|
+
if (!latest) {
|
|
489
|
+
return { ok: false, reason: "Server not found" };
|
|
490
|
+
}
|
|
491
|
+
const commit = commitIfRevisionMatch(loadedRevision, latest.revision);
|
|
492
|
+
if (!commit.ok) {
|
|
493
|
+
return commit;
|
|
494
|
+
}
|
|
495
|
+
await persistServerSnapshot(ctx, latest, result.snapshot, latest.save);
|
|
431
496
|
return { ok: true };
|
|
432
497
|
},
|
|
433
498
|
});
|
|
@@ -480,6 +545,8 @@ export function createGameServerFunctions(options) {
|
|
|
480
545
|
return null;
|
|
481
546
|
if (!server.memberUserIds.includes(actorUserId))
|
|
482
547
|
return null;
|
|
548
|
+
const sessionPlayers = server.sessionPlayers;
|
|
549
|
+
const ownSession = sessionPlayers[actorUserId];
|
|
483
550
|
return {
|
|
484
551
|
_id: server._id,
|
|
485
552
|
gameId: server.gameId,
|
|
@@ -490,7 +557,7 @@ export function createGameServerFunctions(options) {
|
|
|
490
557
|
save: server.save,
|
|
491
558
|
revision: server.revision,
|
|
492
559
|
serverState: server.serverState,
|
|
493
|
-
sessionPlayers:
|
|
560
|
+
sessionPlayers: ownSession === undefined ? {} : { [actorUserId]: ownSession },
|
|
494
561
|
updatedAt: server.updatedAt,
|
|
495
562
|
};
|
|
496
563
|
},
|
|
@@ -560,6 +627,10 @@ export function createGameServerFunctions(options) {
|
|
|
560
627
|
if (!actorUserId) {
|
|
561
628
|
throw new ConvexError("Not authenticated");
|
|
562
629
|
}
|
|
630
|
+
const allowed = validateFeedWrite(feedWriteGate, args.action);
|
|
631
|
+
if (!allowed.ok) {
|
|
632
|
+
throw new ConvexError(allowed.reason);
|
|
633
|
+
}
|
|
563
634
|
const server = await ctx.db.get("jgGameServers", args.serverId);
|
|
564
635
|
if (!server || !server.memberUserIds.includes(actorUserId)) {
|
|
565
636
|
throw new ConvexError("Not a member of this server");
|
|
@@ -740,8 +811,9 @@ export function createLeaderboardFunctions(options) {
|
|
|
740
811
|
return { getTop, getProfile, incrementMany };
|
|
741
812
|
}
|
|
742
813
|
export function createPresenceFunctions(options) {
|
|
743
|
-
const mode = options?.auth ?? "
|
|
814
|
+
const mode = options?.auth ?? "required";
|
|
744
815
|
const freshWindowMs = options?.freshWindowMs ?? 10_000;
|
|
816
|
+
const poseRules = options?.poseRules ?? DEFAULT_CONVEX_POSE_RULES;
|
|
745
817
|
const list = query({
|
|
746
818
|
args: {
|
|
747
819
|
serverId: v.string(),
|
|
@@ -755,6 +827,11 @@ export function createPresenceFunctions(options) {
|
|
|
755
827
|
lastSeenAt: v.number(),
|
|
756
828
|
})),
|
|
757
829
|
handler: async (ctx, args) => {
|
|
830
|
+
const actorUserId = await resolveActor(ctx, args.externalId, mode);
|
|
831
|
+
if (!actorUserId)
|
|
832
|
+
return [];
|
|
833
|
+
if ((await requireServerMember(ctx, args.serverId, actorUserId)) === null)
|
|
834
|
+
return [];
|
|
758
835
|
const threshold = Date.now() - freshWindowMs;
|
|
759
836
|
const rows = await ctx.db
|
|
760
837
|
.query("jgPoses")
|
|
@@ -788,21 +865,38 @@ export function createPresenceFunctions(options) {
|
|
|
788
865
|
const actorUserId = await resolveActor(ctx, args.externalId, mode);
|
|
789
866
|
if (!actorUserId)
|
|
790
867
|
return null;
|
|
868
|
+
if ((await requireServerMember(ctx, args.serverId, actorUserId)) === null)
|
|
869
|
+
return null;
|
|
791
870
|
const now = Date.now();
|
|
792
871
|
const existing = await ctx.db
|
|
793
872
|
.query("jgPoses")
|
|
794
873
|
.withIndex("by_server_and_user", (q) => q.eq("serverId", args.serverId).eq("userId", actorUserId))
|
|
795
874
|
.unique();
|
|
796
|
-
const
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
875
|
+
const current = existing === null
|
|
876
|
+
? spawnPresenceState(undefined, now, poseRules)
|
|
877
|
+
: {
|
|
878
|
+
position: { x: existing.x, y: existing.y, z: existing.z },
|
|
879
|
+
rotationY: existing.rotationY,
|
|
880
|
+
rotationPitch: existing.rotationPitch,
|
|
881
|
+
lastSeenAtMs: existing.updatedAt,
|
|
882
|
+
};
|
|
883
|
+
const decision = decidePoseSync(current, {
|
|
884
|
+
position: { x: args.pose.x, y: args.pose.y, z: args.pose.z },
|
|
800
885
|
rotationY: args.pose.rotationY,
|
|
801
886
|
rotationPitch: args.pose.rotationPitch,
|
|
887
|
+
}, poseRules, now);
|
|
888
|
+
const patch = {
|
|
889
|
+
x: decision.position.x,
|
|
890
|
+
y: decision.position.y,
|
|
891
|
+
z: decision.position.z,
|
|
892
|
+
rotationY: decision.rotationY,
|
|
893
|
+
rotationPitch: decision.rotationPitch,
|
|
802
894
|
updatedAt: now,
|
|
803
895
|
};
|
|
804
896
|
if (existing) {
|
|
805
|
-
|
|
897
|
+
if (decision.changed || decision.refreshKeepAlive) {
|
|
898
|
+
await ctx.db.patch(existing._id, patch);
|
|
899
|
+
}
|
|
806
900
|
}
|
|
807
901
|
else {
|
|
808
902
|
await ctx.db.insert("jgPoses", { serverId: args.serverId, userId: actorUserId, ...patch });
|
|
@@ -832,7 +926,7 @@ export function createPresenceFunctions(options) {
|
|
|
832
926
|
return { list, sync, leave };
|
|
833
927
|
}
|
|
834
928
|
export function createChatFunctions(options) {
|
|
835
|
-
const mode = options?.auth ?? "
|
|
929
|
+
const mode = options?.auth ?? "required";
|
|
836
930
|
const historyLimit = options?.historyLimit ?? 100;
|
|
837
931
|
const maxBodyLength = options?.maxBodyLength ?? 500;
|
|
838
932
|
const minIntervalMs = options?.minIntervalMs ?? 300;
|
|
@@ -850,6 +944,11 @@ export function createChatFunctions(options) {
|
|
|
850
944
|
at: v.number(),
|
|
851
945
|
})),
|
|
852
946
|
handler: async (ctx, args) => {
|
|
947
|
+
const actorUserId = await resolveActor(ctx, args.externalId, mode);
|
|
948
|
+
if (!actorUserId)
|
|
949
|
+
return [];
|
|
950
|
+
if ((await requireServerMember(ctx, args.serverId, actorUserId)) === null)
|
|
951
|
+
return [];
|
|
853
952
|
const rows = await ctx.db
|
|
854
953
|
.query("jgChatMessages")
|
|
855
954
|
.withIndex("by_server_channel", (q) => q.eq("serverId", args.serverId).eq("channelId", args.channelId))
|
|
@@ -879,6 +978,9 @@ export function createChatFunctions(options) {
|
|
|
879
978
|
const actorUserId = await resolveActor(ctx, args.externalId, mode);
|
|
880
979
|
if (!actorUserId)
|
|
881
980
|
return { ok: false, reason: "not signed in" };
|
|
981
|
+
if ((await requireServerMember(ctx, args.serverId, actorUserId)) === null) {
|
|
982
|
+
return { ok: false, reason: "not a member of this server" };
|
|
983
|
+
}
|
|
882
984
|
const body = args.body.trim();
|
|
883
985
|
if (body.length === 0)
|
|
884
986
|
return { ok: false, reason: "empty message" };
|