@jgengine/ws 0.18.1 → 0.21.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 +504 -7
- package/dist/createWsBackend.js +23 -3
- package/dist/host.d.ts +2 -0
- package/dist/host.js +5 -11
- package/dist/hostRouter.d.ts +2 -0
- package/dist/hostRouter.js +179 -33
- package/dist/peer.js +45 -12
- package/dist/protocol.d.ts +5 -0
- package/dist/protocol.js +8 -2
- package/dist/worldHost.d.ts +13 -2
- package/dist/worldHost.js +132 -47
- package/package.json +2 -2
- package/skills/ce-handoff/SKILL.md +2 -2
- package/skills/jgengine/SKILL.md +28 -6
- package/skills/jgengine/api.md +139 -99
- package/skills/jgengine/capabilities.md +28 -0
- package/skills/jgengine/recipes/minimal-game.md +10 -2
- package/skills/jgengine-multiplayer/SKILL.md +3 -3
- package/skills/jgengine-multiplayer/api.md +91 -83
- package/skills/jgengine-multiplayer/reference.md +27 -3
- package/skills/jgengine-multiplayer/references/peer-worlds.md +62 -0
package/dist/protocol.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export const WS_PROTOCOL_VERSION = 1;
|
|
2
2
|
/** Max length of a `runCommand` command name, in UTF-16 code units. */
|
|
3
3
|
export const MAX_COMMAND_LENGTH = 4_096;
|
|
4
|
+
/** Max length of a join/leave `sessionId`, in UTF-16 code units. */
|
|
5
|
+
export const MAX_SESSION_ID_LENGTH = 128;
|
|
4
6
|
/** Max length of a `pushFeed` action name, in UTF-16 code units. */
|
|
5
7
|
export const MAX_FEED_ACTION_LENGTH = 256;
|
|
6
8
|
/** Max serialized size of a `pushFeed` entry payload, in bytes. */
|
|
@@ -13,6 +15,9 @@ export const MAX_APPEARANCE_VALUE_LENGTH = 256;
|
|
|
13
15
|
export function encodeWsMessage(message) {
|
|
14
16
|
return JSON.stringify(message);
|
|
15
17
|
}
|
|
18
|
+
function isOptionalSessionId(value) {
|
|
19
|
+
return value === undefined || (typeof value === "string" && value.length > 0 && value.length <= MAX_SESSION_ID_LENGTH);
|
|
20
|
+
}
|
|
16
21
|
function isRecord(value) {
|
|
17
22
|
return typeof value === "object" && value !== null;
|
|
18
23
|
}
|
|
@@ -126,7 +131,8 @@ export function decodeWsClientMessage(raw) {
|
|
|
126
131
|
typeof message.gameId === "string" &&
|
|
127
132
|
(message.serverId === undefined || typeof message.serverId === "string") &&
|
|
128
133
|
(message.code === undefined || typeof message.code === "string") &&
|
|
129
|
-
(message.role === undefined || message.role === "player" || message.role === "spectator")
|
|
134
|
+
(message.role === undefined || message.role === "player" || message.role === "spectator") &&
|
|
135
|
+
isOptionalSessionId(message.sessionId)
|
|
130
136
|
? message
|
|
131
137
|
: null;
|
|
132
138
|
case "joinByCode":
|
|
@@ -141,7 +147,7 @@ export function decodeWsClientMessage(raw) {
|
|
|
141
147
|
? message
|
|
142
148
|
: null;
|
|
143
149
|
case "leave":
|
|
144
|
-
return typeof message.id === "number" && typeof message.serverId === "string"
|
|
150
|
+
return typeof message.id === "number" && typeof message.serverId === "string" && isOptionalSessionId(message.sessionId)
|
|
145
151
|
? message
|
|
146
152
|
: null;
|
|
147
153
|
case "runCommand":
|
package/dist/worldHost.d.ts
CHANGED
|
@@ -4,18 +4,29 @@ import type { GameHost } from "./host.js";
|
|
|
4
4
|
export interface WorldGameHostOptions {
|
|
5
5
|
/**
|
|
6
6
|
* Resolve (or lazily build) the authoritative {@link HostedWorldSession} for a server — called once per new
|
|
7
|
-
* `serverId
|
|
7
|
+
* loaded `serverId`, and again after successful unload. Create a fresh session on reload. Return `null` for an
|
|
8
|
+
* unknown game. Bind the game's definition + content here.
|
|
8
9
|
*/
|
|
9
10
|
session(args: {
|
|
10
11
|
gameId: string;
|
|
11
12
|
serverId: string;
|
|
12
13
|
}): HostedWorldSession | Promise<HostedWorldSession | null> | null;
|
|
13
14
|
now?: () => number;
|
|
15
|
+
/** Maximum active players per world; reconnecting members and spectators do not consume a new slot. */
|
|
16
|
+
slotsPerServer?: number;
|
|
14
17
|
}
|
|
15
|
-
/** A {@link GameHost}
|
|
18
|
+
/** A {@link GameHost} backed by `HostedWorldSession`s; `tick` advances worlds and `unload` explicitly saves and releases idle world references. */
|
|
16
19
|
export interface WorldGameHost extends GameHost {
|
|
17
20
|
/** Advance every live world by `dtSeconds` and emit a `server` change for each whose revision moved. */
|
|
18
21
|
tick(dtSeconds: number): void;
|
|
22
|
+
/**
|
|
23
|
+
* Save and release an unoccupied world's host references. Returns `occupied` for admitted players/spectators or
|
|
24
|
+
* resident members, and `missing` for an unloaded world. Save failure rejects and retains the world for retry.
|
|
25
|
+
* Later joins resolve a fresh session; external resources remain caller-owned. Closed hosts reject new unloads.
|
|
26
|
+
*/
|
|
27
|
+
unload(serverId: string): Promise<"unloaded" | "missing" | "occupied">;
|
|
28
|
+
/** Permanently close admission/ticks, drain accepted world operations, and save. Repeated calls share completion. */
|
|
29
|
+
stop(): Promise<void>;
|
|
19
30
|
}
|
|
20
31
|
/**
|
|
21
32
|
* The GameContext-loop counterpart of the reducer `createGameHost`: a structural {@link GameHost} that serves each
|
package/dist/worldHost.js
CHANGED
|
@@ -6,36 +6,71 @@ import { INPUT_COMMAND } from "@jgengine/core/runtime/hostedGameRunner";
|
|
|
6
6
|
* the tick cadence (call {@link WorldGameHost.tick} on an interval); commands and joins broadcast immediately.
|
|
7
7
|
*/
|
|
8
8
|
export function createWorldGameHost(options) {
|
|
9
|
+
if (options.slotsPerServer !== undefined && (!Number.isSafeInteger(options.slotsPerServer) || options.slotsPerServer < 1)) {
|
|
10
|
+
throw new Error("slotsPerServer must be a positive safe integer");
|
|
11
|
+
}
|
|
9
12
|
const live = new Map();
|
|
13
|
+
const loading = new Map();
|
|
14
|
+
const queues = new Map();
|
|
10
15
|
const roles = new Map();
|
|
16
|
+
const announcedRevisions = new Map();
|
|
11
17
|
const listeners = new Set();
|
|
12
18
|
const now = options.now ?? (() => Date.now());
|
|
19
|
+
let stopped = false;
|
|
20
|
+
let stopping = null;
|
|
13
21
|
function emit(event) {
|
|
22
|
+
if (event.type === "server") {
|
|
23
|
+
const entry = live.get(event.serverId);
|
|
24
|
+
if (entry !== undefined)
|
|
25
|
+
announcedRevisions.set(event.serverId, entry.session.revision());
|
|
26
|
+
}
|
|
14
27
|
for (const listener of listeners)
|
|
15
28
|
listener(event);
|
|
16
29
|
}
|
|
30
|
+
function enqueue(serverId, operation) {
|
|
31
|
+
if (stopped)
|
|
32
|
+
return Promise.reject(new Error("World host is closed"));
|
|
33
|
+
const previous = queues.get(serverId) ?? Promise.resolve();
|
|
34
|
+
const run = previous.catch(() => { }).then(operation);
|
|
35
|
+
queues.set(serverId, run);
|
|
36
|
+
void run.catch(() => { }).finally(() => {
|
|
37
|
+
if (queues.get(serverId) === run)
|
|
38
|
+
queues.delete(serverId);
|
|
39
|
+
});
|
|
40
|
+
return run;
|
|
41
|
+
}
|
|
17
42
|
function ensure(gameId, serverId) {
|
|
18
43
|
const existing = live.get(serverId);
|
|
19
44
|
if (existing !== undefined)
|
|
20
|
-
return existing;
|
|
45
|
+
return existing.gameId === gameId ? existing : null;
|
|
46
|
+
const pending = loading.get(serverId);
|
|
47
|
+
if (pending !== undefined)
|
|
48
|
+
return pending.then((entry) => entry?.gameId === gameId ? entry : null);
|
|
21
49
|
const resolved = options.session({ gameId, serverId });
|
|
22
50
|
if (resolved instanceof Promise) {
|
|
23
|
-
|
|
51
|
+
const pending = resolved.then((session) => {
|
|
24
52
|
if (session === null)
|
|
25
53
|
return null;
|
|
26
|
-
const entry = { gameId, session };
|
|
54
|
+
const entry = { gameId, session, residentMembers: new Set(session.members()) };
|
|
27
55
|
live.set(serverId, entry);
|
|
28
56
|
return entry;
|
|
29
57
|
});
|
|
58
|
+
loading.set(serverId, pending);
|
|
59
|
+
void pending.catch(() => { }).finally(() => loading.delete(serverId));
|
|
60
|
+
return pending;
|
|
30
61
|
}
|
|
31
62
|
if (resolved === null)
|
|
32
63
|
return null;
|
|
33
|
-
const entry = { gameId, session: resolved };
|
|
64
|
+
const entry = { gameId, session: resolved, residentMembers: new Set(resolved.members()) };
|
|
34
65
|
live.set(serverId, entry);
|
|
35
66
|
return entry;
|
|
36
67
|
}
|
|
37
68
|
function tickAll(dtSeconds) {
|
|
69
|
+
if (stopped)
|
|
70
|
+
return;
|
|
38
71
|
for (const [serverId, entry] of live) {
|
|
72
|
+
if (queues.has(serverId))
|
|
73
|
+
continue;
|
|
39
74
|
const before = entry.session.revision();
|
|
40
75
|
entry.session.tick(dtSeconds);
|
|
41
76
|
if (entry.session.revision() !== before)
|
|
@@ -45,56 +80,74 @@ export function createWorldGameHost(options) {
|
|
|
45
80
|
return {
|
|
46
81
|
async joinServer({ userId, gameId, serverId, role }) {
|
|
47
82
|
const id = serverId ?? gameId;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
83
|
+
return enqueue(id, async () => {
|
|
84
|
+
const pending = ensure(gameId, id);
|
|
85
|
+
const entry = pending instanceof Promise ? await pending : pending;
|
|
86
|
+
if (entry === null)
|
|
87
|
+
throw new Error(`no hosted world for game "${gameId}"`);
|
|
88
|
+
const members = entry.session.members();
|
|
89
|
+
const admittedRoles = roles.get(id);
|
|
90
|
+
const occupiesSlot = (memberId) => admittedRoles?.get(memberId) === "player"
|
|
91
|
+
|| (admittedRoles?.get(memberId) === undefined && entry.residentMembers.has(memberId));
|
|
92
|
+
const usedSlots = members.filter(occupiesSlot).length;
|
|
93
|
+
if (role !== "spectator" && !(members.includes(userId) && occupiesSlot(userId)) && options.slotsPerServer !== undefined && usedSlots >= options.slotsPerServer) {
|
|
94
|
+
throw new Error("Server is full");
|
|
95
|
+
}
|
|
96
|
+
const isNew = !entry.session.hasPlayer(userId);
|
|
97
|
+
let serverRoles = roles.get(id);
|
|
98
|
+
if (serverRoles === undefined) {
|
|
99
|
+
serverRoles = new Map();
|
|
100
|
+
roles.set(id, serverRoles);
|
|
101
|
+
}
|
|
102
|
+
if (role !== "spectator")
|
|
103
|
+
entry.session.join(userId, isNew);
|
|
104
|
+
await entry.session.save();
|
|
105
|
+
serverRoles.set(userId, role ?? "player");
|
|
106
|
+
emit({ type: "server", serverId: id });
|
|
107
|
+
emit({ type: "player", serverId: id, userId });
|
|
108
|
+
return { serverId: id, isNew };
|
|
109
|
+
});
|
|
64
110
|
},
|
|
65
111
|
async leaveServer({ userId, serverId }) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
entry.session.
|
|
71
|
-
|
|
72
|
-
|
|
112
|
+
return enqueue(serverId, async () => {
|
|
113
|
+
const entry = live.get(serverId);
|
|
114
|
+
if (entry === undefined)
|
|
115
|
+
return;
|
|
116
|
+
if (entry.session.members().includes(userId))
|
|
117
|
+
entry.session.leave(userId);
|
|
118
|
+
roles.get(serverId)?.delete(userId);
|
|
119
|
+
await entry.session.save();
|
|
120
|
+
emit({ type: "server", serverId });
|
|
121
|
+
});
|
|
73
122
|
},
|
|
74
123
|
async runCommand({ userId, serverId, command, input }) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
entry.session.
|
|
124
|
+
return enqueue(serverId, async () => {
|
|
125
|
+
const entry = live.get(serverId);
|
|
126
|
+
if (entry === undefined)
|
|
127
|
+
return { ok: false, reason: "no-server" };
|
|
128
|
+
if (!entry.session.members().includes(userId) || roles.get(serverId)?.get(userId) !== "player")
|
|
129
|
+
return { ok: false, reason: "not-a-player" };
|
|
130
|
+
if (command === INPUT_COMMAND) {
|
|
131
|
+
entry.session.input(userId, input);
|
|
132
|
+
return { ok: true };
|
|
133
|
+
}
|
|
134
|
+
const result = entry.session.command(userId, command, input);
|
|
135
|
+
await entry.session.save();
|
|
136
|
+
if (result.status === "rejected")
|
|
137
|
+
return { ok: false, reason: result.reason };
|
|
138
|
+
if (result.status === "unknown-command")
|
|
139
|
+
return { ok: false, reason: "unknown-command" };
|
|
140
|
+
if (entry.session.revision() !== announcedRevisions.get(serverId))
|
|
141
|
+
emit({ type: "server", serverId });
|
|
80
142
|
return { ok: true };
|
|
81
|
-
}
|
|
82
|
-
const before = entry.session.revision();
|
|
83
|
-
const result = entry.session.command(userId, command, input);
|
|
84
|
-
if (result.status === "rejected")
|
|
85
|
-
return { ok: false, reason: result.reason };
|
|
86
|
-
if (result.status === "unknown-command")
|
|
87
|
-
return { ok: false, reason: "unknown-command" };
|
|
88
|
-
if (entry.session.revision() !== before)
|
|
89
|
-
emit({ type: "server", serverId });
|
|
90
|
-
return { ok: true };
|
|
143
|
+
});
|
|
91
144
|
},
|
|
92
145
|
async isMember({ userId, serverId }) {
|
|
93
|
-
return
|
|
146
|
+
return !stopped && roles.get(serverId)?.has(userId) === true;
|
|
94
147
|
},
|
|
95
148
|
async getServerView({ userId, serverId, role }) {
|
|
96
149
|
const entry = live.get(serverId);
|
|
97
|
-
if (entry === undefined)
|
|
150
|
+
if (stopped || entry === undefined || !roles.get(serverId)?.has(userId))
|
|
98
151
|
return null;
|
|
99
152
|
return {
|
|
100
153
|
serverId,
|
|
@@ -107,7 +160,7 @@ export function createWorldGameHost(options) {
|
|
|
107
160
|
},
|
|
108
161
|
async pullWorld({ userId, serverId, sinceRevision, role }) {
|
|
109
162
|
const entry = live.get(serverId);
|
|
110
|
-
if (entry === undefined ||
|
|
163
|
+
if (stopped || entry === undefined || !roles.get(serverId)?.has(userId))
|
|
111
164
|
return null;
|
|
112
165
|
if (entry.session.projectsViewers()) {
|
|
113
166
|
return {
|
|
@@ -138,16 +191,48 @@ export function createWorldGameHost(options) {
|
|
|
138
191
|
return [];
|
|
139
192
|
},
|
|
140
193
|
tick: tickAll,
|
|
194
|
+
unload(serverId) {
|
|
195
|
+
return enqueue(serverId, async () => {
|
|
196
|
+
const entry = live.get(serverId);
|
|
197
|
+
if (entry === undefined)
|
|
198
|
+
return "missing";
|
|
199
|
+
if ((roles.get(serverId)?.size ?? 0) > 0 || entry.session.members().some(userId => entry.residentMembers.has(userId)))
|
|
200
|
+
return "occupied";
|
|
201
|
+
await entry.session.save();
|
|
202
|
+
live.delete(serverId);
|
|
203
|
+
roles.delete(serverId);
|
|
204
|
+
announcedRevisions.delete(serverId);
|
|
205
|
+
return "unloaded";
|
|
206
|
+
});
|
|
207
|
+
},
|
|
141
208
|
async tickOnce() {
|
|
142
209
|
tickAll(0);
|
|
143
210
|
return { ticked: live.size, saved: 0 };
|
|
144
211
|
},
|
|
145
212
|
async flushAll() {
|
|
146
|
-
|
|
213
|
+
await Promise.allSettled([...queues.values()]);
|
|
214
|
+
await Promise.all([...live.values()].map((entry) => entry.session.save()));
|
|
215
|
+
return live.size;
|
|
147
216
|
},
|
|
148
217
|
start() { },
|
|
149
|
-
|
|
218
|
+
stop() {
|
|
219
|
+
if (stopping !== null)
|
|
220
|
+
return stopping;
|
|
221
|
+
stopped = true;
|
|
222
|
+
stopping = (async () => {
|
|
223
|
+
await Promise.allSettled([...queues.values()]);
|
|
224
|
+
try {
|
|
225
|
+
await Promise.all([...live.values()].map(entry => entry.session.save()));
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
listeners.clear();
|
|
229
|
+
}
|
|
230
|
+
})();
|
|
231
|
+
return stopping;
|
|
232
|
+
},
|
|
150
233
|
subscribe(listener) {
|
|
234
|
+
if (stopped)
|
|
235
|
+
return () => { };
|
|
151
236
|
listeners.add(listener);
|
|
152
237
|
return () => listeners.delete(listener);
|
|
153
238
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jgengine/ws",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "Browser-safe WebSocket client backend for JGengine: protocol codec, createWsBackend, and HTTP read helpers.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"test": "bun test src"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@jgengine/core": "^0.
|
|
34
|
+
"@jgengine/core": "^0.21.0"
|
|
35
35
|
},
|
|
36
36
|
"publishConfig": {
|
|
37
37
|
"access": "public"
|
|
@@ -25,7 +25,7 @@ Create one immutable handoff at the destination the user requested, or use the m
|
|
|
25
25
|
### Build the handoff
|
|
26
26
|
|
|
27
27
|
1. Distill the current objective and the user's latest intent. If a focus was supplied, make it the `resume_focus`.
|
|
28
|
-
2. Inspect only the workspace state needed to explain what exists now. Use JGengine project instructions already in context (`AGENTS.md
|
|
28
|
+
2. Inspect only the workspace state needed to explain what exists now. Use JGengine project instructions already in context (`AGENTS.md`, `workflow`, domain skills).
|
|
29
29
|
3. Point to plans, issues, commits, diffs, documentation, and relevant files instead of reproducing their contents. Prefer repository-relative paths (`docs/plans/…`, `packages/…`, `Games/…`).
|
|
30
30
|
4. Redact secrets, credentials, tokens, and unrelated personal information. Preserve operational paths only when the next agent needs them.
|
|
31
31
|
5. Write or publish with existing tools. If the user named another path, folder, format, or publication destination, honor it. Do not also create a managed-store copy unless asked.
|
|
@@ -143,7 +143,7 @@ When sufficient, return a concise orientation: objective, progress, decisions, c
|
|
|
143
143
|
|
|
144
144
|
## JGengine notes
|
|
145
145
|
|
|
146
|
-
- Multi-session engine work
|
|
146
|
+
- Multi-session engine work spans GitHub issues and PRs — point at those; do not paste full plan bodies.
|
|
147
147
|
- Prefer `bun run agent:bootstrap --check` status, branch names, and PR links as state anchors.
|
|
148
148
|
- Local parallelism uses `.claude/worktrees/`; never invent `C:\tmp\…` Codex-shared trees.
|
|
149
149
|
- Durable process changes belong in skills, not only the handoff.
|
package/skills/jgengine/SKILL.md
CHANGED
|
@@ -5,7 +5,25 @@ description: Route game work to the smallest useful JGengine guidance set.
|
|
|
5
5
|
|
|
6
6
|
# JGengine intake and router
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
This skill decides what to load; it is not an engine manual. Package map, commands, layering, and license live in the JGengine README (repo-root [`README.md`](../../../README.md) in the monorepo, [jgengine.com](https://jgengine.com) from a created project); open it when you need one of those facts.
|
|
9
|
+
|
|
10
|
+
A created project installs only `jgengine`, `jgengine-editor`, `jgengine-verify`, `game-design`, and `jgengine-ui`. Run `npx jgengine skills -p --all` before loading any other domain named below. Those projects do not ship `api.md`; read exact signatures from the `.d.ts` files under `node_modules/@jgengine/<pkg>/dist/`.
|
|
11
|
+
|
|
12
|
+
Created projects pin their scaffolding CLI as a local devDependency, separately from SDK versions. Run `npx jgengine doctor` after SDK upgrades to check CLI compatibility. Skill installation preserves newer or differing unversioned copies; update the project CLI first, and use `skills -p --force` only when replacement is intended. Each packaged skill install records its CLI and SDK versions in `.jgengine-version.json`.
|
|
13
|
+
|
|
14
|
+
## Framework-first decisions
|
|
15
|
+
|
|
16
|
+
For every requested interaction, feature, fix or visual change, follow the boundary in
|
|
17
|
+
[AGENTS.md](../../../AGENTS.md): discover the installed
|
|
18
|
+
capability, adopt it, or improve its shared owner before adding reusable game-local code.
|
|
19
|
+
Games keep unique rules, content, assets and presentation. Custom markup is not a reason
|
|
20
|
+
to copy focus handling, input tracking, storage or model lifecycle.
|
|
21
|
+
|
|
22
|
+
Check installed versions before recreating an upstream fix. A published upgrade may
|
|
23
|
+
replace a workaround; a beta merge does not update consumers. In the games workspace,
|
|
24
|
+
the root catalog owns SDK versions. After adoption, remove superseded local machinery
|
|
25
|
+
and verify the actual game. Outside these repositories, report missing SDK contracts
|
|
26
|
+
upstream rather than treating a local engine implementation as the finished solution.
|
|
9
27
|
|
|
10
28
|
## 1. Establish the target
|
|
11
29
|
|
|
@@ -20,10 +38,14 @@ Treat the pitch as a unique composition of needs, not a genre to fill in — nev
|
|
|
20
38
|
- HUD/menu states and **custom** UI art direction (compose shipped building blocks + HudTheme; no unskinned stock face)
|
|
21
39
|
- one observable completion scenario
|
|
22
40
|
|
|
23
|
-
For a new in-repo game, scaffold with `bun run new:game "<id-or-title>"` (thin script alias for `jgengine create`, auto-detects the in-repo variant). Outside this monorepo, start with `npx jgengine create`; never copy `Games/*`.
|
|
41
|
+
Pick the dimension first: `create` defaults to a 3D world (terrain, sky, player model, editor scene); a grid, card, tile, or other board game with no 3D scene takes `create --2d` (a `board` world drawn by GameUI over `@jgengine/core/puzzle/cellGrid`). For a new in-repo game, scaffold with `bun run new:game "<id-or-title>"` (thin script alias for `jgengine create`, auto-detects the in-repo variant). Outside this monorepo, start with `npx jgengine create`; never copy `Games/*`.
|
|
24
42
|
|
|
25
43
|
For an existing game asked to pick up a new engine release ("jgengine published a new version, grab it"), run `npx jgengine upgrade` in the project first: it diffs installed `@jgengine/*` versions against the latest release and prints every Migrate step and Adopt-worthy addition in between (`--json` for structured output). Bump, migrate oldest-first, then work the Adopt lists instead of reconstructing the changelog by hand.
|
|
26
44
|
|
|
45
|
+
`doctor` and `upgrade` resolve Bun `catalog:` and `catalog:<name>` declarations from the nearest workspace's `workspaces.catalog` or `workspaces.catalogs`, and follow hoisted SDK installs. Missing or malformed catalog entries fail diagnostics. `upgrade` is read-only by default. For catalog-owned versions, run `upgrade --plan --to <published-version>` at the workspace root or in a game, review the whole root catalog plan and migrations, then run `upgrade --apply --to <published-version>`. Apply preserves per-game `catalog:` declarations, unrelated text, CLI/github versions, and a held editor override; it verifies published SDK targets and refuses files changed while planning. Apply changes declarations only: run `bun install` at the root, then `jgengine doctor --workspace --json` once there to check installed SDK identities across declared workspace owners. Rebuild, migrate, and verify game behavior afterward. `--json` includes exact before/after text; an apply prints its plan before writing and reports write status on stderr. A report without installed packages labels its resolved pins as a declared baseline.
|
|
46
|
+
|
|
47
|
+
When a linked consumer renders differently despite matching root SDK versions, `doctor` checks direct consumers and SDK dependency/peer paths, including private copies with the same version. Review the reported importer and real paths, align the shared catalog and linked-consumer declarations, then reinstall at the root and rerun `doctor --workspace`; a root install may leave private linked-package installs intact. Restart running games after reinstall. Workspace mode checks installed identities only, reports coverage, and refuses unsupported patterns, escaped symlinks or exhausted traversal budgets. The default project audit stays scoped; neither mode covers custom bundler aliases or indirect non-SDK chains.
|
|
48
|
+
|
|
27
49
|
## 2. Select domains
|
|
28
50
|
|
|
29
51
|
Load only rows the target needs. Do not preload every domain.
|
|
@@ -46,13 +68,13 @@ World content routes editor-first: any request that adds, moves, restyles, or re
|
|
|
46
68
|
|
|
47
69
|
Within each selected domain:
|
|
48
70
|
|
|
49
|
-
0. Fastest start for a common composition: `npx jgengine recipe <name>` prints a vetted, SDK-typechecked snippet (imports + wired code)
|
|
50
|
-
1. Search
|
|
51
|
-
2. Open `api.md` only when exact signatures
|
|
71
|
+
0. Fastest start for a common composition: `npx jgengine recipe <name>` prints a vetted, SDK-typechecked snippet (imports + wired code) (`combat-loop`, `loot`, `world-drops`, `click-target`, `ability-bar`, `hitscan-weapon`, `rolled-gear`, `enter-vehicle` and more); `npx jgengine recipe` lists them. Copy that instead of reconning per-file.
|
|
72
|
+
1. Search capabilities by intent; do not read `capabilities.md` top to bottom. `npx jgengine find <intent>` searches every domain's index and every recipe at once, installed or not, and prints the primitive and its import (or the recipe to open); agents can use `--json --limit 5` for ranked imports and recipes with total, partial-match, and truncation fields. In the monorepo, `rg -i "<intent>" .claude/skills/*/capabilities.md` does the same search. Search the moment you are tempted to hand-roll a HUD window, inventory grid, paperdoll, hotkey listener, minimap, stat bar, or character motion. A `z-index`, a `keydown` for a panel, a `<div>` inventory, or a limb bob almost certainly has a shipped drop-in (`usePanels`/`PanelHost`, `InventoryGrid`, `CharacterSheet`/`Paperdoll`, `PartMotionRig`, `EntityPreview`); games own the *skin*, not the re-derivation.
|
|
73
|
+
2. Open `api.md` (monorepo) or the package `.d.ts` only when exact signatures are needed; grep it, never read it whole.
|
|
52
74
|
3. Open the linked reference only for a deeper recipe or trap.
|
|
53
75
|
4. For how several primitives wire into a running loop, read the domain's `recipes/` — connected, genre-free walkthroughs organized by composition seam; this skill's own [recipes/minimal-game.md](recipes/minimal-game.md) is the default whole-game walkthrough. Prefer a recipe over reading a game's source; the games under `Games/*` are not templates or references, and a hybrid game is just a different composition of the same primitives.
|
|
54
76
|
|
|
55
|
-
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;
|
|
77
|
+
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 and a reviewed consumer cleanup; coordinate a differently composed second consumer when applicable. Recipes wire SDK APIs, not copied implementations. If editor-owned content cannot be expressed, file the editor gap first; track any fallback and its removal path.
|
|
56
78
|
|
|
57
79
|
## 4. Build and finish
|
|
58
80
|
|