@jgengine/ws 0.18.0 → 0.18.1
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 +114 -2
- package/dist/createWsBackend.d.ts +6 -0
- package/dist/createWsBackend.js +101 -11
- package/dist/host.d.ts +10 -1
- package/dist/host.js +4 -3
- package/dist/hostRouter.d.ts +5 -1
- package/dist/hostRouter.js +82 -7
- package/dist/protocol.d.ts +13 -0
- package/dist/protocol.js +8 -2
- package/dist/worldHost.d.ts +1 -1
- package/dist/worldHost.js +46 -10
- package/package.json +2 -2
- package/skills/ce-handoff/SKILL.md +1 -1
- package/skills/jgengine/SKILL.md +2 -0
- package/skills/jgengine/api.md +94 -19
- package/skills/jgengine/capabilities.md +28 -0
- package/skills/jgengine-multiplayer/SKILL.md +25 -0
- package/skills/jgengine-multiplayer/api.md +78 -36
- package/skills/jgengine-multiplayer/capabilities.md +37 -0
package/dist/hostRouter.js
CHANGED
|
@@ -2,10 +2,13 @@ import { DEFAULT_POSE_SYNC_RULES, decidePoseSync, spawnPresenceState, } from "@j
|
|
|
2
2
|
import { createPositionHistory } from "@jgengine/core/multiplayer/lagCompensation";
|
|
3
3
|
import { createChatRateLimiter, DEFAULT_CHAT_BODY_LENGTH, DEFAULT_CHAT_HISTORY_LIMIT, DEFAULT_CHAT_RATE_LIMIT, } from "@jgengine/core/game/chat";
|
|
4
4
|
import { createCommandMiddleware, } from "./commandMiddleware.js";
|
|
5
|
+
import { DEFAULT_COMMAND_LIMITS } from "./commandMiddleware.js";
|
|
5
6
|
import { decodeWsClientMessage, encodeWsMessage, inspectWsDecodeFailure, subscriptionKey, } from "./protocol.js";
|
|
6
7
|
export const DEFAULT_POSE_RULES = DEFAULT_POSE_SYNC_RULES;
|
|
7
8
|
/** Cap on frames queued behind a connection's in-flight message; beyond this a flood gets rejected instead of piling up unbounded promises. */
|
|
8
9
|
export const MAX_QUEUED_MESSAGES = 64;
|
|
10
|
+
/** Default reconnect grace period for hosted socket sessions. */
|
|
11
|
+
export const DEFAULT_GRACE_MS = 15_000;
|
|
9
12
|
function appearanceEqual(a, b) {
|
|
10
13
|
if (a === b)
|
|
11
14
|
return true;
|
|
@@ -43,6 +46,7 @@ function dropUserFrom(store, userId, shouldVisit, onChanged) {
|
|
|
43
46
|
export function createHostRouter(options) {
|
|
44
47
|
const host = options.host;
|
|
45
48
|
const now = options.now ?? Date.now;
|
|
49
|
+
const graceMs = options.graceMs ?? DEFAULT_GRACE_MS;
|
|
46
50
|
const poseRules = options.poseRules ?? DEFAULT_POSE_RULES;
|
|
47
51
|
const allowAnonymous = options.allowAnonymous === true;
|
|
48
52
|
const singleSession = options.singleSession !== false;
|
|
@@ -50,6 +54,22 @@ export function createHostRouter(options) {
|
|
|
50
54
|
(allowAnonymous ? ({ userId }) => userId : null);
|
|
51
55
|
const connections = new Set();
|
|
52
56
|
const sessionsByUserId = new Map();
|
|
57
|
+
const resumeTokens = new Map();
|
|
58
|
+
const pendingLeaves = new Map();
|
|
59
|
+
const ticketKey = (userId, serverId) => `${userId}|${serverId}`;
|
|
60
|
+
const ticketFor = (userId, serverId) => {
|
|
61
|
+
const token = resumeTokens.get(userId) ?? crypto.randomUUID();
|
|
62
|
+
resumeTokens.set(userId, token);
|
|
63
|
+
return { userId, serverId, token };
|
|
64
|
+
};
|
|
65
|
+
const clearPendingLeave = (userId, serverId) => {
|
|
66
|
+
const key = ticketKey(userId, serverId);
|
|
67
|
+
const timer = pendingLeaves.get(key);
|
|
68
|
+
if (timer === undefined)
|
|
69
|
+
return;
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
pendingLeaves.delete(key);
|
|
72
|
+
};
|
|
53
73
|
const subscribers = new Map();
|
|
54
74
|
const subscribersOf = (key) => {
|
|
55
75
|
const set = subscribers.get(key);
|
|
@@ -115,7 +135,7 @@ export function createHostRouter(options) {
|
|
|
115
135
|
data: presenceRows(serverId),
|
|
116
136
|
}));
|
|
117
137
|
const commandMiddleware = createCommandMiddleware({
|
|
118
|
-
limits: options.limits,
|
|
138
|
+
limits: options.limits ?? DEFAULT_COMMAND_LIMITS,
|
|
119
139
|
authorize: options.authorize,
|
|
120
140
|
validate: options.validate,
|
|
121
141
|
});
|
|
@@ -221,7 +241,19 @@ export function createHostRouter(options) {
|
|
|
221
241
|
if (connection.userId === null)
|
|
222
242
|
return;
|
|
223
243
|
if (channel === "server") {
|
|
224
|
-
|
|
244
|
+
if (host.pullWorld !== undefined) {
|
|
245
|
+
const sinceRevision = connection.worldRevisions.get(serverId) ?? null;
|
|
246
|
+
const world = await host.pullWorld({ userId: connection.userId, serverId, sinceRevision, role: connection.roles.get(serverId) });
|
|
247
|
+
if (world !== null) {
|
|
248
|
+
connection.worldRevisions.set(serverId, world.revision);
|
|
249
|
+
const data = await host.getServerView({ userId: connection.userId, serverId, role: connection.roles.get(serverId) });
|
|
250
|
+
if (data !== null) {
|
|
251
|
+
send(connection, { v: 1, t: "update", channel, serverId, data: { ...data, serverState: world } });
|
|
252
|
+
}
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const data = await host.getServerView({ userId: connection.userId, serverId, role: connection.roles.get(serverId) });
|
|
225
257
|
send(connection, { v: 1, t: "update", channel, serverId, data });
|
|
226
258
|
}
|
|
227
259
|
else if (channel === "player") {
|
|
@@ -336,7 +368,10 @@ export function createHostRouter(options) {
|
|
|
336
368
|
connection.transport.close();
|
|
337
369
|
return;
|
|
338
370
|
}
|
|
339
|
-
const
|
|
371
|
+
const resumeToken = resumeTokens.get(message.userId);
|
|
372
|
+
const userId = resumeToken !== undefined && message.token === resumeToken
|
|
373
|
+
? message.userId
|
|
374
|
+
: await authenticate({ userId: message.userId, token: message.token });
|
|
340
375
|
if (userId === null) {
|
|
341
376
|
replyError(connection, message.id, "Not authenticated");
|
|
342
377
|
connection.transport.close();
|
|
@@ -352,10 +387,20 @@ export function createHostRouter(options) {
|
|
|
352
387
|
sessionsByUserId.set(userId, connection);
|
|
353
388
|
}
|
|
354
389
|
connection.userId = userId;
|
|
390
|
+
if (resumeToken !== undefined && message.token === resumeToken) {
|
|
391
|
+
for (const key of [...pendingLeaves.keys()]) {
|
|
392
|
+
if (key.startsWith(`${userId}|`)) {
|
|
393
|
+
const serverId = key.slice(userId.length + 1);
|
|
394
|
+
clearPendingLeave(userId, serverId);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
355
398
|
reply(connection, message.id, { userId });
|
|
356
399
|
return;
|
|
357
400
|
}
|
|
358
401
|
if (message.t === "pose") {
|
|
402
|
+
if (connection.roles.get(message.serverId) === "spectator")
|
|
403
|
+
return;
|
|
359
404
|
await handlePose(connection, message.serverId, message.pose);
|
|
360
405
|
return;
|
|
361
406
|
}
|
|
@@ -366,6 +411,9 @@ export function createHostRouter(options) {
|
|
|
366
411
|
const userId = connection.userId;
|
|
367
412
|
try {
|
|
368
413
|
switch (message.t) {
|
|
414
|
+
case "ping":
|
|
415
|
+
send(connection, { v: 1, t: "pong", id: message.id, at: message.at, serverAt: now() });
|
|
416
|
+
break;
|
|
369
417
|
case "join": {
|
|
370
418
|
if (!(await gate(connection, message.id, "join", { serverId: message.serverId })))
|
|
371
419
|
return;
|
|
@@ -375,9 +423,14 @@ export function createHostRouter(options) {
|
|
|
375
423
|
serverId: message.serverId,
|
|
376
424
|
attributes: message.attributes,
|
|
377
425
|
code: message.code,
|
|
426
|
+
role: message.role,
|
|
378
427
|
});
|
|
379
428
|
connection.joinedServers.add(result.serverId);
|
|
380
|
-
|
|
429
|
+
connection.roles.set(result.serverId, message.role ?? "player");
|
|
430
|
+
reply(connection, message.id, {
|
|
431
|
+
...result,
|
|
432
|
+
resumeTicket: ticketFor(userId, result.serverId),
|
|
433
|
+
});
|
|
381
434
|
return;
|
|
382
435
|
}
|
|
383
436
|
case "joinByCode": {
|
|
@@ -388,9 +441,13 @@ export function createHostRouter(options) {
|
|
|
388
441
|
gameId: message.gameId,
|
|
389
442
|
code: message.code,
|
|
390
443
|
});
|
|
391
|
-
if (result !== null)
|
|
444
|
+
if (result !== null) {
|
|
392
445
|
connection.joinedServers.add(result.serverId);
|
|
393
|
-
|
|
446
|
+
connection.roles.set(result.serverId, message.role ?? "player");
|
|
447
|
+
}
|
|
448
|
+
reply(connection, message.id, result === null
|
|
449
|
+
? null
|
|
450
|
+
: { ...result, resumeTicket: ticketFor(userId, result.serverId) });
|
|
394
451
|
return;
|
|
395
452
|
}
|
|
396
453
|
case "browse": {
|
|
@@ -406,13 +463,19 @@ export function createHostRouter(options) {
|
|
|
406
463
|
}
|
|
407
464
|
case "leave": {
|
|
408
465
|
await host.leaveServer({ userId, serverId: message.serverId });
|
|
466
|
+
clearPendingLeave(userId, message.serverId);
|
|
409
467
|
connection.joinedServers.delete(message.serverId);
|
|
468
|
+
connection.roles.delete(message.serverId);
|
|
410
469
|
dropPresenceForServer(userId, message.serverId);
|
|
411
470
|
dropVoiceForServer(userId, message.serverId);
|
|
412
471
|
reply(connection, message.id, null);
|
|
413
472
|
return;
|
|
414
473
|
}
|
|
415
474
|
case "runCommand": {
|
|
475
|
+
if (connection.roles.get(message.serverId) === "spectator") {
|
|
476
|
+
replyError(connection, message.id, "Spectators cannot run commands");
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
416
479
|
if (!(await gate(connection, message.id, "runCommand", {
|
|
417
480
|
serverId: message.serverId,
|
|
418
481
|
command: message.command,
|
|
@@ -490,6 +553,8 @@ export function createHostRouter(options) {
|
|
|
490
553
|
replyError(connection, message.id, "Not a member of this server");
|
|
491
554
|
return;
|
|
492
555
|
}
|
|
556
|
+
if (message.channel === "server")
|
|
557
|
+
connection.worldRevisions.set(message.serverId, null);
|
|
493
558
|
addSubscription(connection, subscriptionKey(message.channel, message.serverId, message.action));
|
|
494
559
|
reply(connection, message.id, null);
|
|
495
560
|
await pushSubscription(connection, message.channel, message.serverId, message.action);
|
|
@@ -513,7 +578,12 @@ export function createHostRouter(options) {
|
|
|
513
578
|
const servers = [...connection.joinedServers];
|
|
514
579
|
connection.joinedServers.clear();
|
|
515
580
|
for (const serverId of servers) {
|
|
516
|
-
|
|
581
|
+
const key = ticketKey(userId, serverId);
|
|
582
|
+
clearPendingLeave(userId, serverId);
|
|
583
|
+
pendingLeaves.set(key, setTimeout(() => {
|
|
584
|
+
pendingLeaves.delete(key);
|
|
585
|
+
void host.leaveServer({ userId, serverId }).catch(() => undefined);
|
|
586
|
+
}, graceMs));
|
|
517
587
|
}
|
|
518
588
|
};
|
|
519
589
|
return {
|
|
@@ -525,6 +595,8 @@ export function createHostRouter(options) {
|
|
|
525
595
|
joinedServers: new Set(),
|
|
526
596
|
queue: Promise.resolve(),
|
|
527
597
|
queuedMessages: 0,
|
|
598
|
+
worldRevisions: new Map(),
|
|
599
|
+
roles: new Map(),
|
|
528
600
|
};
|
|
529
601
|
connections.add(connection);
|
|
530
602
|
return {
|
|
@@ -579,6 +651,9 @@ export function createHostRouter(options) {
|
|
|
579
651
|
},
|
|
580
652
|
close: () => {
|
|
581
653
|
unsubscribeHost();
|
|
654
|
+
for (const timer of pendingLeaves.values())
|
|
655
|
+
clearTimeout(timer);
|
|
656
|
+
pendingLeaves.clear();
|
|
582
657
|
connections.clear();
|
|
583
658
|
subscribers.clear();
|
|
584
659
|
},
|
package/dist/protocol.d.ts
CHANGED
|
@@ -34,6 +34,11 @@ export type WsVoiceParticipant = {
|
|
|
34
34
|
streamId?: string;
|
|
35
35
|
};
|
|
36
36
|
export type WsClientMessage = {
|
|
37
|
+
v: 1;
|
|
38
|
+
t: "ping";
|
|
39
|
+
id: number;
|
|
40
|
+
at: number;
|
|
41
|
+
} | {
|
|
37
42
|
v: 1;
|
|
38
43
|
t: "hello";
|
|
39
44
|
id: number;
|
|
@@ -47,12 +52,14 @@ export type WsClientMessage = {
|
|
|
47
52
|
serverId?: string;
|
|
48
53
|
attributes?: SessionAttributes;
|
|
49
54
|
code?: string;
|
|
55
|
+
role?: "player" | "spectator";
|
|
50
56
|
} | {
|
|
51
57
|
v: 1;
|
|
52
58
|
t: "joinByCode";
|
|
53
59
|
id: number;
|
|
54
60
|
gameId: string;
|
|
55
61
|
code: string;
|
|
62
|
+
role?: "player" | "spectator";
|
|
56
63
|
} | {
|
|
57
64
|
v: 1;
|
|
58
65
|
t: "browse";
|
|
@@ -167,6 +174,12 @@ export type WsUpdateMessage = {
|
|
|
167
174
|
data: WsVoiceParticipant[];
|
|
168
175
|
};
|
|
169
176
|
export type WsServerMessage = {
|
|
177
|
+
v: 1;
|
|
178
|
+
t: "pong";
|
|
179
|
+
id: number;
|
|
180
|
+
at: number;
|
|
181
|
+
serverAt: number;
|
|
182
|
+
} | {
|
|
170
183
|
v: 1;
|
|
171
184
|
t: "reply";
|
|
172
185
|
id: number;
|
package/dist/protocol.js
CHANGED
|
@@ -113,6 +113,8 @@ export function decodeWsClientMessage(raw) {
|
|
|
113
113
|
if (message === null)
|
|
114
114
|
return null;
|
|
115
115
|
switch (message.t) {
|
|
116
|
+
case "ping":
|
|
117
|
+
return typeof message.id === "number" && typeof message.at === "number" ? message : null;
|
|
116
118
|
case "hello":
|
|
117
119
|
return typeof message.id === "number" &&
|
|
118
120
|
typeof message.userId === "string" &&
|
|
@@ -123,13 +125,15 @@ export function decodeWsClientMessage(raw) {
|
|
|
123
125
|
return typeof message.id === "number" &&
|
|
124
126
|
typeof message.gameId === "string" &&
|
|
125
127
|
(message.serverId === undefined || typeof message.serverId === "string") &&
|
|
126
|
-
(message.code === undefined || typeof message.code === "string")
|
|
128
|
+
(message.code === undefined || typeof message.code === "string") &&
|
|
129
|
+
(message.role === undefined || message.role === "player" || message.role === "spectator")
|
|
127
130
|
? message
|
|
128
131
|
: null;
|
|
129
132
|
case "joinByCode":
|
|
130
133
|
return typeof message.id === "number" &&
|
|
131
134
|
typeof message.gameId === "string" &&
|
|
132
|
-
typeof message.code === "string"
|
|
135
|
+
typeof message.code === "string" &&
|
|
136
|
+
(message.role === undefined || message.role === "player" || message.role === "spectator")
|
|
133
137
|
? message
|
|
134
138
|
: null;
|
|
135
139
|
case "browse":
|
|
@@ -204,6 +208,8 @@ export function decodeWsServerMessage(raw) {
|
|
|
204
208
|
if (message === null)
|
|
205
209
|
return null;
|
|
206
210
|
switch (message.t) {
|
|
211
|
+
case "pong":
|
|
212
|
+
return typeof message.id === "number" && typeof message.at === "number" && typeof message.serverAt === "number" ? message : null;
|
|
207
213
|
case "reply":
|
|
208
214
|
if (typeof message.id !== "number")
|
|
209
215
|
return null;
|
package/dist/worldHost.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export interface WorldGameHostOptions {
|
|
|
9
9
|
session(args: {
|
|
10
10
|
gameId: string;
|
|
11
11
|
serverId: string;
|
|
12
|
-
}): HostedWorldSession | null;
|
|
12
|
+
}): HostedWorldSession | Promise<HostedWorldSession | null> | null;
|
|
13
13
|
now?: () => number;
|
|
14
14
|
}
|
|
15
15
|
/** A {@link GameHost} whose worlds run on `HostedWorldSession`s; `tick` advances them and re-broadcasts on change. */
|
package/dist/worldHost.js
CHANGED
|
@@ -7,6 +7,7 @@ import { INPUT_COMMAND } from "@jgengine/core/runtime/hostedGameRunner";
|
|
|
7
7
|
*/
|
|
8
8
|
export function createWorldGameHost(options) {
|
|
9
9
|
const live = new Map();
|
|
10
|
+
const roles = new Map();
|
|
10
11
|
const listeners = new Set();
|
|
11
12
|
const now = options.now ?? (() => Date.now());
|
|
12
13
|
function emit(event) {
|
|
@@ -17,10 +18,19 @@ export function createWorldGameHost(options) {
|
|
|
17
18
|
const existing = live.get(serverId);
|
|
18
19
|
if (existing !== undefined)
|
|
19
20
|
return existing;
|
|
20
|
-
const
|
|
21
|
-
if (
|
|
21
|
+
const resolved = options.session({ gameId, serverId });
|
|
22
|
+
if (resolved instanceof Promise) {
|
|
23
|
+
return resolved.then((session) => {
|
|
24
|
+
if (session === null)
|
|
25
|
+
return null;
|
|
26
|
+
const entry = { gameId, session };
|
|
27
|
+
live.set(serverId, entry);
|
|
28
|
+
return entry;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
if (resolved === null)
|
|
22
32
|
return null;
|
|
23
|
-
const entry = { gameId, session };
|
|
33
|
+
const entry = { gameId, session: resolved };
|
|
24
34
|
live.set(serverId, entry);
|
|
25
35
|
return entry;
|
|
26
36
|
}
|
|
@@ -33,13 +43,21 @@ export function createWorldGameHost(options) {
|
|
|
33
43
|
}
|
|
34
44
|
}
|
|
35
45
|
return {
|
|
36
|
-
async joinServer({ userId, gameId, serverId }) {
|
|
46
|
+
async joinServer({ userId, gameId, serverId, role }) {
|
|
37
47
|
const id = serverId ?? gameId;
|
|
38
|
-
const
|
|
48
|
+
const pending = ensure(gameId, id);
|
|
49
|
+
const entry = pending instanceof Promise ? await pending : pending;
|
|
39
50
|
if (entry === null)
|
|
40
51
|
throw new Error(`no hosted world for game "${gameId}"`);
|
|
41
52
|
const isNew = !entry.session.members().includes(userId);
|
|
42
|
-
|
|
53
|
+
let serverRoles = roles.get(id);
|
|
54
|
+
if (serverRoles === undefined) {
|
|
55
|
+
serverRoles = new Map();
|
|
56
|
+
roles.set(id, serverRoles);
|
|
57
|
+
}
|
|
58
|
+
serverRoles.set(userId, role ?? "player");
|
|
59
|
+
if (role !== "spectator")
|
|
60
|
+
entry.session.join(userId, isNew);
|
|
43
61
|
emit({ type: "server", serverId: id });
|
|
44
62
|
emit({ type: "player", serverId: id, userId });
|
|
45
63
|
return { serverId: id, isNew };
|
|
@@ -48,7 +66,9 @@ export function createWorldGameHost(options) {
|
|
|
48
66
|
const entry = live.get(serverId);
|
|
49
67
|
if (entry === undefined)
|
|
50
68
|
return;
|
|
51
|
-
entry.session.
|
|
69
|
+
if (entry.session.members().includes(userId))
|
|
70
|
+
entry.session.leave(userId);
|
|
71
|
+
roles.get(serverId)?.delete(userId);
|
|
52
72
|
emit({ type: "server", serverId });
|
|
53
73
|
},
|
|
54
74
|
async runCommand({ userId, serverId, command, input }) {
|
|
@@ -70,9 +90,9 @@ export function createWorldGameHost(options) {
|
|
|
70
90
|
return { ok: true };
|
|
71
91
|
},
|
|
72
92
|
async isMember({ userId, serverId }) {
|
|
73
|
-
return live.get(serverId)?.session.members().includes(userId)
|
|
93
|
+
return live.get(serverId)?.session.members().includes(userId) || roles.get(serverId)?.has(userId) === true;
|
|
74
94
|
},
|
|
75
|
-
async getServerView({ userId, serverId }) {
|
|
95
|
+
async getServerView({ userId, serverId, role }) {
|
|
76
96
|
const entry = live.get(serverId);
|
|
77
97
|
if (entry === undefined)
|
|
78
98
|
return null;
|
|
@@ -81,10 +101,26 @@ export function createWorldGameHost(options) {
|
|
|
81
101
|
gameId: entry.gameId,
|
|
82
102
|
revision: entry.session.revision(),
|
|
83
103
|
memberUserIds: [...entry.session.members()],
|
|
84
|
-
serverState: entry.session.snapshotFor({ userId }),
|
|
104
|
+
serverState: entry.session.snapshotFor({ userId, role: role ?? roles.get(serverId)?.get(userId) ?? "player" }),
|
|
85
105
|
updatedAt: now(),
|
|
86
106
|
};
|
|
87
107
|
},
|
|
108
|
+
async pullWorld({ userId, serverId, sinceRevision, role }) {
|
|
109
|
+
const entry = live.get(serverId);
|
|
110
|
+
if (entry === undefined || (!entry.session.members().includes(userId) && roles.get(serverId)?.get(userId) !== "spectator"))
|
|
111
|
+
return null;
|
|
112
|
+
if (entry.session.projectsViewers()) {
|
|
113
|
+
return {
|
|
114
|
+
kind: "baseline",
|
|
115
|
+
revision: entry.session.revision(),
|
|
116
|
+
snapshot: entry.session.snapshotFor({ userId, role: role ?? roles.get(serverId)?.get(userId) ?? "player" }),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const sync = entry.session.pull(sinceRevision);
|
|
120
|
+
return sync.kind === "baseline"
|
|
121
|
+
? sync
|
|
122
|
+
: { kind: "diff", revision: sync.diff.revision, diff: sync.diff };
|
|
123
|
+
},
|
|
88
124
|
async getPlayerView() {
|
|
89
125
|
return null;
|
|
90
126
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jgengine/ws",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.1",
|
|
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.18.
|
|
34
|
+
"@jgengine/core": "^0.18.1"
|
|
35
35
|
},
|
|
36
36
|
"publishConfig": {
|
|
37
37
|
"access": "public"
|
|
@@ -146,4 +146,4 @@ When sufficient, return a concise orientation: objective, progress, decisions, c
|
|
|
146
146
|
- Multi-session engine work often spans `docs/plans/`, GitHub issues, and stacked 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
|
@@ -9,6 +9,8 @@ Read the JGengine README for package truth, commands, layering, and license —
|
|
|
9
9
|
|
|
10
10
|
## 1. Establish the target
|
|
11
11
|
|
|
12
|
+
For greenfield builds, write `src/art-direction.md` before any code.
|
|
13
|
+
|
|
12
14
|
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
15
|
|
|
14
16
|
- player point of view and controls
|