@jgengine/convex 0.9.0 → 0.10.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 +21 -1
- package/dist/hostedServer.d.ts +96 -0
- package/dist/hostedServer.js +350 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/server.d.ts +35 -1
- package/dist/server.js +22 -1
- package/llms.txt +33 -10
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -11,7 +11,27 @@ 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
|
-
##
|
|
14
|
+
## 0.10.0
|
|
15
|
+
|
|
16
|
+
### Migrate
|
|
17
|
+
|
|
18
|
+
- **Nothing to change for existing games** — the shared mobile-composition system (viewport allocation, region collision, `--jg-*` viewport vars) is wired automatically by the shell for every game; `HudPanel`s inside a `HudCanvas` register themselves. Legacy `orientation: "landscape" | "portrait"` keeps its advisory dismissible-hint behavior unchanged.
|
|
19
|
+
- **Opt a driving/landscape game into the strict gate**: replace the advisory `orientation` with the contract form — `orientation: { mobile: "landscape-required" }` (rules: `any` · `portrait`/`landscape` advisory · `portrait-required`/`landscape-required` · `unsupported`). In portrait the engine now shows a polished `RotateDeviceScreen`, suppresses input, freezes the sim, and unmounts the HUD/controls until the device is landscape.
|
|
20
|
+
- **Declare HUD intent** where useful: `HudPanel` gains `priority` (`critical`/`secondary`/`tertiary`), `mobileBehavior` (`hidden` unmounts on phones, `transient` softens collision, …), `allowOverlapWith`, and `collisionGroup`. Optional — omitting them keeps prior behavior.
|
|
21
|
+
- **Mobile validation now also fails on overlap**: `bun run shoot <game> --device mobile|mobile-landscape|both` exits non-zero on forbidden inter-element collisions (not just viewport overflow), naming both regions.
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- Shared mobile layout composition — `@jgengine/core/ui/gameLayout` (pure geometry: `resolveLayoutMode`, `intersects`, `overlapArea`, `detectLayoutCollisions`, `computeGameplayRect`, `LayoutRegion`, `GameViewportLayout`) and `@jgengine/core/ui/orientation` (`resolveOrientationRequirement`, `orientationGateActive`, `MobileOrientationRule`). `@jgengine/react` adds `GameViewportProvider` (mounted by the shell), hooks (`useGameViewportLayout`, `useGameLayoutMode`, `useGameOrientation`, `useReservedControlZones`, `useLayoutCollisions`, `useRegisterLayoutRegion`), and a headless `RotateDeviceScreen`. The provider tracks `window.visualViewport`, publishes `--jg-viewport-*` / `--jg-visual-viewport-*` / `--jg-safe-*` CSS vars, resolves the explicit layout mode, and detects forbidden region overlaps (dev `data-jg-layout-collision` + console diagnostic).
|
|
26
|
+
- Mandatory orientation contract — `PlayableGame.orientation` accepts `{ mobile: <rule> }`; `landscape-required`/`portrait-required` render an engine-owned rotate gate that blocks gameplay (input suppressed, simulation frozen, HUD/controls unmounted), self-correcting when the device is turned. Reduced-motion and safe-area respected; `visualViewport`-sized for mobile Safari/PWA.
|
|
27
|
+
- Reserved touch-control zones — the touch dock registers its joystick / action-cluster / utility rectangles (runtime-measured) as `control` regions, so HUD placement and collision validation know exactly where controls sit.
|
|
28
|
+
- HUD placement metadata — `HudPanel` gains `priority`, `mobileBehavior`, `allowOverlapWith`, `collisionGroup`, and registers as a collision-tracked layout region.
|
|
29
|
+
- Mobile-landscape shoot device — `bun run shoot --device mobile-landscape` (844×390) plus collision-gate reads that fail mobile validation on forbidden overlaps.
|
|
30
|
+
- Canyon Chase migrated as the reference landscape-required game — declares `orientation: { mobile: "landscape-required" }`, composes its HUD through coordinated `HudPanel` regions (pursuit distance critical, border secondary, survey map hidden on mobile, radio transient) instead of independent fixed corners.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- First-person projectile tracers now originate from the weapon muzzle instead of the camera/eye centerline. Hit detection is unchanged (still crosshair-accurate) — only the drawn tracer's start point moved to the viewmodel muzzle, which tracks full aim yaw and pitch. Non-viewmodel games and enemy tracers are unaffected.
|
|
15
35
|
|
|
16
36
|
## 0.9.0
|
|
17
37
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { GameDefinition } from "@jgengine/core/game/defineGame";
|
|
2
|
+
import type { GameContextContent } from "@jgengine/core/runtime/gameContext";
|
|
3
|
+
import { type InputFrame } from "@jgengine/core/runtime/hostedGameRunner";
|
|
4
|
+
import { type HostedWorldSession, type HostedWorldStore } from "@jgengine/core/runtime/hostedWorldSession";
|
|
5
|
+
import type { ModelAssetRef } from "@jgengine/core/scene/assetCatalog";
|
|
6
|
+
import { type JgAuthMode } from "./server.js";
|
|
7
|
+
/** One game the hosted path can serve, bound per `gameId`: its definition plus content lookup. */
|
|
8
|
+
export interface HostedGameConfig {
|
|
9
|
+
definition: GameDefinition<ModelAssetRef, unknown>;
|
|
10
|
+
content: GameContextContent;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Everything one stateless invocation reconstructs a hosted world from: the persisted record via `store`,
|
|
14
|
+
* plus the member roster and held inputs the snapshot doesn't carry, and the op to apply to the fresh session.
|
|
15
|
+
*/
|
|
16
|
+
export interface HostedWorldInvocation<T> {
|
|
17
|
+
game: HostedGameConfig;
|
|
18
|
+
store: HostedWorldStore;
|
|
19
|
+
members?: readonly string[];
|
|
20
|
+
inputs?: Readonly<Record<string, InputFrame>>;
|
|
21
|
+
now?: () => number;
|
|
22
|
+
op: (session: HostedWorldSession) => T;
|
|
23
|
+
}
|
|
24
|
+
/** What one stateless invocation produced: the op's value, the resulting revision, whether the world changed (and was saved back), and the post-op roster. */
|
|
25
|
+
export interface HostedWorldOutcome<T> {
|
|
26
|
+
value: T;
|
|
27
|
+
revision: number;
|
|
28
|
+
changed: boolean;
|
|
29
|
+
members: readonly string[];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The reconstruct → op → save core of the stateless hosted path, Convex-agnostic so it unit-tests against
|
|
33
|
+
* `memoryWorldStore`: build a fresh {@link createHostedWorldSession} from `store.load()`, resume persisted
|
|
34
|
+
* members and replay held inputs, run the op, then persist the snapshot at `revision + 1` iff the world changed.
|
|
35
|
+
*/
|
|
36
|
+
export declare function invokeHostedWorld<T>(invocation: HostedWorldInvocation<T>): HostedWorldOutcome<T>;
|
|
37
|
+
/** Default minimum elapsed ms before `tickHostedWorlds` advances a world — override via the factory's `tickMs`. */
|
|
38
|
+
export declare const JG_HOSTED_TICK_MS = 1000;
|
|
39
|
+
/**
|
|
40
|
+
* The Convex counterpart of ws's `createWorldGameHost`: serves GameContext worlds statelessly by
|
|
41
|
+
* reconstructing a hosted session from the persisted `jgHostedWorlds` row on every invocation via
|
|
42
|
+
* {@link invokeHostedWorld}. Destructure + re-export the returned functions from a `convex/` module and
|
|
43
|
+
* drive `tickHostedWorlds` from a cron. Requires {@link jgengineHostedTables} in the deployment schema.
|
|
44
|
+
*/
|
|
45
|
+
export declare function createHostedGameServerFunctions(options: {
|
|
46
|
+
games: Record<string, HostedGameConfig>;
|
|
47
|
+
auth?: JgAuthMode;
|
|
48
|
+
tickMs?: number;
|
|
49
|
+
}): {
|
|
50
|
+
getHostedServer: import("convex/server").RegisteredQuery<"public", {
|
|
51
|
+
externalId?: string | undefined;
|
|
52
|
+
gameId: string;
|
|
53
|
+
serverId?: string | undefined;
|
|
54
|
+
}, Promise<{
|
|
55
|
+
serverId: string;
|
|
56
|
+
gameId: string;
|
|
57
|
+
revision: number;
|
|
58
|
+
serverState: any;
|
|
59
|
+
memberUserIds: string[];
|
|
60
|
+
} | null>>;
|
|
61
|
+
joinHostedServer: import("convex/server").RegisteredMutation<"public", {
|
|
62
|
+
externalId?: string | undefined;
|
|
63
|
+
gameId: string;
|
|
64
|
+
serverId?: string | undefined;
|
|
65
|
+
}, Promise<{
|
|
66
|
+
serverId: string;
|
|
67
|
+
isNew: boolean;
|
|
68
|
+
}>>;
|
|
69
|
+
leaveHostedServer: import("convex/server").RegisteredMutation<"public", {
|
|
70
|
+
externalId?: string | undefined;
|
|
71
|
+
gameId: string;
|
|
72
|
+
serverId?: string | undefined;
|
|
73
|
+
}, Promise<null>>;
|
|
74
|
+
runHostedCommand: import("convex/server").RegisteredMutation<"public", {
|
|
75
|
+
command: string;
|
|
76
|
+
externalId?: string | undefined;
|
|
77
|
+
gameId: string;
|
|
78
|
+
input: any;
|
|
79
|
+
serverId?: string | undefined;
|
|
80
|
+
}, Promise<{
|
|
81
|
+
ok: false;
|
|
82
|
+
reason: string;
|
|
83
|
+
} | {
|
|
84
|
+
ok: true;
|
|
85
|
+
}>>;
|
|
86
|
+
submitHostedInput: import("convex/server").RegisteredMutation<"public", {
|
|
87
|
+
externalId?: string | undefined;
|
|
88
|
+
frame: any;
|
|
89
|
+
gameId: string;
|
|
90
|
+
serverId?: string | undefined;
|
|
91
|
+
}, Promise<null>>;
|
|
92
|
+
tickHostedWorlds: import("convex/server").RegisteredMutation<"internal", {}, Promise<{
|
|
93
|
+
ticked: number;
|
|
94
|
+
saved: number;
|
|
95
|
+
}>>;
|
|
96
|
+
};
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { defineSchema, internalMutationGeneric, mutationGeneric, queryGeneric, } from "convex/server";
|
|
2
|
+
import { ConvexError, v } from "convex/values";
|
|
3
|
+
import { INPUT_COMMAND } from "@jgengine/core/runtime/hostedGameRunner";
|
|
4
|
+
import { createHostedWorldSession, } from "@jgengine/core/runtime/hostedWorldSession";
|
|
5
|
+
import { commitIfRevisionMatch } from "./occ.js";
|
|
6
|
+
import { jgengineHostedTables, resolveActor } from "./server.js";
|
|
7
|
+
/**
|
|
8
|
+
* The reconstruct → op → save core of the stateless hosted path, Convex-agnostic so it unit-tests against
|
|
9
|
+
* `memoryWorldStore`: build a fresh {@link createHostedWorldSession} from `store.load()`, resume persisted
|
|
10
|
+
* members and replay held inputs, run the op, then persist the snapshot at `revision + 1` iff the world changed.
|
|
11
|
+
*/
|
|
12
|
+
export function invokeHostedWorld(invocation) {
|
|
13
|
+
const { game, store, members = [], inputs = {}, now, op } = invocation;
|
|
14
|
+
const loaded = store.load();
|
|
15
|
+
const loadedJson = loaded === null ? null : JSON.stringify(loaded.snapshot);
|
|
16
|
+
const session = createHostedWorldSession({
|
|
17
|
+
definition: game.definition,
|
|
18
|
+
content: game.content,
|
|
19
|
+
store: { load: () => loaded, save: () => { } },
|
|
20
|
+
...(now === undefined ? {} : { now }),
|
|
21
|
+
});
|
|
22
|
+
for (const userId of members)
|
|
23
|
+
session.runner().resume(userId);
|
|
24
|
+
for (const [userId, frame] of Object.entries(inputs))
|
|
25
|
+
session.input(userId, frame);
|
|
26
|
+
const value = op(session);
|
|
27
|
+
const snapshot = session.runner().snapshot();
|
|
28
|
+
const baseRevision = loaded?.revision ?? 0;
|
|
29
|
+
if (loadedJson !== null && JSON.stringify(snapshot) === loadedJson) {
|
|
30
|
+
return { value, revision: baseRevision, changed: false, members: session.members() };
|
|
31
|
+
}
|
|
32
|
+
const record = { snapshot, revision: baseRevision + 1 };
|
|
33
|
+
store.save(record);
|
|
34
|
+
return { value, revision: record.revision, changed: true, members: session.members() };
|
|
35
|
+
}
|
|
36
|
+
const hostedSchemaForTypes = defineSchema(jgengineHostedTables());
|
|
37
|
+
const query = queryGeneric;
|
|
38
|
+
const mutation = mutationGeneric;
|
|
39
|
+
const internalMutation = internalMutationGeneric;
|
|
40
|
+
/** Default minimum elapsed ms before `tickHostedWorlds` advances a world — override via the factory's `tickMs`. */
|
|
41
|
+
export const JG_HOSTED_TICK_MS = 1_000;
|
|
42
|
+
async function getWorldRow(ctx, gameId, serverId) {
|
|
43
|
+
return await ctx.db
|
|
44
|
+
.query("jgHostedWorlds")
|
|
45
|
+
.withIndex("by_game_and_server", (q) => q.eq("gameId", gameId).eq("serverId", serverId))
|
|
46
|
+
.unique();
|
|
47
|
+
}
|
|
48
|
+
function heldInputsOf(row) {
|
|
49
|
+
return (row?.inputs ?? {});
|
|
50
|
+
}
|
|
51
|
+
function worldStoreForRow(row) {
|
|
52
|
+
const loaded = row === null ? null : { snapshot: row.snapshot, revision: row.revision };
|
|
53
|
+
let captured = null;
|
|
54
|
+
return {
|
|
55
|
+
store: {
|
|
56
|
+
load: () => loaded,
|
|
57
|
+
save(record) {
|
|
58
|
+
captured = record;
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
captured: () => captured,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
async function persistWorldRow(ctx, args) {
|
|
65
|
+
const nowMs = Date.now();
|
|
66
|
+
if (args.row === null) {
|
|
67
|
+
if (args.record === null)
|
|
68
|
+
return;
|
|
69
|
+
await ctx.db.insert("jgHostedWorlds", {
|
|
70
|
+
gameId: args.gameId,
|
|
71
|
+
serverId: args.serverId,
|
|
72
|
+
snapshot: args.record.snapshot,
|
|
73
|
+
revision: args.record.revision,
|
|
74
|
+
memberUserIds: [...args.memberUserIds],
|
|
75
|
+
inputs: args.inputs,
|
|
76
|
+
tickAnchorMs: args.tickAnchorMs ?? nowMs,
|
|
77
|
+
createdAt: nowMs,
|
|
78
|
+
updatedAt: nowMs,
|
|
79
|
+
});
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
await ctx.db.patch(args.row._id, {
|
|
83
|
+
...(args.record === null
|
|
84
|
+
? {}
|
|
85
|
+
: { snapshot: args.record.snapshot, revision: args.record.revision }),
|
|
86
|
+
memberUserIds: [...args.memberUserIds],
|
|
87
|
+
inputs: args.inputs,
|
|
88
|
+
...(args.tickAnchorMs === undefined ? {} : { tickAnchorMs: args.tickAnchorMs }),
|
|
89
|
+
updatedAt: nowMs,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The Convex counterpart of ws's `createWorldGameHost`: serves GameContext worlds statelessly by
|
|
94
|
+
* reconstructing a hosted session from the persisted `jgHostedWorlds` row on every invocation via
|
|
95
|
+
* {@link invokeHostedWorld}. Destructure + re-export the returned functions from a `convex/` module and
|
|
96
|
+
* drive `tickHostedWorlds` from a cron. Requires {@link jgengineHostedTables} in the deployment schema.
|
|
97
|
+
*/
|
|
98
|
+
export function createHostedGameServerFunctions(options) {
|
|
99
|
+
const mode = options.auth ?? "anonymous";
|
|
100
|
+
const tickMs = options.tickMs ?? JG_HOSTED_TICK_MS;
|
|
101
|
+
function resolveGame(gameId) {
|
|
102
|
+
const game = options.games[gameId];
|
|
103
|
+
if (game === undefined)
|
|
104
|
+
throw new ConvexError(`Unknown hosted game "${gameId}"`);
|
|
105
|
+
return game;
|
|
106
|
+
}
|
|
107
|
+
const getHostedServer = query({
|
|
108
|
+
args: {
|
|
109
|
+
gameId: v.string(),
|
|
110
|
+
serverId: v.optional(v.string()),
|
|
111
|
+
externalId: v.optional(v.string()),
|
|
112
|
+
},
|
|
113
|
+
returns: v.union(v.object({
|
|
114
|
+
serverId: v.string(),
|
|
115
|
+
gameId: v.string(),
|
|
116
|
+
revision: v.number(),
|
|
117
|
+
serverState: v.any(),
|
|
118
|
+
memberUserIds: v.array(v.string()),
|
|
119
|
+
}), v.null()),
|
|
120
|
+
handler: async (ctx, args) => {
|
|
121
|
+
const actorUserId = await resolveActor(ctx, args.externalId, mode);
|
|
122
|
+
if (!actorUserId)
|
|
123
|
+
return null;
|
|
124
|
+
const row = await getWorldRow(ctx, args.gameId, args.serverId ?? args.gameId);
|
|
125
|
+
if (!row)
|
|
126
|
+
return null;
|
|
127
|
+
if (!row.memberUserIds.includes(actorUserId))
|
|
128
|
+
return null;
|
|
129
|
+
return {
|
|
130
|
+
serverId: row.serverId,
|
|
131
|
+
gameId: row.gameId,
|
|
132
|
+
revision: row.revision,
|
|
133
|
+
serverState: row.snapshot,
|
|
134
|
+
memberUserIds: row.memberUserIds,
|
|
135
|
+
};
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
const joinHostedServer = mutation({
|
|
139
|
+
args: {
|
|
140
|
+
gameId: v.string(),
|
|
141
|
+
serverId: v.optional(v.string()),
|
|
142
|
+
externalId: v.optional(v.string()),
|
|
143
|
+
},
|
|
144
|
+
returns: v.object({ serverId: v.string(), isNew: v.boolean() }),
|
|
145
|
+
handler: async (ctx, args) => {
|
|
146
|
+
const actorUserId = await resolveActor(ctx, args.externalId, mode);
|
|
147
|
+
if (!actorUserId) {
|
|
148
|
+
throw new ConvexError("Not authenticated");
|
|
149
|
+
}
|
|
150
|
+
const game = resolveGame(args.gameId);
|
|
151
|
+
const serverId = args.serverId ?? args.gameId;
|
|
152
|
+
const row = await getWorldRow(ctx, args.gameId, serverId);
|
|
153
|
+
const isNew = !(row?.memberUserIds ?? []).includes(actorUserId);
|
|
154
|
+
const { store, captured } = worldStoreForRow(row);
|
|
155
|
+
const outcome = invokeHostedWorld({
|
|
156
|
+
game,
|
|
157
|
+
store,
|
|
158
|
+
members: row?.memberUserIds ?? [],
|
|
159
|
+
inputs: heldInputsOf(row),
|
|
160
|
+
op: (session) => session.join(actorUserId, isNew),
|
|
161
|
+
});
|
|
162
|
+
await persistWorldRow(ctx, {
|
|
163
|
+
gameId: args.gameId,
|
|
164
|
+
serverId,
|
|
165
|
+
row,
|
|
166
|
+
record: captured(),
|
|
167
|
+
memberUserIds: outcome.members,
|
|
168
|
+
inputs: heldInputsOf(row),
|
|
169
|
+
});
|
|
170
|
+
return { serverId, isNew };
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
const leaveHostedServer = mutation({
|
|
174
|
+
args: {
|
|
175
|
+
gameId: v.string(),
|
|
176
|
+
serverId: v.optional(v.string()),
|
|
177
|
+
externalId: v.optional(v.string()),
|
|
178
|
+
},
|
|
179
|
+
returns: v.null(),
|
|
180
|
+
handler: async (ctx, args) => {
|
|
181
|
+
const actorUserId = await resolveActor(ctx, args.externalId, mode);
|
|
182
|
+
if (!actorUserId) {
|
|
183
|
+
throw new ConvexError("Not authenticated");
|
|
184
|
+
}
|
|
185
|
+
const serverId = args.serverId ?? args.gameId;
|
|
186
|
+
const row = await getWorldRow(ctx, args.gameId, serverId);
|
|
187
|
+
if (!row)
|
|
188
|
+
return null;
|
|
189
|
+
if (!row.memberUserIds.includes(actorUserId))
|
|
190
|
+
return null;
|
|
191
|
+
const game = resolveGame(args.gameId);
|
|
192
|
+
const { store, captured } = worldStoreForRow(row);
|
|
193
|
+
const outcome = invokeHostedWorld({
|
|
194
|
+
game,
|
|
195
|
+
store,
|
|
196
|
+
members: row.memberUserIds,
|
|
197
|
+
inputs: heldInputsOf(row),
|
|
198
|
+
op: (session) => session.leave(actorUserId),
|
|
199
|
+
});
|
|
200
|
+
const inputs = heldInputsOf(row);
|
|
201
|
+
delete inputs[actorUserId];
|
|
202
|
+
await persistWorldRow(ctx, {
|
|
203
|
+
gameId: args.gameId,
|
|
204
|
+
serverId,
|
|
205
|
+
row,
|
|
206
|
+
record: captured(),
|
|
207
|
+
memberUserIds: outcome.members,
|
|
208
|
+
inputs,
|
|
209
|
+
});
|
|
210
|
+
return null;
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
const runHostedCommand = mutation({
|
|
214
|
+
args: {
|
|
215
|
+
gameId: v.string(),
|
|
216
|
+
serverId: v.optional(v.string()),
|
|
217
|
+
command: v.string(),
|
|
218
|
+
input: v.any(),
|
|
219
|
+
externalId: v.optional(v.string()),
|
|
220
|
+
},
|
|
221
|
+
returns: v.union(v.object({ ok: v.literal(true) }), v.object({ ok: v.literal(false), reason: v.string() })),
|
|
222
|
+
handler: async (ctx, args) => {
|
|
223
|
+
const actorUserId = await resolveActor(ctx, args.externalId, mode);
|
|
224
|
+
if (!actorUserId) {
|
|
225
|
+
throw new ConvexError("Not authenticated");
|
|
226
|
+
}
|
|
227
|
+
const serverId = args.serverId ?? args.gameId;
|
|
228
|
+
const row = await getWorldRow(ctx, args.gameId, serverId);
|
|
229
|
+
if (!row) {
|
|
230
|
+
return { ok: false, reason: "Server not found" };
|
|
231
|
+
}
|
|
232
|
+
if (!row.memberUserIds.includes(actorUserId)) {
|
|
233
|
+
return { ok: false, reason: "Not a member of this server" };
|
|
234
|
+
}
|
|
235
|
+
if (args.command === INPUT_COMMAND) {
|
|
236
|
+
await ctx.db.patch(row._id, {
|
|
237
|
+
inputs: { ...heldInputsOf(row), [actorUserId]: args.input },
|
|
238
|
+
updatedAt: Date.now(),
|
|
239
|
+
});
|
|
240
|
+
return { ok: true };
|
|
241
|
+
}
|
|
242
|
+
const game = resolveGame(args.gameId);
|
|
243
|
+
const loadedRevision = row.revision;
|
|
244
|
+
const { store, captured } = worldStoreForRow(row);
|
|
245
|
+
const outcome = invokeHostedWorld({
|
|
246
|
+
game,
|
|
247
|
+
store,
|
|
248
|
+
members: row.memberUserIds,
|
|
249
|
+
inputs: heldInputsOf(row),
|
|
250
|
+
op: (session) => session.command(actorUserId, args.command, args.input),
|
|
251
|
+
});
|
|
252
|
+
if (outcome.value.status === "rejected") {
|
|
253
|
+
return { ok: false, reason: outcome.value.reason };
|
|
254
|
+
}
|
|
255
|
+
if (outcome.value.status === "unknown-command") {
|
|
256
|
+
return { ok: false, reason: "unknown-command" };
|
|
257
|
+
}
|
|
258
|
+
const latest = await getWorldRow(ctx, args.gameId, serverId);
|
|
259
|
+
if (!latest) {
|
|
260
|
+
return { ok: false, reason: "Server not found" };
|
|
261
|
+
}
|
|
262
|
+
const commit = commitIfRevisionMatch(loadedRevision, latest.revision);
|
|
263
|
+
if (!commit.ok) {
|
|
264
|
+
return commit;
|
|
265
|
+
}
|
|
266
|
+
await persistWorldRow(ctx, {
|
|
267
|
+
gameId: args.gameId,
|
|
268
|
+
serverId,
|
|
269
|
+
row: latest,
|
|
270
|
+
record: captured(),
|
|
271
|
+
memberUserIds: outcome.members,
|
|
272
|
+
inputs: heldInputsOf(latest),
|
|
273
|
+
});
|
|
274
|
+
return { ok: true };
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
const submitHostedInput = mutation({
|
|
278
|
+
args: {
|
|
279
|
+
gameId: v.string(),
|
|
280
|
+
serverId: v.optional(v.string()),
|
|
281
|
+
frame: v.any(),
|
|
282
|
+
externalId: v.optional(v.string()),
|
|
283
|
+
},
|
|
284
|
+
returns: v.null(),
|
|
285
|
+
handler: async (ctx, args) => {
|
|
286
|
+
const actorUserId = await resolveActor(ctx, args.externalId, mode);
|
|
287
|
+
if (!actorUserId) {
|
|
288
|
+
throw new ConvexError("Not authenticated");
|
|
289
|
+
}
|
|
290
|
+
const row = await getWorldRow(ctx, args.gameId, args.serverId ?? args.gameId);
|
|
291
|
+
if (!row || !row.memberUserIds.includes(actorUserId)) {
|
|
292
|
+
throw new ConvexError("Not a member of this server");
|
|
293
|
+
}
|
|
294
|
+
await ctx.db.patch(row._id, {
|
|
295
|
+
inputs: { ...heldInputsOf(row), [actorUserId]: args.frame },
|
|
296
|
+
updatedAt: Date.now(),
|
|
297
|
+
});
|
|
298
|
+
return null;
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
const tickHostedWorlds = internalMutation({
|
|
302
|
+
args: {},
|
|
303
|
+
handler: async (ctx) => {
|
|
304
|
+
const nowMs = Date.now();
|
|
305
|
+
const rows = await ctx.db.query("jgHostedWorlds").collect();
|
|
306
|
+
let ticked = 0;
|
|
307
|
+
let saved = 0;
|
|
308
|
+
for (const row of rows) {
|
|
309
|
+
const game = options.games[row.gameId];
|
|
310
|
+
if (game === undefined)
|
|
311
|
+
continue;
|
|
312
|
+
if (row.memberUserIds.length === 0)
|
|
313
|
+
continue;
|
|
314
|
+
const elapsedMs = nowMs - row.tickAnchorMs;
|
|
315
|
+
if (elapsedMs < tickMs)
|
|
316
|
+
continue;
|
|
317
|
+
const { store, captured } = worldStoreForRow(row);
|
|
318
|
+
const outcome = invokeHostedWorld({
|
|
319
|
+
game,
|
|
320
|
+
store,
|
|
321
|
+
members: row.memberUserIds,
|
|
322
|
+
inputs: heldInputsOf(row),
|
|
323
|
+
op: (session) => session.tick(elapsedMs / 1_000),
|
|
324
|
+
});
|
|
325
|
+
ticked += 1;
|
|
326
|
+
const record = captured();
|
|
327
|
+
if (record !== null)
|
|
328
|
+
saved += 1;
|
|
329
|
+
await persistWorldRow(ctx, {
|
|
330
|
+
gameId: row.gameId,
|
|
331
|
+
serverId: row.serverId,
|
|
332
|
+
row,
|
|
333
|
+
record,
|
|
334
|
+
memberUserIds: outcome.members,
|
|
335
|
+
inputs: heldInputsOf(row),
|
|
336
|
+
tickAnchorMs: nowMs,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
return { ticked, saved };
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
return {
|
|
343
|
+
getHostedServer,
|
|
344
|
+
joinHostedServer,
|
|
345
|
+
leaveHostedServer,
|
|
346
|
+
runHostedCommand,
|
|
347
|
+
submitHostedInput,
|
|
348
|
+
tickHostedWorlds,
|
|
349
|
+
};
|
|
350
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export * from "./convexChatTransport.js";
|
|
|
2
2
|
export * from "./convexPresenceTransport.js";
|
|
3
3
|
export * from "./createConvexBackend.js";
|
|
4
4
|
export * from "./createConvexGameTransport.js";
|
|
5
|
+
export * from "./hostedServer.js";
|
|
5
6
|
export * from "./occ.js";
|
|
6
7
|
export * from "./resolveConvexMultiplayer.js";
|
|
7
8
|
export * from "./server.js";
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,7 @@ export * from "./convexChatTransport.js";
|
|
|
2
2
|
export * from "./convexPresenceTransport.js";
|
|
3
3
|
export * from "./createConvexBackend.js";
|
|
4
4
|
export * from "./createConvexGameTransport.js";
|
|
5
|
+
export * from "./hostedServer.js";
|
|
5
6
|
export * from "./occ.js";
|
|
6
7
|
export * from "./resolveConvexMultiplayer.js";
|
|
7
8
|
export * from "./server.js";
|
package/dist/server.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DataModelFromSchemaDefinition, GenericMutationCtx } from "convex/server";
|
|
1
|
+
import type { Auth, DataModelFromSchemaDefinition, GenericMutationCtx } from "convex/server";
|
|
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";
|
|
@@ -163,6 +163,36 @@ export declare function jgengineTables(): {
|
|
|
163
163
|
by_server_channel: ["serverId", "channelId", "_creationTime"];
|
|
164
164
|
}, {}, {}>;
|
|
165
165
|
};
|
|
166
|
+
/**
|
|
167
|
+
* Convex table for the hosted GameContext-world path: one row per (gameId, serverId) holding the full
|
|
168
|
+
* `WorldSnapshot` blob, its revision, the member roster, and each member's held input. A sibling of
|
|
169
|
+
* {@link jgengineTables} — spread either (or both) into `defineSchema`.
|
|
170
|
+
*/
|
|
171
|
+
export declare function jgengineHostedTables(): {
|
|
172
|
+
jgHostedWorlds: import("convex/server").TableDefinition<import("convex/values").VObject<{
|
|
173
|
+
createdAt: number;
|
|
174
|
+
gameId: string;
|
|
175
|
+
inputs: any;
|
|
176
|
+
memberUserIds: string[];
|
|
177
|
+
revision: number;
|
|
178
|
+
serverId: string;
|
|
179
|
+
snapshot: any;
|
|
180
|
+
tickAnchorMs: number;
|
|
181
|
+
updatedAt: number;
|
|
182
|
+
}, {
|
|
183
|
+
gameId: import("convex/values").VString<string, "required">;
|
|
184
|
+
serverId: import("convex/values").VString<string, "required">;
|
|
185
|
+
snapshot: import("convex/values").VAny<any, "required", string>;
|
|
186
|
+
revision: import("convex/values").VFloat64<number, "required">;
|
|
187
|
+
memberUserIds: import("convex/values").VArray<string[], import("convex/values").VString<string, "required">, "required">;
|
|
188
|
+
inputs: import("convex/values").VAny<any, "required", string>;
|
|
189
|
+
tickAnchorMs: import("convex/values").VFloat64<number, "required">;
|
|
190
|
+
createdAt: import("convex/values").VFloat64<number, "required">;
|
|
191
|
+
updatedAt: import("convex/values").VFloat64<number, "required">;
|
|
192
|
+
}, "required", "createdAt" | "gameId" | "inputs" | "memberUserIds" | "revision" | "serverId" | "snapshot" | "tickAnchorMs" | "updatedAt" | `inputs.${string}` | `snapshot.${string}`>, {
|
|
193
|
+
by_game_and_server: ["gameId", "serverId", "_creationTime"];
|
|
194
|
+
}, {}, {}>;
|
|
195
|
+
};
|
|
166
196
|
declare const schemaForTypes: import("convex/server").SchemaDefinition<{
|
|
167
197
|
jgGameServers: import("convex/server").TableDefinition<import("convex/values").VObject<{
|
|
168
198
|
createdAt: number;
|
|
@@ -325,6 +355,10 @@ type JGDataModel = DataModelFromSchemaDefinition<typeof schemaForTypes>;
|
|
|
325
355
|
type JGMutationCtx = GenericMutationCtx<JGDataModel>;
|
|
326
356
|
export type JgAuthMode = "anonymous" | "required";
|
|
327
357
|
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`. */
|
|
359
|
+
export declare function resolveActor(ctx: {
|
|
360
|
+
auth: Auth;
|
|
361
|
+
}, claimedExternalId: string | undefined, mode: JgAuthMode): Promise<string | null>;
|
|
328
362
|
export declare function applyLeaderboardIncrements(ctx: JGMutationCtx, gameId: string, entries: LeaderboardIncrement[]): Promise<void>;
|
|
329
363
|
export declare const JG_RUNTIME_TICK_MS = 1000;
|
|
330
364
|
export declare function createGameServerFunctions(options?: {
|
package/dist/server.js
CHANGED
|
@@ -87,6 +87,26 @@ export function jgengineTables() {
|
|
|
87
87
|
}).index("by_server_channel", ["serverId", "channelId"]),
|
|
88
88
|
};
|
|
89
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Convex table for the hosted GameContext-world path: one row per (gameId, serverId) holding the full
|
|
92
|
+
* `WorldSnapshot` blob, its revision, the member roster, and each member's held input. A sibling of
|
|
93
|
+
* {@link jgengineTables} — spread either (or both) into `defineSchema`.
|
|
94
|
+
*/
|
|
95
|
+
export function jgengineHostedTables() {
|
|
96
|
+
return {
|
|
97
|
+
jgHostedWorlds: defineTable({
|
|
98
|
+
gameId: v.string(),
|
|
99
|
+
serverId: v.string(),
|
|
100
|
+
snapshot: v.any(),
|
|
101
|
+
revision: v.number(),
|
|
102
|
+
memberUserIds: v.array(v.string()),
|
|
103
|
+
inputs: v.any(),
|
|
104
|
+
tickAnchorMs: v.number(),
|
|
105
|
+
createdAt: v.number(),
|
|
106
|
+
updatedAt: v.number(),
|
|
107
|
+
}).index("by_game_and_server", ["gameId", "serverId"]),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
90
110
|
const schemaForTypes = defineSchema(jgengineTables());
|
|
91
111
|
const query = queryGeneric;
|
|
92
112
|
const mutation = mutationGeneric;
|
|
@@ -98,7 +118,8 @@ export const DEFAULT_CONVEX_POSE_RULES = {
|
|
|
98
118
|
maxElapsedSec: 0.5,
|
|
99
119
|
keepAliveRefreshMs: 10_000,
|
|
100
120
|
};
|
|
101
|
-
|
|
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`. */
|
|
122
|
+
export async function resolveActor(ctx, claimedExternalId, mode) {
|
|
102
123
|
const identity = await ctx.auth.getUserIdentity();
|
|
103
124
|
if (identity !== null) {
|
|
104
125
|
if (claimedExternalId !== undefined &&
|
package/llms.txt
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @jgengine/convex
|
|
2
2
|
> Convex adapters for JGengine: game transport, presence transport, and backend wiring over @jgengine/core.
|
|
3
3
|
|
|
4
|
-
Version: 0.
|
|
4
|
+
Version: 0.10.0
|
|
5
5
|
License: AGPL-3.0-only
|
|
6
6
|
Repository: https://github.com/Noisemaker111/jgengine
|
|
7
7
|
Docs: https://jgengine.com
|
|
@@ -39,6 +39,15 @@ Imports use deep paths: `@jgengine/convex/<path>`.
|
|
|
39
39
|
- defaultConvexGameApi (function): function defaultConvexGameApi(): ConvexGameApi
|
|
40
40
|
- watchConvexQuery (function): function watchConvexQuery<TArgs extends DefaultFunctionArgs, TResult, TView>(client: ConvexReactClient, query: FunctionReference<"query", "public", TArgs, TResult>, args: TArgs, toView: (result: TResult) => TView, onChange: (view: TView) => void): () => void
|
|
41
41
|
|
|
42
|
+
### @jgengine/convex/hostedServer
|
|
43
|
+
|
|
44
|
+
- HostedGameConfig (interface): interface HostedGameConfig — One game the hosted path can serve, bound per `gameId`: its definition plus content lookup.
|
|
45
|
+
- HostedWorldInvocation (interface): interface HostedWorldInvocation<T> — Everything one stateless invocation reconstructs a hosted world from: the persisted record via `store`, plus the member roster and held inputs the snapshot doesn't carry, and the op to apply to the fresh session.
|
|
46
|
+
- HostedWorldOutcome (interface): interface HostedWorldOutcome<T> — What one stateless invocation produced: the op's value, the resulting revision, whether the world changed (and was saved back), and the post-op roster.
|
|
47
|
+
- JG_HOSTED_TICK_MS (const): const JG_HOSTED_TICK_MS: 1000 — Default minimum elapsed ms before `tickHostedWorlds` advances a world — override via the factory's `tickMs`.
|
|
48
|
+
- createHostedGameServerFunctions (function): function createHostedGameServerFunctions(options: { games: Record<string, HostedGameConfig>; auth?: JgAuthMode; tickMs?: number; }): { getHostedServer: RegisteredQuery<"public", { serverId?: string | undefined; externalId?: string | undefined; gameId: string; }, Promise<{ serverId: string; gameId: s… — The Convex counterpart of ws's `createWorldGameHost`: serves GameContext worlds statelessly by reconstructing a hosted session from the persisted `jgHostedWorlds` row on every invocation via {@link invokeHostedWorld}. Destructure + re-export the returned functions from a `convex/` module and drive `tickHostedWorlds` from a cron. Requires {@link jgengineHostedTables} in the deployment schema.
|
|
49
|
+
- invokeHostedWorld (function): function invokeHostedWorld<T>(invocation: HostedWorldInvocation<T>): HostedWorldOutcome<T> — The reconstruct → op → save core of the stateless hosted path, Convex-agnostic so it unit-tests against `memoryWorldStore`: build a fresh {@link createHostedWorldSession} from `store.load()`, resume persisted members and replay held inputs, run the op, then persist the snapshot at `revision + 1` iff the world changed.
|
|
50
|
+
|
|
42
51
|
### @jgengine/convex/index
|
|
43
52
|
|
|
44
53
|
- ConvexBackend (type): type ConvexBackend<TPresenceRow = unknown, TPresenceLocation = unknown, TGameId extends string = string> = LiveGameBackend<TPresenceRow, TPresenceLocation, TGameId> & { leaderboard: ConvexLeaderboardReads; }
|
|
@@ -49,6 +58,10 @@ Imports use deep paths: `@jgengine/convex/<path>`.
|
|
|
49
58
|
- ConvexLeaderboardReads (type): type ConvexLeaderboardReads = ReturnType<typeof createConvexLeaderboardReads>
|
|
50
59
|
- ConvexPresenceFunctions (interface): interface ConvexPresenceFunctions
|
|
51
60
|
- DEFAULT_CONVEX_POSE_RULES (const): const DEFAULT_CONVEX_POSE_RULES: PoseSyncRules
|
|
61
|
+
- HostedGameConfig (interface): interface HostedGameConfig — One game the hosted path can serve, bound per `gameId`: its definition plus content lookup.
|
|
62
|
+
- HostedWorldInvocation (interface): interface HostedWorldInvocation<T> — Everything one stateless invocation reconstructs a hosted world from: the persisted record via `store`, plus the member roster and held inputs the snapshot doesn't carry, and the op to apply to the fresh session.
|
|
63
|
+
- HostedWorldOutcome (interface): interface HostedWorldOutcome<T> — What one stateless invocation produced: the op's value, the resulting revision, whether the world changed (and was saved back), and the post-op roster.
|
|
64
|
+
- JG_HOSTED_TICK_MS (const): const JG_HOSTED_TICK_MS: 1000 — Default minimum elapsed ms before `tickHostedWorlds` advances a world — override via the factory's `tickMs`.
|
|
52
65
|
- JG_RUNTIME_TICK_MS (const): const JG_RUNTIME_TICK_MS: 1000
|
|
53
66
|
- JgAuthMode (type): type JgAuthMode = "anonymous" | "required"
|
|
54
67
|
- JgCronSpec (type): type JgCronSpec = { name: string; intervalSeconds: number; functionKey: "tickActiveServers" | "flushDirtyServers"; }
|
|
@@ -66,13 +79,17 @@ Imports use deep paths: `@jgengine/convex/<path>`.
|
|
|
66
79
|
- createConvexLeaderboardReads (function): function createConvexLeaderboardReads(api: ConvexGameApi, config: ConvexGameTransportConfig): { getTop(args: { stat: string; scope: LeaderboardScope; serverId?: string | undefined; limit?: number | undefined; }): { query: FunctionReference<"query", "public", { gameId: string; stat: string; scope: Le…
|
|
67
80
|
- createConvexPresenceSync (function): function createConvexPresenceSync(client: ConvexReactClient, api: ConvexGameApi, config: ConvexGameTransportConfig, tuning?: PoseSyncTuning): PresenceSync
|
|
68
81
|
- createConvexPresenceTransport (function): function createConvexPresenceTransport<TRawRow extends RawPresenceActor, TRow, TLocation, TGameId extends string = string>(functions: ConvexPresenceFunctions, mapRow: (row: TRawRow) => TRow): PresenceTransport<TRow, TLocation, TGameId> — Wires a game's Convex presence functions into the engine's PresenceTransport contract: one snapshot subscription (my location + online players), one residents subscription (dormant candidates, decoupled from pose writes), and one tick mutation for pose upload + keep-alive. Dormant rows are derived client-side by subtracting the snapshot's online actors, so pose ticks never re-execute the residents query. mapRow converts backend rows into the game's row type (e.g. branding positions into its coordinate space).
|
|
69
|
-
- createGameServerFunctions (function): function createGameServerFunctions(options?: { runtimes?: GameRuntime[]; auth?: JgAuthMode; slotsPerServer?: number; allowedFeedActions?: readonly string[]; }): { joinServer: RegisteredMutation<"public", {
|
|
82
|
+
- createGameServerFunctions (function): function createGameServerFunctions(options?: { runtimes?: GameRuntime[]; auth?: JgAuthMode; slotsPerServer?: number; allowedFeedActions?: readonly string[]; }): { joinServer: RegisteredMutation<"public", { serverId?: Id<"jgGameServers"> | undefined; externalId?: string | undefined; mode?: string | u…
|
|
83
|
+
- createHostedGameServerFunctions (function): function createHostedGameServerFunctions(options: { games: Record<string, HostedGameConfig>; auth?: JgAuthMode; tickMs?: number; }): { getHostedServer: RegisteredQuery<"public", { serverId?: string | undefined; externalId?: string | undefined; gameId: string; }, Promise<{ serverId: string; gameId: s… — The Convex counterpart of ws's `createWorldGameHost`: serves GameContext worlds statelessly by reconstructing a hosted session from the persisted `jgHostedWorlds` row on every invocation via {@link invokeHostedWorld}. Destructure + re-export the returned functions from a `convex/` module and drive `tickHostedWorlds` from a cron. Requires {@link jgengineHostedTables} in the deployment schema.
|
|
70
84
|
- createLeaderboardFunctions (function): function createLeaderboardFunctions(options?: { auth?: JgAuthMode }): { getTop: RegisteredQuery<"public", { serverId?: Id<"jgGameServers"> | undefined; externalId?: string | undefined; limit?: number | undefined; gameId: string; scope: "global" | "server" | "profile"; stat: string; }, Promise<{ user…
|
|
71
85
|
- createPresenceFunctions (function): function createPresenceFunctions(options?: { auth?: JgAuthMode; freshWindowMs?: number; poseRules?: PoseSyncRules; }): { list: RegisteredQuery<"public", { externalId?: string | undefined; serverId: string; }, Promise<{ userId: string; position: { x: number; y: number; z: number; }; rotationY: number…
|
|
72
86
|
- defaultConvexGameApi (function): function defaultConvexGameApi(): ConvexGameApi
|
|
87
|
+
- invokeHostedWorld (function): function invokeHostedWorld<T>(invocation: HostedWorldInvocation<T>): HostedWorldOutcome<T> — The reconstruct → op → save core of the stateless hosted path, Convex-agnostic so it unit-tests against `memoryWorldStore`: build a fresh {@link createHostedWorldSession} from `store.load()`, resume persisted members and replay held inputs, run the op, then persist the snapshot at `revision + 1` iff the world changed.
|
|
73
88
|
- jgengineCronSpecs (function): function jgengineCronSpecs(): readonly JgCronSpec[]
|
|
74
|
-
-
|
|
89
|
+
- jgengineHostedTables (function): function jgengineHostedTables(): { jgHostedWorlds: TableDefinition<VObject<{ gameId: string; serverId: string; snapshot: any; revision: number; memberUserIds: string[]; inputs: any; tickAnchorMs: number; createdAt: number; updatedAt: number; }, { gameId: VString<string, "required">; serverId: VStrin… — Convex table for the hosted GameContext-world path: one row per (gameId, serverId) holding the full `WorldSnapshot` blob, its revision, the member roster, and each member's held input. A sibling of {@link jgengineTables} — spread either (or both) into `defineSchema`.
|
|
90
|
+
- jgengineTables (function): function jgengineTables(): { jgGameServers: TableDefinition<VObject<{ mode?: string | undefined; modeConfig?: any; visibility?: "public" | "private" | undefined; joinCode?: string | undefined; lastSavedAt?: number | undefined; dirtyAt?: number | undefined; gameId: string; revision: number; memberUse…
|
|
75
91
|
- randomConvexPlayerId (function): function randomConvexPlayerId(): string
|
|
92
|
+
- resolveActor (function): function resolveActor(ctx: { auth: Auth }, claimedExternalId: string | undefined, mode: JgAuthMode): Promise<string | null> — 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`.
|
|
76
93
|
- resolveConvexMultiplayer (function): function resolveConvexMultiplayer(args: { game: GameDefinition; gameId: string; url?: string; client?: ConvexReactClient; api?: ConvexGameApi; userId?: string; force?: boolean; feedActions?: string[]; poseTuning?: PoseSyncTuning; }): MultiplayerSession | null
|
|
77
94
|
- watchConvexQuery (function): function watchConvexQuery<TArgs extends DefaultFunctionArgs, TResult, TView>(client: ConvexReactClient, query: FunctionReference<"query", "public", TArgs, TResult>, args: TArgs, toView: (result: TResult) => TView, onChange: (view: TView) => void): () => void
|
|
78
95
|
|
|
@@ -95,11 +112,13 @@ Imports use deep paths: `@jgengine/convex/<path>`.
|
|
|
95
112
|
- JgCronSpec (type): type JgCronSpec = { name: string; intervalSeconds: number; functionKey: "tickActiveServers" | "flushDirtyServers"; }
|
|
96
113
|
- applyLeaderboardIncrements (function): function applyLeaderboardIncrements(ctx: JGMutationCtx, gameId: string, entries: LeaderboardIncrement[]): Promise<void>
|
|
97
114
|
- createChatFunctions (function): function createChatFunctions(options?: { auth?: JgAuthMode; historyLimit?: number; maxBodyLength?: number; minIntervalMs?: number; }): { messages: RegisteredQuery<"public", { externalId?: string | undefined; serverId: string; channelId: string; }, Promise<ChatMessage[]>>; sendMessage: RegisteredMuta…
|
|
98
|
-
- createGameServerFunctions (function): function createGameServerFunctions(options?: { runtimes?: GameRuntime[]; auth?: JgAuthMode; slotsPerServer?: number; allowedFeedActions?: readonly string[]; }): { joinServer: RegisteredMutation<"public", {
|
|
115
|
+
- createGameServerFunctions (function): function createGameServerFunctions(options?: { runtimes?: GameRuntime[]; auth?: JgAuthMode; slotsPerServer?: number; allowedFeedActions?: readonly string[]; }): { joinServer: RegisteredMutation<"public", { serverId?: Id<"jgGameServers"> | undefined; externalId?: string | undefined; mode?: string | u…
|
|
99
116
|
- createLeaderboardFunctions (function): function createLeaderboardFunctions(options?: { auth?: JgAuthMode }): { getTop: RegisteredQuery<"public", { serverId?: Id<"jgGameServers"> | undefined; externalId?: string | undefined; limit?: number | undefined; gameId: string; scope: "global" | "server" | "profile"; stat: string; }, Promise<{ user…
|
|
100
117
|
- createPresenceFunctions (function): function createPresenceFunctions(options?: { auth?: JgAuthMode; freshWindowMs?: number; poseRules?: PoseSyncRules; }): { list: RegisteredQuery<"public", { externalId?: string | undefined; serverId: string; }, Promise<{ userId: string; position: { x: number; y: number; z: number; }; rotationY: number…
|
|
101
118
|
- jgengineCronSpecs (function): function jgengineCronSpecs(): readonly JgCronSpec[]
|
|
102
|
-
-
|
|
119
|
+
- jgengineHostedTables (function): function jgengineHostedTables(): { jgHostedWorlds: TableDefinition<VObject<{ gameId: string; serverId: string; snapshot: any; revision: number; memberUserIds: string[]; inputs: any; tickAnchorMs: number; createdAt: number; updatedAt: number; }, { gameId: VString<string, "required">; serverId: VStrin… — Convex table for the hosted GameContext-world path: one row per (gameId, serverId) holding the full `WorldSnapshot` blob, its revision, the member roster, and each member's held input. A sibling of {@link jgengineTables} — spread either (or both) into `defineSchema`.
|
|
120
|
+
- jgengineTables (function): function jgengineTables(): { jgGameServers: TableDefinition<VObject<{ mode?: string | undefined; modeConfig?: any; visibility?: "public" | "private" | undefined; joinCode?: string | undefined; lastSavedAt?: number | undefined; dirtyAt?: number | undefined; gameId: string; revision: number; memberUse…
|
|
121
|
+
- resolveActor (function): function resolveActor(ctx: { auth: Auth }, claimedExternalId: string | undefined, mode: JgAuthMode): Promise<string | null> — 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`.
|
|
103
122
|
|
|
104
123
|
## Guides
|
|
105
124
|
|
|
@@ -201,7 +220,9 @@ Exact import paths and export names — **do not invent paths**; every row below
|
|
|
201
220
|
| Loadout | `game/loadout` | `LoadoutDef`, `LoadoutItemEntry`, `Loadouts` |
|
|
202
221
|
| Cosmetic loadout | `game/cosmetics` | `createCosmetics`, `Cosmetics`, `CosmeticLoadoutDef` |
|
|
203
222
|
| Quest | `game/quest` | `QuestDef`, `QuestRewards`, `QuestObjective`, `QuestJournal` |
|
|
204
|
-
| World features | `world/features` | `WorldFeature`, `biomes`, `voxel`, `plots`, `tilemap`, `flat`, `environment`, `terrain`, `rain`, `snow`, `grass`, `ocean`, `building` |
|
|
223
|
+
| World features | `world/features` | `WorldFeature`, `biomes`, `voxel`, `plots`, `tilemap`, `flat`, `environment`, `terrain`, `rain`, `snow`, `grass`, `ocean`, `building`, `road` |
|
|
224
|
+
| Street layout | `world/streets` | `laneCenters`, `sidewalkPaths`, `furnitureSpots`, `parkingSpots`, `sidewalkPoint`, `offsetPath`, `sidewalkWidthOf`, `StreetLane`, `FurnitureSpot`, `ParkingSpot` — where things belong on a `road()`: lanes for traffic, sidewalks for peds, curb anchors for furniture |
|
|
225
|
+
| Road ribbons | `world/roads` | `buildRoadRibbon`, `dashSegments`, `nearestOnPath`, `isOnRoad`, `pathLength`, `RoadRibbon`, `RoadSample`, `RoadPoint` — geometry + queries behind the `road()` environment feature |
|
|
205
226
|
| Voxel field | `world/voxelField` | `createVoxelField`, `VoxelField`, `VoxelCell`, `VoxelHit`, `VoxelBounds`, `VoxelFieldSummary`, `VoxelFace`, `VOXEL_FACES`, `VOXEL_FACE_NORMALS` — a chunked block lattice, distinct from the `voxel()` `WorldFeature` descriptor |
|
|
206
227
|
| Terrain field | `world/terrain` | `TerrainField`, `noiseField`, `resolveTerrainField`, `rollingField`, `fractalNoise`, `valueNoise`, `withNormal`, `arenaField`, `flatField`, `resolveGroundStep`, `snapToGround`, `snapEntityToGround`, `resolveTerrainPalette`, `TERRAIN_MATERIAL_PALETTES` |
|
|
207
228
|
| Seeded RNG | `random/rng` | `seededRng`, `seededStreams` |
|
|
@@ -493,7 +514,7 @@ A voxel block is an object. A rack is an object with a slot inventory. A GPU is
|
|
|
493
514
|
|
|
494
515
|
## Game repo layout
|
|
495
516
|
|
|
496
|
-
Every game is one shape, enforced by `check-game-shape` (part of `check-types`): the top of `src/` holds only the skeleton, and every game-specific module, UI component, and test lives under `src/game/`. Dense files — one `catalog.ts` per domain, never one file per entry.
|
|
517
|
+
Every game is one shape, enforced by `check-game-shape` (part of `check-types`): the top of `src/` holds only the skeleton, and every game-specific module, UI component, and test lives under `src/game/`. In this repo the gate also requires a root `package.json` script `"games:<id>": "bun run --cwd Games/<id> dev"` for every `Games/*` directory — add it with the scaffold, or the very first `check-types` fails before the compiler even runs. Dense files — one `catalog.ts` per domain, never one file per entry.
|
|
497
518
|
|
|
498
519
|
```
|
|
499
520
|
src/
|
|
@@ -524,6 +545,8 @@ src/
|
|
|
524
545
|
|
|
525
546
|
**Smart defaults** — omit any of these and the call still resolves: `multiplayer` → `offline()`; `assets` → an empty asset catalog; `loop` hooks (`onInit`/`onNewPlayer`/`onTick`) → no-ops; `content` → `{}`; `GameUI` → an empty component; `camera` → third-person orbit; `feed` → 20-entry ring buffers per action; a `world` of kind `environment()` auto-renders as the backdrop with no `environment` component supplied — a non-`environment()` world (`flat()`, `voxel()`, …) still needs the game to hand it one.
|
|
526
547
|
|
|
548
|
+
**Opt-in `ctx.game.*` subsystems (`features`)** — core is genre-agnostic: the always-on base is `commands` / `events` / `store` / `feed` (plus `audio`), and genre subsystems are opt-in via `defineGame({ features: { roster, cards, turn, race, leaderboard, social, chat } })`. Omit one and `ctx.game.<name>` is `undefined` — a puzzle game isn't handed a card pile, race state, or party/chat it never asked for. Declare only what the game uses (`chat` implies `social`). (The content cluster — economy/quest/loot/trade — joins this manifest in a later slim-core phase.)
|
|
549
|
+
|
|
527
550
|
```ts
|
|
528
551
|
// game.config.ts — imports only, nothing inline
|
|
529
552
|
import { defineGame } from "@jgengine/shell/defineGame";
|
|
@@ -585,7 +608,7 @@ export const physics: PhysicsConfig = { gravity: -32 };
|
|
|
585
608
|
|
|
586
609
|
- `PhysicsConfig.gravity`/`jumpVelocity` tune the shell's built-in walk controller's fall/jump feel (defaults ~`-24`/`7.1`) — the only two levers on gravity and jump height; everything else about movement (speed, poses) stays catalog `movement` fields.
|
|
587
610
|
- Input bindings are string arrays (hold semantics) or `{ hold, toggle, repeatMs? }` for the same verb. `repeatMs` turns a held action into an auto-repeat fire (build-mode place-on-drag, rapid-fire without a separate `wasPressed`/interval combo in game code) — the shell refires the command every `repeatMs` while the binding stays down, on top of its normal press-edge fire.
|
|
588
|
-
- **Keybind → command convention.** The shell fires a command for any bound action that isn't reserved: pressing an action runs a command of the **same name** if one is defined, else a `ui.<action>` fallback (so `openBackpack` → `ui.openBackpack`). Just declare the binding and a matching command — no per-game `keydown` listener. Reserved actions the shell consumes natively and never routes to a command: `moveForward/moveBack/moveLeft/moveRight`, `turnLeft/turnRight`, `sprint`, `jump`, `tabTarget`, `clearTarget`, `useAbility`, `interact`, and any `hotbarSlotN`/`slotN`. `tabTarget`/`clearTarget` run `target.cycle`/`target.clear` (native `cycleTarget`/`setTarget` fallback).
|
|
611
|
+
- **Keybind → command convention.** The shell fires a command for any bound action that isn't reserved: pressing an action runs a command of the **same name** if one is defined, else a `ui.<action>` fallback (so `openBackpack` → `ui.openBackpack`). Just declare the binding and a matching command — no per-game `keydown` listener. Reserved actions the shell consumes natively and never routes to a command: `moveForward/moveBack/moveLeft/moveRight`, `turnLeft/turnRight`, `sprint`, `jump`, `tabTarget`, `clearTarget`, `useAbility`, `interact`, and any `hotbarSlotN`/`slotN`. `tabTarget`/`clearTarget` run `target.cycle`/`target.clear` (native `cycleTarget`/`setTarget` fallback). Because `hotbarSlotN` never reaches a command, a game that drives selection from game code binds its own `selectSlot1..N` actions, defines matching commands that write a store key, and reads it back through `hotbarSelection: () => ...` on `defineGame` (see `loot-shooter`).
|
|
589
612
|
- **`interact`** is special: pressing it resolves the active proximity prompt from the `prompts` field of `defineGame({...})` and runs that prompt's `invoke` command. A prompt with `invoke: null` is display-only and does nothing on the key.
|
|
590
613
|
- UI keybind badges derive from `keybinds` via `actionLabel(keybinds, "openBackpack")` — `bindingLabel` maps codes to short labels (`Digit1`→`1`, `KeyB`→`B`, `mouse0`→`LMB`, `Escape`→`Esc`). Never hardcode label strings; they drift the moment a binding changes.
|
|
591
614
|
- `server.mode` is a string your loop/commands interpret — the engine ships no gamemode presets.
|
|
@@ -670,7 +693,7 @@ Catalog-first, no per-game audio glue. The `audio` field of `defineGame({...})`
|
|
|
670
693
|
The shell ships a **rig library**; a game picks and tunes one through `camera` config, never by writing camera positions from `onTick`. Select with `camera.rig` (or the `perspective: "third" | "first"` shorthand) — or by config block alone (#207.8): supplying `camera.<rig>` selects that rig with no redundant `rig` field, checked in the table's order; an explicit `rig` wins when several blocks are present:
|
|
671
694
|
| `rig` | For | Key config (`camera.<rig>`) |
|
|
672
695
|
|-------|-----|------------------------------|
|
|
673
|
-
| `orbit` (default) | Third-person chase | `initialDistance`, `targetHeight`, `min/maxPolarAngle`, drag/zoom |
|
|
696
|
+
| `orbit` (default) | Third-person chase | Top-level fields on `camera` itself — `initialDistance`, `minDistance`/`maxDistance`, `targetHeight`, `min/maxPolarAngle`, drag/zoom. There is **no `camera.orbit` block**; orbit is the one rig tuned at the top level |
|
|
674
697
|
| `first` | FPS mouse-look | `firstPerson: { eyeHeight, sensitivity, maxPitch, reticle, viewmodel }` |
|
|
675
698
|
| `topDown` | ARPG iso / top-down (Diablo IV, Hades II) | `topDown: { height, pitch, yaw, followSmoothing, zoom }` — decoupled follow; `pitch` is camera elevation (PI/2 = straight down, near 0 = grazing and boom-distance blows up past `frustum.far`) |
|
|
676
699
|
| `rts` | Free-pan / edge-scroll (The Sims, Manor Lords) | `rts: { panSpeed, edgeScroll, rotateSpeed, bounds, start, pan }` — `pan: false` turns it into a static backdrop camera: no WASD/arrow pan, no edge-scroll, no Q/E rotate, no wheel zoom, still re-centers on `followEntityId` if one resolves |
|
|
@@ -729,7 +752,7 @@ ctx.time advance, now, calendar, snapshot; pause, play, toggle,
|
|
|
729
752
|
ctx.subscribe / ctx.version change signal — UI layers bind via useSyncExternalStore
|
|
730
753
|
```
|
|
731
754
|
|
|
732
|
-
`content.itemById(id)` supplies `{ use?, weapon?, trade? }
|
|
755
|
+
`content.itemById(id)` supplies `{ use?, weapon?, trade? }` — exact shapes: `use` is the handler **name string** (not an object), `weapon` is a flat `Record<string, number | Record<string, number>>` built from your catalog stats (`damage`, `projectile.{...}`, `explosion.{...}`), and every lookup returns **`null`** (not `undefined`) for an unknown id; `content.entityById(id)` supplies `{ stats?, receive?, onDeath?, movement?, role? }`; `content.objectById(id)` supplies `GameContextObjectEntry` `{ proximityPrompt?, breakable?, slotInventory? }`. Build all three from your catalogs in `content.ts`. Call-shape gotchas that cost typecheck loops: `ctx.item.use.use(input)` takes **one** argument on the ctx surface (the two-arg `use(state, input)` is the raw factory shape); `ctx.scene.entity.moveToward(id, target, { speed, dt, stopDistance? })` — the third argument is an options object, never a bare `dt`; `nav/pathFollow`'s `createPathFollow(config)` returns the initial `PathFollowState` directly and `advancePathFollow(config, state, dt)` returns the **next state** (with `position: [x,y,z]`, `heading`, `done`) — there is no wrapper object, and `Waypoint` is a `[x, y, z]` tuple. A placed object resolves its catalog entry via `ctx.scene.object.catalog(instanceId)`; `ctx.scene.object.at(x, y, z, tolerance?)` finds placed objects near a point (grid interaction, click resolution beyond the pointer service). `ctx.scene.entity.update(id, patch)` writes a shallow patch onto a spawned entity's mutable fields (e.g. `movement.walkSpeed`) without a full respawn — `scene/movementSpeed`'s `applyStatDrivenSpeed(deps, id, { baseSpeed, multiplierStat?, flatBonusStat? })` is the catalog-driven helper that recomputes and writes `movement.walkSpeed` from a stat-modifier pair each time a buff changes.
|
|
733
756
|
|
|
734
757
|
### Two tiers: `ctx` runtime vs pure factories
|
|
735
758
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jgengine/convex",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Convex adapters for JGengine: game transport, presence transport, and backend wiring over @jgengine/core.",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"type": "module",
|
|
@@ -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.10.0"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
36
|
"convex": "^1.31.2",
|