@jgengine/ws 0.11.0 → 0.13.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 +52 -0
- package/dist/host.js +23 -26
- package/dist/hostRouter.js +60 -88
- package/package.json +2 -2
- package/skills/jgengine/SKILL.md +8 -4
- package/skills/jgengine/api.md +182 -81
- 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,58 @@ 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.13.0
|
|
16
|
+
|
|
17
|
+
### Migrate
|
|
18
|
+
|
|
19
|
+
- **Nothing required** — this release is additive fixes, defaults, and new drop-in primitives. Bump `@jgengine/*` pins to `^0.13.0` (CLI `jgengine` is 0.10.0; `@jgengine/github` unchanged at 0.1.0) and rebuild.
|
|
20
|
+
- Two defaults changed in the player's favor, both overridable: **offline games now persist to localStorage automatically** (#1207) and **rigged catalog assets animate by default** via clip metadata + semantic clip roles (#1209). If a game relied on rigged models staying frozen or on saves being memory-only, opt out explicitly; otherwise you get better behavior for free.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **Drop-in inventory grid, window manager, and character sheet** (#1220) — `@jgengine/react` ships `InventoryGrid` (real drag/stack/split), a toggleable-window system (`panels`: bag, character sheet, spellbook-style windows with `@jgengine/core/ui/panelModel` state), and a `CharacterSheet` paperdoll, all HudTheme-skinnable — wire in a line or two instead of re-deriving from raw divs.
|
|
25
|
+
- **Serializable runtime snapshots** (#1179) — capture/restore of live runtime state, including magazine state (`@jgengine/core/combat/magazine`) and stat modifiers, for saves and replication.
|
|
26
|
+
- **Portable weapon plumbing** (#1211) — `@jgengine/core/combat/weaponFire` composes trigger → magazine → spread → projectile/hitscan as data, no game-local glue.
|
|
27
|
+
- **Portable leveling** (#1206) and **portable damage-pool access** (#1208) — progression and damage pools exposed as genre-agnostic seams on the game context.
|
|
28
|
+
- **Named combat VFX presets** (#1204) — `@jgengine/core/combat/vfxPresets`: a visible attack is one line.
|
|
29
|
+
- **Runtime-measured render-bounds hitboxes** (#1227) — colliders wrap what the shell actually mounts, not the raw asset bounds.
|
|
30
|
+
- **Character motion feel** — procedural part motion for rig-less part-composed characters (#1222) plus squash & stretch and death splat (#1229).
|
|
31
|
+
- **City fabric** (#1191) — `streetGenerator` + `cityGenerator`, editor bake verb, street-aware lots, road junctions, grounding, and a web playground.
|
|
32
|
+
- **`jgengine recipe` CLI** (#1214, #1231) — vetted wired compositions, compile-pinned against the SDK, listed and copied from the CLI.
|
|
33
|
+
- **Editor/debug modes mirror to the URL** with a proper editor exit (#1203); game template ships a dependency-free WebGL screenshot script (#1202).
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
- Offline games persist to localStorage by default (#1207); rigged assets animate by default (#1209).
|
|
38
|
+
- RTS camera pan inversion fixed (#1228); sprite raycast warning silenced and duplicate nameplate health bars removed (#1194); scaffolded F2+E editor Tailwind `@source` renders its theme (#1195).
|
|
39
|
+
- Outside-game DX: stat-pool no-op stability, camera-transparent decor, cleaner install/version output, headless UI-intent seam (#1223); new-game briefs direct engine bugs/gaps upstream (#1200).
|
|
40
|
+
|
|
41
|
+
## 0.12.0
|
|
42
|
+
|
|
43
|
+
### Migrate
|
|
44
|
+
|
|
45
|
+
- **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.
|
|
46
|
+
- **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.
|
|
47
|
+
- Everything else is additive — stat pools, minimap marker sources, auction house, city/path generation, editor workspaces require no changes to existing games.
|
|
48
|
+
|
|
49
|
+
### Added
|
|
50
|
+
|
|
51
|
+
- **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`.
|
|
52
|
+
- **`@jgengine/shell/gameKit`** — happy-path authoring surface: authoring, mount, stores, systems, authored-scene helpers, HUD primitives, seeded rng in one import.
|
|
53
|
+
- **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`.
|
|
54
|
+
- **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.
|
|
55
|
+
- **Portable minimap marker sources** — core world seam feeding game minimaps; recipe in skill `jgengine-ui`.
|
|
56
|
+
- **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.
|
|
57
|
+
- **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).
|
|
58
|
+
- **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.
|
|
59
|
+
- **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).
|
|
60
|
+
- **Skills** — research-backed `game-design` and `level-design` skills (#962); every-game-owns-custom-UI invariant codified (#1148).
|
|
61
|
+
|
|
62
|
+
### Changed
|
|
63
|
+
|
|
64
|
+
- `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`.
|
|
65
|
+
- Environment studio visual-quality pass — turf tufts, depth-aware water, relief soil, weathered poles; grass fields no longer rebuild geometry per frame (#1090).
|
|
66
|
+
|
|
15
67
|
## 0.11.0
|
|
16
68
|
|
|
17
69
|
### Migrate
|
package/dist/host.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createGameRuntime } from "@jgengine/core/runtime/gameRuntime";
|
|
2
2
|
import { applyLeaderboardRows, buildHydratePlayers, planServerPersist, profileLeaderboardStats, shouldAutoSave, topLeaderboardRows, toOpenServerListings, toServerListing, trimFeedEntries, } from "@jgengine/core/runtime/hostPersistence";
|
|
3
|
-
import {
|
|
3
|
+
import { isListablePublicly, isPrivateJoinBlocked, isServerFull, isServerMember, statusAfterLeave, withJoinedMember, withoutMember, } from "@jgengine/core/runtime/hostPolicy";
|
|
4
|
+
import { browseSessions, findByJoinCode } from "@jgengine/core/multiplayer/matchmaking";
|
|
4
5
|
import { createFeedWriteGate, validateFeedWrite, } from "@jgengine/core/multiplayer/feedWriteGate";
|
|
5
6
|
import { clearDirtyFlags, createEmptyServerRow, splitProfilePlayer } from "@jgengine/core/runtime/snapshot";
|
|
6
7
|
const builtinCommands = {
|
|
@@ -219,15 +220,14 @@ export function createGameHost(options) {
|
|
|
219
220
|
throw new Error("Server belongs to a different game");
|
|
220
221
|
}
|
|
221
222
|
if (entry !== null &&
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
}
|
|
223
|
+
isPrivateJoinBlocked({
|
|
224
|
+
visibility: entry.record.visibility,
|
|
225
|
+
memberUserIds: entry.record.memberUserIds,
|
|
226
|
+
userId: args.userId,
|
|
227
|
+
joinCode: entry.record.joinCode,
|
|
228
|
+
suppliedCode: args.code,
|
|
229
|
+
})) {
|
|
230
|
+
throw new Error("Server is private");
|
|
231
231
|
}
|
|
232
232
|
if (entry === null) {
|
|
233
233
|
const attributes = args.attributes;
|
|
@@ -253,17 +253,14 @@ export function createGameHost(options) {
|
|
|
253
253
|
entry = await hydrate(record);
|
|
254
254
|
}
|
|
255
255
|
const { record } = entry;
|
|
256
|
-
if (record.memberUserIds
|
|
257
|
-
!record.memberUserIds.includes(args.userId)) {
|
|
256
|
+
if (isServerFull(record.memberUserIds, record.slotsPerServer, args.userId)) {
|
|
258
257
|
throw new Error("Server is full");
|
|
259
258
|
}
|
|
260
259
|
const profile = await persistence.loadProfile({ userId: args.userId, gameId: args.gameId });
|
|
261
260
|
const isNew = profile === null;
|
|
262
261
|
entry.record = {
|
|
263
262
|
...record,
|
|
264
|
-
memberUserIds: record.memberUserIds
|
|
265
|
-
? record.memberUserIds
|
|
266
|
-
: [...record.memberUserIds, args.userId],
|
|
263
|
+
memberUserIds: withJoinedMember(record.memberUserIds, args.userId),
|
|
267
264
|
status: "running",
|
|
268
265
|
updatedAt: timestamp,
|
|
269
266
|
dirtyAt: timestamp,
|
|
@@ -292,11 +289,11 @@ export function createGameHost(options) {
|
|
|
292
289
|
const entry = await getLive(args.serverId);
|
|
293
290
|
if (entry === null)
|
|
294
291
|
return;
|
|
295
|
-
if (!entry.record.memberUserIds
|
|
292
|
+
if (!isServerMember(entry.record.memberUserIds, args.userId))
|
|
296
293
|
return;
|
|
297
294
|
await flushServer(entry);
|
|
298
295
|
const timestamp = now();
|
|
299
|
-
const memberUserIds = entry.record.memberUserIds
|
|
296
|
+
const memberUserIds = withoutMember(entry.record.memberUserIds, args.userId);
|
|
300
297
|
const sessionPlayers = { ...entry.record.sessionPlayers };
|
|
301
298
|
delete sessionPlayers[args.userId];
|
|
302
299
|
const players = { ...entry.snapshot.players };
|
|
@@ -306,7 +303,7 @@ export function createGameHost(options) {
|
|
|
306
303
|
memberUserIds,
|
|
307
304
|
sessionPlayers,
|
|
308
305
|
updatedAt: timestamp,
|
|
309
|
-
status: memberUserIds.length
|
|
306
|
+
status: statusAfterLeave(memberUserIds.length, entry.record.status),
|
|
310
307
|
};
|
|
311
308
|
entry.snapshot = { ...entry.snapshot, players };
|
|
312
309
|
await persistence.saveServer(entry.record);
|
|
@@ -321,7 +318,7 @@ export function createGameHost(options) {
|
|
|
321
318
|
if (entry === null) {
|
|
322
319
|
return { ok: false, reason: "Server not found" };
|
|
323
320
|
}
|
|
324
|
-
if (!entry.record.memberUserIds
|
|
321
|
+
if (!isServerMember(entry.record.memberUserIds, args.userId)) {
|
|
325
322
|
return { ok: false, reason: "Not a member of this server" };
|
|
326
323
|
}
|
|
327
324
|
const envelope = unwrapOpEnvelope(args.input);
|
|
@@ -351,13 +348,13 @@ export function createGameHost(options) {
|
|
|
351
348
|
const entry = await getLive(args.serverId);
|
|
352
349
|
if (entry === null)
|
|
353
350
|
return false;
|
|
354
|
-
return entry.record.memberUserIds
|
|
351
|
+
return isServerMember(entry.record.memberUserIds, args.userId);
|
|
355
352
|
},
|
|
356
353
|
getServerView: async (args) => {
|
|
357
354
|
const entry = await getLive(args.serverId);
|
|
358
355
|
if (entry === null)
|
|
359
356
|
return null;
|
|
360
|
-
if (!entry.record.memberUserIds
|
|
357
|
+
if (!isServerMember(entry.record.memberUserIds, args.userId))
|
|
361
358
|
return null;
|
|
362
359
|
return {
|
|
363
360
|
serverId: entry.record.serverId,
|
|
@@ -372,7 +369,7 @@ export function createGameHost(options) {
|
|
|
372
369
|
const entry = await getLive(args.serverId);
|
|
373
370
|
if (entry === null)
|
|
374
371
|
return null;
|
|
375
|
-
if (!entry.record.memberUserIds
|
|
372
|
+
if (!isServerMember(entry.record.memberUserIds, args.userId))
|
|
376
373
|
return null;
|
|
377
374
|
const player = entry.snapshot.players[args.userId];
|
|
378
375
|
if (player !== undefined) {
|
|
@@ -397,7 +394,7 @@ export function createGameHost(options) {
|
|
|
397
394
|
const entry = await getLive(args.serverId);
|
|
398
395
|
if (entry === null)
|
|
399
396
|
return [];
|
|
400
|
-
if (!entry.record.memberUserIds
|
|
397
|
+
if (!isServerMember(entry.record.memberUserIds, args.userId))
|
|
401
398
|
return [];
|
|
402
399
|
return persistence.loadFeed({ serverId: args.serverId, action: args.action });
|
|
403
400
|
},
|
|
@@ -407,7 +404,7 @@ export function createGameHost(options) {
|
|
|
407
404
|
throw new Error(allowed.reason);
|
|
408
405
|
}
|
|
409
406
|
const entry = await getLive(args.serverId);
|
|
410
|
-
if (entry === null || !entry.record.memberUserIds
|
|
407
|
+
if (entry === null || !isServerMember(entry.record.memberUserIds, args.userId)) {
|
|
411
408
|
throw new Error("Not a member of this server");
|
|
412
409
|
}
|
|
413
410
|
await persistence.appendFeed({ serverId: args.serverId, action: args.action, entry: args.entry });
|
|
@@ -416,14 +413,14 @@ export function createGameHost(options) {
|
|
|
416
413
|
listOpenServers: async (args) => {
|
|
417
414
|
const byId = new Map();
|
|
418
415
|
for (const record of await persistence.listServers(args.gameId)) {
|
|
419
|
-
if ((record.visibility
|
|
416
|
+
if (!isListablePublicly(record.visibility))
|
|
420
417
|
continue;
|
|
421
418
|
byId.set(record.serverId, toServerListing(record));
|
|
422
419
|
}
|
|
423
420
|
for (const entry of live.values()) {
|
|
424
421
|
if (entry.record.gameId !== args.gameId)
|
|
425
422
|
continue;
|
|
426
|
-
if ((entry.record.visibility
|
|
423
|
+
if (!isListablePublicly(entry.record.visibility))
|
|
427
424
|
continue;
|
|
428
425
|
byId.set(entry.record.serverId, toServerListing(entry.record));
|
|
429
426
|
}
|
package/dist/hostRouter.js
CHANGED
|
@@ -1,15 +1,9 @@
|
|
|
1
|
-
import { decidePoseSync, spawnPresenceState } from "@jgengine/core/multiplayer/presenceModel";
|
|
1
|
+
import { DEFAULT_POSE_SYNC_RULES, decidePoseSync, spawnPresenceState, } from "@jgengine/core/multiplayer/presenceModel";
|
|
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
5
|
import { decodeWsClientMessage, encodeWsMessage, inspectWsDecodeFailure, subscriptionKey, } from "./protocol.js";
|
|
6
|
-
export const DEFAULT_POSE_RULES =
|
|
7
|
-
maxSpeed: 12,
|
|
8
|
-
maxVerticalOffset: 3,
|
|
9
|
-
minElapsedSec: 0.05,
|
|
10
|
-
maxElapsedSec: 0.5,
|
|
11
|
-
keepAliveRefreshMs: 10_000,
|
|
12
|
-
};
|
|
6
|
+
export const DEFAULT_POSE_RULES = DEFAULT_POSE_SYNC_RULES;
|
|
13
7
|
/** Cap on frames queued behind a connection's in-flight message; beyond this a flood gets rejected instead of piling up unbounded promises. */
|
|
14
8
|
export const MAX_QUEUED_MESSAGES = 64;
|
|
15
9
|
function appearanceEqual(a, b) {
|
|
@@ -23,6 +17,29 @@ function appearanceEqual(a, b) {
|
|
|
23
17
|
return false;
|
|
24
18
|
return aKeys.every((key) => a[key] === b[key]);
|
|
25
19
|
}
|
|
20
|
+
function getOrCreate(map, key, factory) {
|
|
21
|
+
let value = map.get(key);
|
|
22
|
+
if (value === undefined) {
|
|
23
|
+
value = factory();
|
|
24
|
+
map.set(key, value);
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Remove `userId` from every visited `server|scope` bucket, drop buckets that empty out, and notify
|
|
30
|
+
* per changed key. Backs both the disconnect-wide and per-server drops for presence and voice.
|
|
31
|
+
*/
|
|
32
|
+
function dropUserFrom(store, userId, shouldVisit, onChanged) {
|
|
33
|
+
for (const [key, inner] of store) {
|
|
34
|
+
if (!shouldVisit(key))
|
|
35
|
+
continue;
|
|
36
|
+
if (!inner.delete(userId))
|
|
37
|
+
continue;
|
|
38
|
+
if (inner.size === 0)
|
|
39
|
+
store.delete(key);
|
|
40
|
+
onChanged(key);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
26
43
|
export function createHostRouter(options) {
|
|
27
44
|
const host = options.host;
|
|
28
45
|
const now = options.now ?? Date.now;
|
|
@@ -59,14 +76,7 @@ export function createHostRouter(options) {
|
|
|
59
76
|
const presence = new Map();
|
|
60
77
|
const positionHistoryMs = options.positionHistoryMs ?? 1_000;
|
|
61
78
|
const histories = new Map();
|
|
62
|
-
const historyFor = (serverId) => {
|
|
63
|
-
let history = histories.get(serverId);
|
|
64
|
-
if (history === undefined) {
|
|
65
|
-
history = createPositionHistory({ historyMs: positionHistoryMs });
|
|
66
|
-
histories.set(serverId, history);
|
|
67
|
-
}
|
|
68
|
-
return history;
|
|
69
|
-
};
|
|
79
|
+
const historyFor = (serverId) => getOrCreate(histories, serverId, () => createPositionHistory({ historyMs: positionHistoryMs }));
|
|
70
80
|
const send = (connection, message) => {
|
|
71
81
|
connection.transport.send(encodeWsMessage(message));
|
|
72
82
|
};
|
|
@@ -89,16 +99,21 @@ export function createHostRouter(options) {
|
|
|
89
99
|
appearance: pose.appearance,
|
|
90
100
|
}));
|
|
91
101
|
};
|
|
92
|
-
const
|
|
93
|
-
const key = subscriptionKey("presence", serverId);
|
|
102
|
+
const broadcast = (key, build) => {
|
|
94
103
|
const targets = subscribersOf(key);
|
|
95
104
|
if (targets.length === 0)
|
|
96
105
|
return;
|
|
97
|
-
const
|
|
98
|
-
for (const connection of targets)
|
|
99
|
-
send(connection,
|
|
100
|
-
}
|
|
106
|
+
const message = build();
|
|
107
|
+
for (const connection of targets)
|
|
108
|
+
send(connection, message);
|
|
101
109
|
};
|
|
110
|
+
const broadcastPresence = (serverId) => broadcast(subscriptionKey("presence", serverId), () => ({
|
|
111
|
+
v: 1,
|
|
112
|
+
t: "update",
|
|
113
|
+
channel: "presence",
|
|
114
|
+
serverId,
|
|
115
|
+
data: presenceRows(serverId),
|
|
116
|
+
}));
|
|
102
117
|
const commandMiddleware = createCommandMiddleware({
|
|
103
118
|
limits: options.limits,
|
|
104
119
|
authorize: options.authorize,
|
|
@@ -125,69 +140,37 @@ export function createHostRouter(options) {
|
|
|
125
140
|
const chatHistoryLimit = options.chatHistoryLimit ?? DEFAULT_CHAT_HISTORY_LIMIT;
|
|
126
141
|
const chatMaxBodyLength = options.chatMaxBodyLength ?? DEFAULT_CHAT_BODY_LENGTH;
|
|
127
142
|
let chatCounter = 0;
|
|
128
|
-
const chatRing = (serverId, channelId) => {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
};
|
|
137
|
-
const broadcastChat = (serverId, channelId) => {
|
|
138
|
-
const key = subscriptionKey("chat", serverId, channelId);
|
|
139
|
-
const targets = subscribersOf(key);
|
|
140
|
-
if (targets.length === 0)
|
|
141
|
-
return;
|
|
142
|
-
const data = chatRing(serverId, channelId).slice();
|
|
143
|
-
for (const connection of targets) {
|
|
144
|
-
send(connection, { v: 1, t: "update", channel: "chat", serverId, action: channelId, data });
|
|
145
|
-
}
|
|
146
|
-
};
|
|
143
|
+
const chatRing = (serverId, channelId) => getOrCreate(chatRings, `${serverId}|${channelId}`, () => []);
|
|
144
|
+
const broadcastChat = (serverId, channelId) => broadcast(subscriptionKey("chat", serverId, channelId), () => ({
|
|
145
|
+
v: 1,
|
|
146
|
+
t: "update",
|
|
147
|
+
channel: "chat",
|
|
148
|
+
serverId,
|
|
149
|
+
action: channelId,
|
|
150
|
+
data: chatRing(serverId, channelId).slice(),
|
|
151
|
+
}));
|
|
147
152
|
const voiceRosters = new Map();
|
|
148
|
-
const voiceRoster = (serverId, channelId) => {
|
|
149
|
-
const key = `${serverId}|${channelId}`;
|
|
150
|
-
let roster = voiceRosters.get(key);
|
|
151
|
-
if (roster === undefined) {
|
|
152
|
-
roster = new Map();
|
|
153
|
-
voiceRosters.set(key, roster);
|
|
154
|
-
}
|
|
155
|
-
return roster;
|
|
156
|
-
};
|
|
153
|
+
const voiceRoster = (serverId, channelId) => getOrCreate(voiceRosters, `${serverId}|${channelId}`, () => new Map());
|
|
157
154
|
const voiceParticipants = (serverId, channelId) => [...voiceRoster(serverId, channelId).values()].map((participant) => ({ ...participant }));
|
|
158
|
-
const broadcastVoice = (serverId, channelId) => {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
}
|
|
167
|
-
};
|
|
155
|
+
const broadcastVoice = (serverId, channelId) => broadcast(subscriptionKey("voice", serverId, channelId), () => ({
|
|
156
|
+
v: 1,
|
|
157
|
+
t: "update",
|
|
158
|
+
channel: "voice",
|
|
159
|
+
serverId,
|
|
160
|
+
action: channelId,
|
|
161
|
+
data: voiceParticipants(serverId, channelId),
|
|
162
|
+
}));
|
|
168
163
|
const dropVoice = (connection) => {
|
|
169
164
|
if (connection.userId === null)
|
|
170
165
|
return;
|
|
171
|
-
|
|
172
|
-
if (!roster.delete(connection.userId))
|
|
173
|
-
continue;
|
|
174
|
-
if (roster.size === 0)
|
|
175
|
-
voiceRosters.delete(key);
|
|
166
|
+
dropUserFrom(voiceRosters, connection.userId, () => true, (key) => {
|
|
176
167
|
const [serverId, channelId] = key.split("|");
|
|
177
168
|
broadcastVoice(serverId, channelId);
|
|
178
|
-
}
|
|
169
|
+
});
|
|
179
170
|
};
|
|
180
171
|
const dropVoiceForServer = (userId, serverId) => {
|
|
181
172
|
const prefix = `${serverId}|`;
|
|
182
|
-
|
|
183
|
-
if (!key.startsWith(prefix))
|
|
184
|
-
continue;
|
|
185
|
-
if (!roster.delete(userId))
|
|
186
|
-
continue;
|
|
187
|
-
if (roster.size === 0)
|
|
188
|
-
voiceRosters.delete(key);
|
|
189
|
-
broadcastVoice(serverId, key.slice(prefix.length));
|
|
190
|
-
}
|
|
173
|
+
dropUserFrom(voiceRosters, userId, (key) => key.startsWith(prefix), (key) => broadcastVoice(serverId, key.slice(prefix.length)));
|
|
191
174
|
};
|
|
192
175
|
const requireMembership = async (connection, id, serverId) => {
|
|
193
176
|
const userId = connection.userId;
|
|
@@ -337,21 +320,10 @@ export function createHostRouter(options) {
|
|
|
337
320
|
const dropPresence = (connection) => {
|
|
338
321
|
if (connection.userId === null)
|
|
339
322
|
return;
|
|
340
|
-
|
|
341
|
-
if (rows.delete(connection.userId)) {
|
|
342
|
-
if (rows.size === 0)
|
|
343
|
-
presence.delete(serverId);
|
|
344
|
-
broadcastPresence(serverId);
|
|
345
|
-
}
|
|
346
|
-
}
|
|
323
|
+
dropUserFrom(presence, connection.userId, () => true, (serverId) => broadcastPresence(serverId));
|
|
347
324
|
};
|
|
348
325
|
const dropPresenceForServer = (userId, serverId) => {
|
|
349
|
-
|
|
350
|
-
if (rows === undefined || !rows.delete(userId))
|
|
351
|
-
return;
|
|
352
|
-
if (rows.size === 0)
|
|
353
|
-
presence.delete(serverId);
|
|
354
|
-
broadcastPresence(serverId);
|
|
326
|
+
dropUserFrom(presence, userId, (key) => key === serverId, () => broadcastPresence(serverId));
|
|
355
327
|
};
|
|
356
328
|
const handleMessage = async (connection, message) => {
|
|
357
329
|
if (message.t === "hello") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jgengine/ws",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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.13.0"
|
|
35
35
|
},
|
|
36
36
|
"publishConfig": {
|
|
37
37
|
"access": "public"
|
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,15 +36,17 @@ 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
|
|
|
41
43
|
Within each selected domain:
|
|
42
44
|
|
|
45
|
+
0. Fastest start for a common composition: `npx jgengine recipe <name>` prints a vetted, SDK-typechecked snippet (imports + wired code) for `combat-loop`, `boss-telegraph`, `loot`, `quest`, `coop-presence`, `third-person-camera`; `npx jgengine recipe` lists them. Copy that instead of reconning per-file.
|
|
43
46
|
1. Search `capabilities.md` by intent; it maps needs to imports.
|
|
44
47
|
2. Open `api.md` only when exact signatures or export inventory are needed.
|
|
45
48
|
3. Open the linked reference only for a deeper recipe or trap.
|
|
49
|
+
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
50
|
|
|
47
51
|
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
52
|
|