@jgengine/convex 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/dist/hostedServer.d.ts +3 -1
- package/dist/hostedServer.js +1 -0
- package/dist/server.d.ts +3 -14
- package/dist/server.js +87 -130
- package/package.json +2 -2
- package/skills/jgengine/SKILL.md +7 -4
- package/skills/jgengine/api.md +172 -79
- package/skills/jgengine/capabilities.md +104 -2
- package/skills/jgengine/recipes/minimal-game.md +88 -0
- package/skills/jgengine-multiplayer/SKILL.md +11 -0
- package/skills/jgengine-multiplayer/api.md +9 -0
- package/skills/jgengine-multiplayer/capabilities.md +8 -0
- package/skills/jgengine-multiplayer/reference.md +6 -4
- package/skills/jgengine/reference-cartridge.md +0 -72
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,32 @@ Agents building on the published SDK can also read this programmatically:
|
|
|
12
12
|
same data as typed values, so an updater can diff its installed version against
|
|
13
13
|
the latest and surface the migration steps.
|
|
14
14
|
|
|
15
|
+
## 0.12.0
|
|
16
|
+
|
|
17
|
+
### Migrate
|
|
18
|
+
|
|
19
|
+
- **Author games through `defineGame` from `@jgengine/shell`** — it is the single public authoring entry (and carries the define-game capability tag). Core's constructor was renamed `defineGame` → `defineGameDefinition` (`@jgengine/core/game/defineGame`); only hosts and tooling call it. If you imported `defineGame` from core, switch the import to `@jgengine/shell`. `GamePlayer`/`GamePlayerShell` are `@internal` now — `GameHost` is the documented mount, and it owns the editor summon (`?mode=editor`, F2+E, dev save endpoint), so delete any per-game editor bootstrap.
|
|
20
|
+
- **Declare your world with `world()`** (`@jgengine/core/world/place`): `world({ id, ground: { mode, size, surface? }, physics? })` with `flat` / `round` / `voxel` / `board` ground. New scaffolds emit a thin infinite flat place instead of the old 96 m meadow. `environment()` still works but is demoted to the legacy consumption path for editor-written scene data — **nothing required** for existing games; new worlds should use `world()` and author all dressing in the editor.
|
|
21
|
+
- Everything else is additive — stat pools, minimap marker sources, auction house, city/path generation, editor workspaces require no changes to existing games.
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- **World = place: `world()` API** (#1178) — a world is substrate + laws, not a coded diorama. `@jgengine/core/world/place` ships `world({ id, ground: { mode, size, surface?, generator? }, physics? })`; size is discriminated by mode (flat `{x,z}` with `Infinity` axes, round `{radius}`, voxel generator domain, board `{x,y}` cells — TS and runtime both reject mismatches). `ground.surface` carries matter/feel laws (metal vs slime vs felt) read by the same rule systems; per-place `physics` resolves over the game default; multiple worlds per game are first-class. The shell renders place substrates (`PlaceScene`) under the engine default sky; seeds derive from world id + save via `seedForPlace`.
|
|
26
|
+
- **`@jgengine/shell/gameKit`** — happy-path authoring surface: authoring, mount, stores, systems, authored-scene helpers, HUD primitives, seeded rng in one import.
|
|
27
|
+
- **Portable generic stat pools** — `@jgengine/core/stats/statPool`: bounded named pools (health, energy, heat, ammo…) with the entity-stats integration rebuilt on them; recipe in skill `jgengine-combat`.
|
|
28
|
+
- **Auction-house economy primitives** (#1086) — `@jgengine/core/economy/auctionBook` (timed bid auctions, escrowed bids, outbid refunds, optional buyout, anti-snipe close extension, seller settlement minus house cut) plus market price history.
|
|
29
|
+
- **Portable minimap marker sources** — core world seam feeding game minimaps; recipe in skill `jgengine-ui`.
|
|
30
|
+
- **Unified seed-driven path network + city generation v2** (#1103, #1106) — streets/blocks/buildings with grid-ness, curviness, branching and block-size dials; zone bands, weighted building-class mixes, massing pieces, balance presets, junction-centered clustering, road-derived block polygons and parcels. Shell renders realistic roads: real intersections, curbs, lane lines, tunnels, and roads riding bridge decks.
|
|
31
|
+
- **Physical fidelity** — mesh-accurate hitboxes (shots pass through holes in concave models, #1075), auto-fit entity/object colliders from measured model bounds (#1072), movement obstruction reading fitted boxes + compound-box openings (#1077).
|
|
32
|
+
- **Editor overhaul** (#1110) — materials / lighting / scripting / animation-timeline / multiplayer workspaces, command palette, minimap bake wired end-to-end (#1036), real offscreen GLB thumbnails in the content browser, hierarchy drag-drop + keyboard + context menus + per-object visibility/lock, multi-select inspector, console RPC, asset drag-place, ortho projection, pivot modes, play chrome.
|
|
33
|
+
- **Editor authoring pipeline** — author entity definitions and starter catalogs (#1012), author catalogs/schema fields in the Data tab (#1043), promote an authored scene folder into a runnable game (#1011), authored triggers promoted to a shared primitive with goal/win rules (#1013), asset import rewrites the game's `game/assets.ts` (#1042).
|
|
34
|
+
- **Skills** — research-backed `game-design` and `level-design` skills (#962); every-game-owns-custom-UI invariant codified (#1148).
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- `npx jgengine create` scaffolds a thin infinite flat `world()` place; the editor `blankWorld` no longer pre-bakes seeded grass. Dressing belongs in `editor.scene.json`.
|
|
39
|
+
- Environment studio visual-quality pass — turf tufts, depth-aware water, relief soil, weathered poles; grass fields no longer rebuild geometry per frame (#1090).
|
|
40
|
+
|
|
15
41
|
## 0.11.0
|
|
16
42
|
|
|
17
43
|
### Migrate
|
package/dist/hostedServer.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { GameDefinition } from "@jgengine/core/game/defineGame";
|
|
2
|
-
import type { GameContextContent } from "@jgengine/core/runtime/gameContext";
|
|
2
|
+
import type { GameContextContent, GameContextModels } from "@jgengine/core/runtime/gameContext";
|
|
3
3
|
import { type InputFrame } from "@jgengine/core/runtime/hostedGameRunner";
|
|
4
4
|
import { type HostedWorldSession, type HostedWorldStore } from "@jgengine/core/runtime/hostedWorldSession";
|
|
5
5
|
import type { ModelAssetRef } from "@jgengine/core/scene/assetCatalog";
|
|
@@ -8,6 +8,8 @@ import { type JgAuthMode } from "./server.js";
|
|
|
8
8
|
export interface HostedGameConfig {
|
|
9
9
|
definition: GameDefinition<ModelAssetRef, unknown>;
|
|
10
10
|
content: GameContextContent;
|
|
11
|
+
/** Render-model lookup for collider auto-fit — pass the same lookup the shell derives (`contextModels(playable)`) so the authoritative host resolves the hitboxes clients see. */
|
|
12
|
+
models?: GameContextModels;
|
|
11
13
|
}
|
|
12
14
|
/**
|
|
13
15
|
* Everything one stateless invocation reconstructs a hosted world from: the persisted record via `store`,
|
package/dist/hostedServer.js
CHANGED
|
@@ -19,6 +19,7 @@ export function invokeHostedWorld(invocation) {
|
|
|
19
19
|
content: game.content,
|
|
20
20
|
store: { load: () => loaded, save: () => { } },
|
|
21
21
|
...(now === undefined ? {} : { now }),
|
|
22
|
+
...(game.models === undefined ? {} : { models: game.models }),
|
|
22
23
|
});
|
|
23
24
|
for (const userId of members)
|
|
24
25
|
session.runner().resume(userId);
|
package/dist/server.d.ts
CHANGED
|
@@ -4,7 +4,9 @@ import type { ChatMessage } from "@jgengine/core/game/chat";
|
|
|
4
4
|
import type { ChatSendOutcome } from "@jgengine/core/multiplayer/chatContract";
|
|
5
5
|
import { type PoseSyncRules } from "@jgengine/core/multiplayer/presenceModel";
|
|
6
6
|
import type { GameRuntime } from "@jgengine/core/runtime/gameRuntime";
|
|
7
|
-
import type { LeaderboardIncrement
|
|
7
|
+
import type { LeaderboardIncrement } from "@jgengine/core/runtime/hostPersistence";
|
|
8
|
+
/** @internal Re-export shared host-policy pure helpers so existing convex imports keep working. */
|
|
9
|
+
export { canJoinPrivateServer, isListablePublicly, } from "@jgengine/core/runtime/hostPolicy";
|
|
8
10
|
/** @internal */
|
|
9
11
|
export declare function jgengineTables(): {
|
|
10
12
|
jgGameServers: import("convex/server").TableDefinition<import("convex/values").VObject<{
|
|
@@ -368,18 +370,6 @@ export declare const DEFAULT_CONVEX_POSE_RULES: PoseSyncRules;
|
|
|
368
370
|
export declare function resolveActor(ctx: {
|
|
369
371
|
auth: Auth;
|
|
370
372
|
}, claimedExternalId: string | undefined, mode: JgAuthMode): Promise<string | null>;
|
|
371
|
-
/** True when a server's `visibility` should surface in public listings/browse results.
|
|
372
|
-
* @internal
|
|
373
|
-
*/
|
|
374
|
-
export declare function isListablePublicly(visibility: SessionVisibility | undefined): boolean;
|
|
375
|
-
/** A non-member may enter a private server only by presenting its `joinCode`; members always pass.
|
|
376
|
-
* @internal
|
|
377
|
-
*/
|
|
378
|
-
export declare function canJoinPrivateServer(args: {
|
|
379
|
-
isMember: boolean;
|
|
380
|
-
joinCode: string | undefined;
|
|
381
|
-
suppliedCode: string | undefined;
|
|
382
|
-
}): boolean;
|
|
383
373
|
/** @internal */
|
|
384
374
|
export declare function applyLeaderboardIncrements(ctx: JGMutationCtx, gameId: string, entries: LeaderboardIncrement[]): Promise<void>;
|
|
385
375
|
export declare const JG_RUNTIME_TICK_MS = 1000;
|
|
@@ -576,4 +566,3 @@ export type JgCronSpec = {
|
|
|
576
566
|
};
|
|
577
567
|
/** @internal */
|
|
578
568
|
export declare function jgengineCronSpecs(): readonly JgCronSpec[];
|
|
579
|
-
export {};
|
package/dist/server.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { defineSchema, defineTable, internalMutationGeneric, mutationGeneric, queryGeneric, } from "convex/server";
|
|
2
2
|
import { ConvexError, v } from "convex/values";
|
|
3
3
|
import { createFeedWriteGate, validateFeedWrite, } from "@jgengine/core/multiplayer/feedWriteGate";
|
|
4
|
-
import {
|
|
5
|
-
import { decidePoseSync, spawnPresenceState, } from "@jgengine/core/multiplayer/presenceModel";
|
|
4
|
+
import { DEFAULT_POSE_SYNC_RULES, decidePoseSync, spawnPresenceState, } from "@jgengine/core/multiplayer/presenceModel";
|
|
6
5
|
import { createGameRuntime } from "@jgengine/core/runtime/gameRuntime";
|
|
7
6
|
import { buildHydratePlayers, planServerPersist, shouldAutoSave, trimFeedEntries } from "@jgengine/core/runtime/hostPersistence";
|
|
7
|
+
import { isAutoJoinCandidate, isListablePublicly, isPrivateJoinBlocked, isServerFull, isServerMember, statusAfterLeave, withJoinedMember, withoutMember, } from "@jgengine/core/runtime/hostPolicy";
|
|
8
8
|
import { clearDirtyFlags, createEmptyServerRow } from "@jgengine/core/runtime/snapshot";
|
|
9
9
|
import { applyCommandWithOcc, commitIfRevisionMatch } from "./occ.js";
|
|
10
|
+
/** @internal Re-export shared host-policy pure helpers so existing convex imports keep working. */
|
|
11
|
+
export { canJoinPrivateServer, isListablePublicly, } from "@jgengine/core/runtime/hostPolicy";
|
|
10
12
|
const saveConfigValidator = v.union(v.literal("none"), v.object({
|
|
11
13
|
auto: v.string(),
|
|
12
14
|
scope: v.union(v.literal("player"), v.literal("chunks"), v.literal("player+chunks")),
|
|
@@ -119,13 +121,7 @@ const schemaForTypes = defineSchema(jgengineTables());
|
|
|
119
121
|
const query = queryGeneric;
|
|
120
122
|
const mutation = mutationGeneric;
|
|
121
123
|
const internalMutation = internalMutationGeneric;
|
|
122
|
-
export const DEFAULT_CONVEX_POSE_RULES =
|
|
123
|
-
maxSpeed: 12,
|
|
124
|
-
maxVerticalOffset: 3,
|
|
125
|
-
minElapsedSec: 0.05,
|
|
126
|
-
maxElapsedSec: 0.5,
|
|
127
|
-
keepAliveRefreshMs: 10_000,
|
|
128
|
-
};
|
|
124
|
+
export const DEFAULT_CONVEX_POSE_RULES = DEFAULT_POSE_SYNC_RULES;
|
|
129
125
|
/** Resolve the acting user id under a {@link JgAuthMode}: the Convex identity when signed in (rejecting a mismatched claim), the claimed external id under `"anonymous"`, else `null`.
|
|
130
126
|
* @internal
|
|
131
127
|
*/
|
|
@@ -144,30 +140,38 @@ export async function resolveActor(ctx, claimedExternalId, mode) {
|
|
|
144
140
|
}
|
|
145
141
|
return null;
|
|
146
142
|
}
|
|
147
|
-
/** True when a server's `visibility` should surface in public listings/browse results.
|
|
148
|
-
* @internal
|
|
149
|
-
*/
|
|
150
|
-
export function isListablePublicly(visibility) {
|
|
151
|
-
return (visibility ?? "public") !== "private";
|
|
152
|
-
}
|
|
153
|
-
/** A non-member may enter a private server only by presenting its `joinCode`; members always pass.
|
|
154
|
-
* @internal
|
|
155
|
-
*/
|
|
156
|
-
export function canJoinPrivateServer(args) {
|
|
157
|
-
if (args.isMember)
|
|
158
|
-
return true;
|
|
159
|
-
if (args.joinCode === undefined || args.suppliedCode === undefined)
|
|
160
|
-
return false;
|
|
161
|
-
return normalizeJoinCode(args.suppliedCode) === normalizeJoinCode(args.joinCode);
|
|
162
|
-
}
|
|
163
143
|
async function requireServerMember(ctx, serverId, actorUserId) {
|
|
164
144
|
const server = await ctx.db.get("jgGameServers", serverId);
|
|
165
145
|
if (!server)
|
|
166
146
|
return null;
|
|
167
|
-
if (!server.memberUserIds
|
|
147
|
+
if (!isServerMember(server.memberUserIds, actorUserId))
|
|
168
148
|
return null;
|
|
169
149
|
return server;
|
|
170
150
|
}
|
|
151
|
+
/** Resolve the acting user for a mutation, throwing the standard error when unauthenticated.
|
|
152
|
+
* @internal
|
|
153
|
+
*/
|
|
154
|
+
async function requireActor(ctx, externalId, mode) {
|
|
155
|
+
const actorUserId = await resolveActor(ctx, externalId, mode);
|
|
156
|
+
if (!actorUserId)
|
|
157
|
+
throw new ConvexError("Not authenticated");
|
|
158
|
+
return actorUserId;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Resolve the actor and confirm server membership, invoking `fn` only when both hold and yielding
|
|
162
|
+
* `deniedResult` for every denial (unauthenticated or non-member). The uniform-denial path shared by
|
|
163
|
+
* the read handlers whose not-authenticated and not-member cases return the same value.
|
|
164
|
+
* @internal
|
|
165
|
+
*/
|
|
166
|
+
async function withMember(ctx, mode, args, deniedResult, fn) {
|
|
167
|
+
const actorUserId = await resolveActor(ctx, args.externalId, mode);
|
|
168
|
+
if (!actorUserId)
|
|
169
|
+
return deniedResult;
|
|
170
|
+
const server = await requireServerMember(ctx, args.serverId, actorUserId);
|
|
171
|
+
if (server === null)
|
|
172
|
+
return deniedResult;
|
|
173
|
+
return fn(server, actorUserId);
|
|
174
|
+
}
|
|
171
175
|
const builtinRuntimeCommands = {
|
|
172
176
|
"engine.ping": {
|
|
173
177
|
validate: (_snapshot) => null,
|
|
@@ -372,10 +376,7 @@ export function createGameServerFunctions(options) {
|
|
|
372
376
|
isNew: v.boolean(),
|
|
373
377
|
}),
|
|
374
378
|
handler: async (ctx, args) => {
|
|
375
|
-
const actorUserId = await
|
|
376
|
-
if (!actorUserId) {
|
|
377
|
-
throw new ConvexError("Not authenticated");
|
|
378
|
-
}
|
|
379
|
+
const actorUserId = await requireActor(ctx, args.externalId, mode);
|
|
379
380
|
const now = Date.now();
|
|
380
381
|
const runtime = resolveRuntime(registry, args.gameId);
|
|
381
382
|
const save = runtime.save;
|
|
@@ -397,13 +398,12 @@ export function createGameServerFunctions(options) {
|
|
|
397
398
|
joinable.push(...rows);
|
|
398
399
|
}
|
|
399
400
|
server =
|
|
400
|
-
joinable.find((row) => {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
}) ?? null;
|
|
401
|
+
joinable.find((row) => isAutoJoinCandidate({
|
|
402
|
+
memberUserIds: row.memberUserIds,
|
|
403
|
+
slotsPerServer: row.slotsPerServer,
|
|
404
|
+
visibility: row.visibility,
|
|
405
|
+
userId: actorUserId,
|
|
406
|
+
})) ?? null;
|
|
407
407
|
}
|
|
408
408
|
if (!server) {
|
|
409
409
|
const serverId = await ctx.db.insert("jgGameServers", {
|
|
@@ -427,16 +427,16 @@ export function createGameServerFunctions(options) {
|
|
|
427
427
|
if (!server)
|
|
428
428
|
throw new ConvexError("Failed to create server");
|
|
429
429
|
}
|
|
430
|
-
if (server.memberUserIds
|
|
431
|
-
!server.memberUserIds.includes(actorUserId)) {
|
|
430
|
+
if (isServerFull(server.memberUserIds, server.slotsPerServer, actorUserId)) {
|
|
432
431
|
throw new ConvexError("Server is full");
|
|
433
432
|
}
|
|
434
|
-
if ((
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
433
|
+
if (isPrivateJoinBlocked({
|
|
434
|
+
visibility: server.visibility,
|
|
435
|
+
memberUserIds: server.memberUserIds,
|
|
436
|
+
userId: actorUserId,
|
|
437
|
+
joinCode: server.joinCode,
|
|
438
|
+
suppliedCode: args.joinCode,
|
|
439
|
+
})) {
|
|
440
440
|
throw new ConvexError("Server is private");
|
|
441
441
|
}
|
|
442
442
|
const profile = await ctx.db
|
|
@@ -444,9 +444,7 @@ export function createGameServerFunctions(options) {
|
|
|
444
444
|
.withIndex("by_user_and_game", (q) => q.eq("userId", actorUserId).eq("gameId", args.gameId))
|
|
445
445
|
.unique();
|
|
446
446
|
const isNew = profile === null;
|
|
447
|
-
const memberUserIds = server.memberUserIds
|
|
448
|
-
? server.memberUserIds
|
|
449
|
-
: [...server.memberUserIds, actorUserId];
|
|
447
|
+
const memberUserIds = withJoinedMember(server.memberUserIds, actorUserId);
|
|
450
448
|
await ctx.db.patch(server._id, {
|
|
451
449
|
memberUserIds,
|
|
452
450
|
status: "running",
|
|
@@ -469,20 +467,15 @@ export function createGameServerFunctions(options) {
|
|
|
469
467
|
},
|
|
470
468
|
returns: v.null(),
|
|
471
469
|
handler: async (ctx, args) => {
|
|
472
|
-
const actorUserId = await
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
}
|
|
476
|
-
const server = await ctx.db.get("jgGameServers", args.serverId);
|
|
477
|
-
if (!server)
|
|
478
|
-
return null;
|
|
479
|
-
if (!server.memberUserIds.includes(actorUserId))
|
|
470
|
+
const actorUserId = await requireActor(ctx, args.externalId, mode);
|
|
471
|
+
const server = await requireServerMember(ctx, args.serverId, actorUserId);
|
|
472
|
+
if (server === null)
|
|
480
473
|
return null;
|
|
481
474
|
const now = Date.now();
|
|
482
475
|
const runtime = resolveRuntime(registry, server.gameId);
|
|
483
476
|
const snapshot = await loadServerSnapshot(ctx, server, runtime);
|
|
484
477
|
await persistServerSnapshot(ctx, server, snapshot, server.save);
|
|
485
|
-
const memberUserIds = server.memberUserIds
|
|
478
|
+
const memberUserIds = withoutMember(server.memberUserIds, actorUserId);
|
|
486
479
|
const sessionPlayers = { ...server.sessionPlayers };
|
|
487
480
|
delete sessionPlayers[actorUserId];
|
|
488
481
|
await ctx.db.patch(server._id, {
|
|
@@ -490,7 +483,7 @@ export function createGameServerFunctions(options) {
|
|
|
490
483
|
sessionPlayers,
|
|
491
484
|
updatedAt: now,
|
|
492
485
|
dirtyAt: now,
|
|
493
|
-
status: memberUserIds.length
|
|
486
|
+
status: statusAfterLeave(memberUserIds.length, server.status),
|
|
494
487
|
});
|
|
495
488
|
const pose = await ctx.db
|
|
496
489
|
.query("jgPoses")
|
|
@@ -510,15 +503,12 @@ export function createGameServerFunctions(options) {
|
|
|
510
503
|
},
|
|
511
504
|
returns: v.union(v.object({ ok: v.literal(true) }), v.object({ ok: v.literal(false), reason: v.string() })),
|
|
512
505
|
handler: async (ctx, args) => {
|
|
513
|
-
const actorUserId = await
|
|
514
|
-
if (!actorUserId) {
|
|
515
|
-
throw new ConvexError("Not authenticated");
|
|
516
|
-
}
|
|
506
|
+
const actorUserId = await requireActor(ctx, args.externalId, mode);
|
|
517
507
|
const server = await ctx.db.get("jgGameServers", args.serverId);
|
|
518
508
|
if (!server) {
|
|
519
509
|
return { ok: false, reason: "Server not found" };
|
|
520
510
|
}
|
|
521
|
-
if (!server.memberUserIds
|
|
511
|
+
if (!isServerMember(server.memberUserIds, actorUserId)) {
|
|
522
512
|
return { ok: false, reason: "Not a member of this server" };
|
|
523
513
|
}
|
|
524
514
|
const loadedRevision = server.revision;
|
|
@@ -555,14 +545,9 @@ export function createGameServerFunctions(options) {
|
|
|
555
545
|
},
|
|
556
546
|
returns: v.boolean(),
|
|
557
547
|
handler: async (ctx, args) => {
|
|
558
|
-
const actorUserId = await
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
}
|
|
562
|
-
const server = await ctx.db.get("jgGameServers", args.serverId);
|
|
563
|
-
if (!server)
|
|
564
|
-
return false;
|
|
565
|
-
if (!server.memberUserIds.includes(actorUserId))
|
|
548
|
+
const actorUserId = await requireActor(ctx, args.externalId, mode);
|
|
549
|
+
const server = await requireServerMember(ctx, args.serverId, actorUserId);
|
|
550
|
+
if (server === null)
|
|
566
551
|
return false;
|
|
567
552
|
const runtime = resolveRuntime(registry, server.gameId);
|
|
568
553
|
const snapshot = await loadServerSnapshot(ctx, server, runtime);
|
|
@@ -588,15 +573,7 @@ export function createGameServerFunctions(options) {
|
|
|
588
573
|
sessionPlayers: v.any(),
|
|
589
574
|
updatedAt: v.number(),
|
|
590
575
|
}), v.null()),
|
|
591
|
-
handler:
|
|
592
|
-
const actorUserId = await resolveActor(ctx, args.externalId, mode);
|
|
593
|
-
if (!actorUserId)
|
|
594
|
-
return null;
|
|
595
|
-
const server = await ctx.db.get("jgGameServers", args.serverId);
|
|
596
|
-
if (!server)
|
|
597
|
-
return null;
|
|
598
|
-
if (!server.memberUserIds.includes(actorUserId))
|
|
599
|
-
return null;
|
|
576
|
+
handler: (ctx, args) => withMember(ctx, mode, args, null, (server, actorUserId) => {
|
|
600
577
|
const sessionPlayers = server.sessionPlayers;
|
|
601
578
|
const ownSession = sessionPlayers[actorUserId];
|
|
602
579
|
return {
|
|
@@ -612,7 +589,7 @@ export function createGameServerFunctions(options) {
|
|
|
612
589
|
sessionPlayers: ownSession === undefined ? {} : { [actorUserId]: ownSession },
|
|
613
590
|
updatedAt: server.updatedAt,
|
|
614
591
|
};
|
|
615
|
-
},
|
|
592
|
+
}),
|
|
616
593
|
});
|
|
617
594
|
const getPlayerProfile = query({
|
|
618
595
|
args: {
|
|
@@ -653,17 +630,13 @@ export function createGameServerFunctions(options) {
|
|
|
653
630
|
},
|
|
654
631
|
returns: v.array(v.any()),
|
|
655
632
|
handler: async (ctx, args) => {
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
return [];
|
|
662
|
-
|
|
663
|
-
.query("jgFeedBuffers")
|
|
664
|
-
.withIndex("by_server_and_action", (q) => q.eq("serverId", args.serverId).eq("action", args.action))
|
|
665
|
-
.unique();
|
|
666
|
-
return row?.entries ?? [];
|
|
633
|
+
return withMember(ctx, mode, args, [], async () => {
|
|
634
|
+
const row = await ctx.db
|
|
635
|
+
.query("jgFeedBuffers")
|
|
636
|
+
.withIndex("by_server_and_action", (q) => q.eq("serverId", args.serverId).eq("action", args.action))
|
|
637
|
+
.unique();
|
|
638
|
+
return row?.entries ?? [];
|
|
639
|
+
});
|
|
667
640
|
},
|
|
668
641
|
});
|
|
669
642
|
const pushFeedEntry = mutation({
|
|
@@ -675,16 +648,13 @@ export function createGameServerFunctions(options) {
|
|
|
675
648
|
},
|
|
676
649
|
returns: v.null(),
|
|
677
650
|
handler: async (ctx, args) => {
|
|
678
|
-
const actorUserId = await
|
|
679
|
-
if (!actorUserId) {
|
|
680
|
-
throw new ConvexError("Not authenticated");
|
|
681
|
-
}
|
|
651
|
+
const actorUserId = await requireActor(ctx, args.externalId, mode);
|
|
682
652
|
const allowed = validateFeedWrite(feedWriteGate, args.action);
|
|
683
653
|
if (!allowed.ok) {
|
|
684
654
|
throw new ConvexError(allowed.reason);
|
|
685
655
|
}
|
|
686
656
|
const server = await ctx.db.get("jgGameServers", args.serverId);
|
|
687
|
-
if (!server || !server.memberUserIds
|
|
657
|
+
if (!server || !isServerMember(server.memberUserIds, actorUserId)) {
|
|
688
658
|
throw new ConvexError("Not a member of this server");
|
|
689
659
|
}
|
|
690
660
|
const now = Date.now();
|
|
@@ -887,25 +857,22 @@ export function createPresenceFunctions(options) {
|
|
|
887
857
|
lastSeenAt: v.number(),
|
|
888
858
|
})),
|
|
889
859
|
handler: async (ctx, args) => {
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
rotationPitch: row.rotationPitch,
|
|
907
|
-
lastSeenAt: row.updatedAt,
|
|
908
|
-
}));
|
|
860
|
+
return withMember(ctx, mode, args, [], async () => {
|
|
861
|
+
const threshold = Date.now() - freshWindowMs;
|
|
862
|
+
const rows = await ctx.db
|
|
863
|
+
.query("jgPoses")
|
|
864
|
+
.withIndex("by_server", (q) => q.eq("serverId", args.serverId))
|
|
865
|
+
.collect();
|
|
866
|
+
return rows
|
|
867
|
+
.filter((row) => row.updatedAt >= threshold)
|
|
868
|
+
.map((row) => ({
|
|
869
|
+
userId: row.userId,
|
|
870
|
+
position: { x: row.x, y: row.y, z: row.z },
|
|
871
|
+
rotationY: row.rotationY,
|
|
872
|
+
rotationPitch: row.rotationPitch,
|
|
873
|
+
lastSeenAt: row.updatedAt,
|
|
874
|
+
}));
|
|
875
|
+
});
|
|
909
876
|
},
|
|
910
877
|
});
|
|
911
878
|
const sync = mutation({
|
|
@@ -921,12 +888,7 @@ export function createPresenceFunctions(options) {
|
|
|
921
888
|
externalId: v.optional(v.string()),
|
|
922
889
|
},
|
|
923
890
|
returns: v.null(),
|
|
924
|
-
handler:
|
|
925
|
-
const actorUserId = await resolveActor(ctx, args.externalId, mode);
|
|
926
|
-
if (!actorUserId)
|
|
927
|
-
return null;
|
|
928
|
-
if ((await requireServerMember(ctx, args.serverId, actorUserId)) === null)
|
|
929
|
-
return null;
|
|
891
|
+
handler: (ctx, args) => withMember(ctx, mode, args, null, async (_server, actorUserId) => {
|
|
930
892
|
const now = Date.now();
|
|
931
893
|
const existing = await ctx.db
|
|
932
894
|
.query("jgPoses")
|
|
@@ -962,7 +924,7 @@ export function createPresenceFunctions(options) {
|
|
|
962
924
|
await ctx.db.insert("jgPoses", { serverId: args.serverId, userId: actorUserId, ...patch });
|
|
963
925
|
}
|
|
964
926
|
return null;
|
|
965
|
-
},
|
|
927
|
+
}),
|
|
966
928
|
});
|
|
967
929
|
const leave = mutation({
|
|
968
930
|
args: {
|
|
@@ -1004,12 +966,7 @@ export function createChatFunctions(options) {
|
|
|
1004
966
|
body: v.string(),
|
|
1005
967
|
at: v.number(),
|
|
1006
968
|
})),
|
|
1007
|
-
handler:
|
|
1008
|
-
const actorUserId = await resolveActor(ctx, args.externalId, mode);
|
|
1009
|
-
if (!actorUserId)
|
|
1010
|
-
return [];
|
|
1011
|
-
if ((await requireServerMember(ctx, args.serverId, actorUserId)) === null)
|
|
1012
|
-
return [];
|
|
969
|
+
handler: (ctx, args) => withMember(ctx, mode, args, [], async () => {
|
|
1013
970
|
const rows = await ctx.db
|
|
1014
971
|
.query("jgChatMessages")
|
|
1015
972
|
.withIndex("by_server_channel", (q) => q.eq("serverId", args.serverId).eq("channelId", args.channelId))
|
|
@@ -1025,7 +982,7 @@ export function createChatFunctions(options) {
|
|
|
1025
982
|
body: row.body,
|
|
1026
983
|
at: row.at,
|
|
1027
984
|
}));
|
|
1028
|
-
},
|
|
985
|
+
}),
|
|
1029
986
|
});
|
|
1030
987
|
const sendMessage = mutation({
|
|
1031
988
|
args: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jgengine/convex",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Convex adapters for JGengine: game transport, presence transport, and backend wiring over @jgengine/core.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
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.12.0"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
36
|
"convex": "^1.31.2",
|
package/skills/jgengine/SKILL.md
CHANGED
|
@@ -9,16 +9,16 @@ Read the repository [README](../../../README.md) for package truth, commands, la
|
|
|
9
9
|
|
|
10
10
|
## 1. Establish the target
|
|
11
11
|
|
|
12
|
-
Capture only decisions that change architecture:
|
|
12
|
+
Treat the pitch as a unique composition of needs, not a genre to fill in — never reach for a genre kit, preset, archetype, or class template ("default sports car", "default RPG", "default FPS") in the SDK or skills (see [AGENTS.md](../../../AGENTS.md)). Use `game-design` to turn the pitch into a testable player promise, verbs, loops, progression, failure, and completion scenario. Use `level-design` when play depends on authored spaces, routes, encounters, exploration, or spatial pacing. Capture only decisions that change architecture:
|
|
13
13
|
|
|
14
14
|
- player point of view and controls
|
|
15
15
|
- world shape and authored content
|
|
16
16
|
- core loop, interaction, combat, and progression
|
|
17
17
|
- solo, local, hosted, or persistent multiplayer
|
|
18
|
-
- HUD/menu states and art direction
|
|
18
|
+
- HUD/menu states and **custom** UI art direction (every game owns its presentation — no stock drop-in face)
|
|
19
19
|
- one observable completion scenario
|
|
20
20
|
|
|
21
|
-
For a new in-repo game, scaffold with `bun run new:game <id>
|
|
21
|
+
For a new in-repo game, scaffold with `bun run new:game "<id-or-title>"` (thin script alias for `jgengine create`, auto-detects the in-repo variant). Outside this monorepo, start with `npx jgengine create`; never copy `Games/*`.
|
|
22
22
|
|
|
23
23
|
## 2. Select domains
|
|
24
24
|
|
|
@@ -26,6 +26,8 @@ Load only rows the target needs. Do not preload every domain.
|
|
|
26
26
|
|
|
27
27
|
| Need | Skill | Owner |
|
|
28
28
|
| --- | --- | --- |
|
|
29
|
+
| player promise, pillars, verbs, loops, economy, progression, balance | `game-design` | experience and rule intent |
|
|
30
|
+
| metrics, topology, encounters, navigation, spatial pacing | `level-design` | playable space intent |
|
|
29
31
|
| scene placement, terrain painting, paths, asset authoring | `jgengine-editor` | editor document and authoring tools |
|
|
30
32
|
| runtime world, movement, input, interaction, AI, navigation, audio | `jgengine-world` | spatial simulation and world runtime |
|
|
31
33
|
| game state, systems, inventory, crafting, quests, economy, turns | `jgengine-gameplay` | non-combat rules and state |
|
|
@@ -34,7 +36,7 @@ Load only rows the target needs. Do not preload every domain.
|
|
|
34
36
|
| models, sprites, materials, audio files, source licensing | `jgengine-assets` | asset discovery and catalogs |
|
|
35
37
|
| authority, transport, replication, sessions, host persistence | `jgengine-multiplayer` | network topology and persistence adapters |
|
|
36
38
|
|
|
37
|
-
Scenes normally require both `jgengine-editor` for authoring and `jgengine-world` for runtime consumption. Combat and multiplayer are opt-in, not default intake.
|
|
39
|
+
World content routes editor-first: any request that adds, moves, restyles, or removes something visible in the world — however phrased ("design this world", "place the enemies", "make it look better") — starts in `jgengine-editor`; the other domains consume the authored document. Scenes normally require both `jgengine-editor` for authoring and `jgengine-world` for runtime consumption. Combat and multiplayer are opt-in, not default intake.
|
|
38
40
|
|
|
39
41
|
## 3. Discover before designing
|
|
40
42
|
|
|
@@ -43,6 +45,7 @@ Within each selected domain:
|
|
|
43
45
|
1. Search `capabilities.md` by intent; it maps needs to imports.
|
|
44
46
|
2. Open `api.md` only when exact signatures or export inventory are needed.
|
|
45
47
|
3. Open the linked reference only for a deeper recipe or trap.
|
|
48
|
+
4. For how several primitives wire into a running loop, read the domain's `recipes/` — connected, genre-free walkthroughs organized by composition seam; this skill's own [recipes/minimal-game.md](recipes/minimal-game.md) is the default whole-game walkthrough. Prefer a recipe over reading a game's source; the games under `Games/*` are not templates or references, and a hybrid game is just a different composition of the same primitives.
|
|
46
49
|
|
|
47
50
|
If no capability fits, identify the upstream package seam before writing game-local code. In this repository, implement a reusable primitive with its first adopter; if editor-owned content cannot be expressed, file the editor gap first.
|
|
48
51
|
|