@jgengine/core 0.15.0 → 0.17.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/dist/game/playableGame.d.ts +36 -0
  3. package/dist/meta/changelog.d.ts +1 -1
  4. package/dist/meta/changelog.js +49 -1
  5. package/dist/movement/playerMovement.js +6 -172
  6. package/dist/movement/solidObstacles.d.ts +87 -0
  7. package/dist/movement/solidObstacles.js +208 -0
  8. package/dist/multiplayer.d.ts +1 -0
  9. package/dist/multiplayer.js +1 -0
  10. package/dist/runtime/context/objectSlots.d.ts +60 -0
  11. package/dist/runtime/context/objectSlots.js +103 -0
  12. package/dist/runtime/context/player.d.ts +3 -1
  13. package/dist/runtime/context/player.js +1 -0
  14. package/dist/runtime/context/scene.d.ts +9 -0
  15. package/dist/runtime/context/scene.js +25 -0
  16. package/dist/runtime/descriptors/baseline.d.ts +3 -1
  17. package/dist/runtime/descriptors/baseline.js +11 -0
  18. package/dist/runtime/gameContext.js +10 -1
  19. package/dist/runtime/gameContextTypes.d.ts +13 -0
  20. package/dist/runtime/gameRuntime.d.ts +6 -0
  21. package/dist/runtime/gameRuntime.js +1 -0
  22. package/dist/runtime/hostPersistence.d.ts +27 -2
  23. package/dist/runtime/hostPersistence.js +68 -12
  24. package/dist/runtime/hostPolicy.d.ts +54 -0
  25. package/dist/runtime/hostPolicy.js +53 -0
  26. package/dist/runtime/objectRows.d.ts +17 -0
  27. package/dist/runtime/objectRows.js +35 -0
  28. package/dist/runtime/snapshot.d.ts +15 -0
  29. package/dist/runtime/snapshot.js +12 -0
  30. package/dist/runtime/worldChunks.d.ts +39 -0
  31. package/dist/runtime/worldChunks.js +83 -0
  32. package/dist/runtime/worldProjection.d.ts +16 -2
  33. package/dist/runtime/worldProjection.js +20 -0
  34. package/dist/scene/behaviorRuntime.js +25 -3
  35. package/dist/scene/objectStore.d.ts +26 -0
  36. package/dist/scene/objectStore.js +51 -9
  37. package/dist/scene/spatial.d.ts +16 -0
  38. package/dist/scene/spatial.js +7 -4
  39. package/dist/world/buildingKit.d.ts +110 -0
  40. package/dist/world/buildingKit.js +93 -0
  41. package/dist/world/buildings.d.ts +25 -3
  42. package/dist/world/buildings.js +39 -5
  43. package/dist/world/cityKind.d.ts +6 -0
  44. package/dist/world/cityKind.js +3 -0
  45. package/dist/world/environmentSummary.js +13 -3
  46. package/dist/world/features.d.ts +7 -1
  47. package/dist/world/features.js +1 -0
  48. package/dist/world.d.ts +1 -0
  49. package/dist/world.js +1 -0
  50. package/package.json +1 -1
  51. package/skills/jgengine/api.md +38 -5
  52. package/skills/jgengine/capabilities.md +24 -0
  53. package/skills/jgengine-gameplay/api.md +2 -1
  54. package/skills/jgengine-world/SKILL.md +5 -0
  55. package/skills/jgengine-world/api.md +27 -2
  56. package/skills/jgengine-world/capabilities.md +24 -0
  57. package/skills/jgengine-world/recipes/building-kit.md +78 -0
@@ -151,14 +151,39 @@ export type HostPersistence = {
151
151
  userId: string;
152
152
  }) => Promise<Record<string, number>>;
153
153
  };
154
- /** @internal */
155
- export declare function buildHydratePlayers(server: GameServerRecord, profiles: Record<string, PlayerProfileRecord | null>): Record<string, RuntimePlayerRow>;
154
+ /**
155
+ * Hydrate the runtime's player map from the server row plus whatever profiles were loaded.
156
+ *
157
+ * `userIds` narrows the work to a subset of the roster (partial hydration); omit it for the whole
158
+ * roster. A member with a loaded profile takes it as authoritative and keeps its session blob from
159
+ * the server row.
160
+ *
161
+ * A member with no profile row resolves by save scope, not by falling back to the blob: when the game
162
+ * persists players, a missing profile means the profile was deleted, so the member hydrates empty and
163
+ * the deletion sticks. Only a game that never writes profiles (`save: "none"`, or a scope without
164
+ * `player`) treats the server row's session copy as the player's state.
165
+ * @internal
166
+ */
167
+ export declare function buildHydratePlayers(server: GameServerRecord, profiles: Record<string, PlayerProfileRecord | null>, userIds?: readonly string[]): Record<string, RuntimePlayerRow>;
168
+ /** True when a snapshot carries no unwritten mutation — nothing for a persist to do. */
169
+ export declare function isSnapshotClean(snapshot: GameRuntimeSnapshot): boolean;
156
170
  export type ServerPersistPlan = {
157
171
  server: GameServerRecord;
158
172
  profiles: PlayerProfileRecord[];
159
173
  chunks: WorldChunkRecord[];
160
174
  deletedChunks: string[];
161
175
  leaderboard: LeaderboardIncrement[];
176
+ /**
177
+ * Which fields of the server row the plan actually moved. A store that pays per written field —
178
+ * or whose subscribers re-push a whole document on any change — writes only what changed and skips
179
+ * the row entirely when `changed.any` is false.
180
+ */
181
+ changed: {
182
+ serverState: boolean;
183
+ sessionPlayers: boolean;
184
+ revision: boolean;
185
+ any: boolean;
186
+ };
162
187
  };
163
188
  /** @internal */
164
189
  export declare function planServerPersist(server: GameServerRecord, snapshot: GameRuntimeSnapshot, save: SaveConfig, now: number): ServerPersistPlan;
@@ -119,27 +119,55 @@ export function toOpenServerListings(listings, limit = OPEN_SERVER_LISTING_LIMIT
119
119
  .sort((a, b) => b.updatedAt - a.updatedAt)
120
120
  .slice(0, clampLimit(limit, OPEN_SERVER_LISTING_LIMIT, OPEN_SERVER_LISTING_MAX));
121
121
  }
122
- /** @internal */
123
- export function buildHydratePlayers(server, profiles) {
122
+ /**
123
+ * Hydrate the runtime's player map from the server row plus whatever profiles were loaded.
124
+ *
125
+ * `userIds` narrows the work to a subset of the roster (partial hydration); omit it for the whole
126
+ * roster. A member with a loaded profile takes it as authoritative and keeps its session blob from
127
+ * the server row.
128
+ *
129
+ * A member with no profile row resolves by save scope, not by falling back to the blob: when the game
130
+ * persists players, a missing profile means the profile was deleted, so the member hydrates empty and
131
+ * the deletion sticks. Only a game that never writes profiles (`save: "none"`, or a scope without
132
+ * `player`) treats the server row's session copy as the player's state.
133
+ * @internal
134
+ */
135
+ export function buildHydratePlayers(server, profiles, userIds) {
136
+ const persistsPlayers = isSaveEnabled(server.save) && saveScopeIncludesPlayer(server.save.scope);
124
137
  const players = {};
125
- for (const userId of server.memberUserIds) {
138
+ for (const userId of userIds ?? server.memberUserIds) {
126
139
  const profile = profiles[userId] ?? null;
127
140
  const sessionPlayer = server.sessionPlayers[userId];
141
+ const session = sessionPlayer?.session;
128
142
  if (profile) {
129
143
  players[userId] = {
130
144
  ...profile.playerState,
131
- ...(sessionPlayer?.session ? { session: sessionPlayer.session } : {}),
145
+ ...(session ? { session } : {}),
132
146
  };
133
147
  }
134
- else if (sessionPlayer) {
148
+ else if (sessionPlayer && !persistsPlayers) {
135
149
  players[userId] = sessionPlayer;
136
150
  }
137
151
  else {
138
- players[userId] = createEmptyPlayerRow(userId);
152
+ players[userId] = {
153
+ ...createEmptyPlayerRow(userId),
154
+ ...(session ? { session } : {}),
155
+ };
139
156
  }
140
157
  }
141
158
  return players;
142
159
  }
160
+ /** True when a snapshot carries no unwritten mutation — nothing for a persist to do. */
161
+ export function isSnapshotClean(snapshot) {
162
+ return (!snapshot.dirty.server &&
163
+ snapshot.dirty.players.length === 0 &&
164
+ snapshot.dirty.chunks.length === 0);
165
+ }
166
+ function sameJson(a, b) {
167
+ if (a === b)
168
+ return true;
169
+ return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
170
+ }
143
171
  /** @internal */
144
172
  export function planServerPersist(server, snapshot, save, now) {
145
173
  const drained = drainPendingLeaderboardIncrements(snapshot.server.session);
@@ -147,17 +175,25 @@ export function planServerPersist(server, snapshot, save, now) {
147
175
  const leaderboard = drained.increments.map((entry) => entry.scope === "server" && entry.serverId === undefined
148
176
  ? { ...entry, serverId: server.serverId }
149
177
  : entry);
178
+ const canWritePlayers = isSaveEnabled(save) && saveScopeIncludesPlayer(save.scope);
179
+ const canWriteChunks = isSaveEnabled(save) && saveScopeIncludesChunks(save.scope);
180
+ // Merged over the stored map rather than rebuilt from `snapshot.players`, so a partially hydrated
181
+ // snapshot cannot drop the session state of members it never loaded.
150
182
  const sessionPlayers = {};
183
+ const roster = new Set(server.memberUserIds);
184
+ for (const [userId, stored] of Object.entries(server.sessionPlayers ?? {})) {
185
+ if (roster.has(userId))
186
+ sessionPlayers[userId] = stored;
187
+ }
151
188
  const profiles = [];
152
189
  for (const userId of Object.keys(snapshot.players)) {
153
190
  const player = snapshot.players[userId];
154
191
  if (!player)
155
192
  continue;
156
193
  const { persistent, session } = splitProfilePlayer(player);
157
- sessionPlayers[userId] = { ...persistent, session };
158
- if (isSaveEnabled(save) &&
159
- saveScopeIncludesPlayer(save.scope) &&
160
- snapshot.dirty.players.includes(userId)) {
194
+ if (roster.has(userId))
195
+ sessionPlayers[userId] = { ...persistent, session };
196
+ if (canWritePlayers && snapshot.dirty.players.includes(userId)) {
161
197
  profiles.push({
162
198
  userId,
163
199
  gameId: server.gameId,
@@ -169,7 +205,7 @@ export function planServerPersist(server, snapshot, save, now) {
169
205
  }
170
206
  const chunks = [];
171
207
  const deletedChunks = [];
172
- if (isSaveEnabled(save) && saveScopeIncludesChunks(save.scope)) {
208
+ if (canWriteChunks) {
173
209
  for (const chunkKey of snapshot.dirty.chunks) {
174
210
  const chunk = snapshot.chunks[chunkKey];
175
211
  if (chunk) {
@@ -180,13 +216,20 @@ export function planServerPersist(server, snapshot, save, now) {
180
216
  }
181
217
  }
182
218
  }
219
+ // Server state is written unconditionally, so only unwritable player/chunk dirt keeps the row dirty;
220
+ // anything else would make shouldAutoSave re-fire forever after a successful full flush.
221
+ const unwritten = (snapshot.dirty.players.length > 0 && !canWritePlayers) ||
222
+ (snapshot.dirty.chunks.length > 0 && !canWriteChunks);
223
+ const serverStateChanged = !sameJson(serverState, server.serverState);
224
+ const sessionPlayersChanged = !sameJson(sessionPlayers, server.sessionPlayers);
225
+ const revisionChanged = snapshot.revision !== server.revision;
183
226
  return {
184
227
  server: {
185
228
  ...server,
186
229
  serverState,
187
230
  sessionPlayers,
188
231
  revision: snapshot.revision,
189
- dirtyAt: snapshot.dirty.server || snapshot.dirty.players.length > 0 ? now : server.dirtyAt,
232
+ dirtyAt: unwritten ? (server.dirtyAt ?? now) : undefined,
190
233
  updatedAt: now,
191
234
  lastSavedAt: now,
192
235
  },
@@ -194,5 +237,18 @@ export function planServerPersist(server, snapshot, save, now) {
194
237
  chunks,
195
238
  deletedChunks,
196
239
  leaderboard,
240
+ changed: {
241
+ serverState: serverStateChanged,
242
+ sessionPlayers: sessionPlayersChanged,
243
+ revision: revisionChanged,
244
+ any: serverStateChanged ||
245
+ sessionPlayersChanged ||
246
+ revisionChanged ||
247
+ profiles.length > 0 ||
248
+ chunks.length > 0 ||
249
+ deletedChunks.length > 0 ||
250
+ leaderboard.length > 0 ||
251
+ (server.dirtyAt !== undefined) !== unwritten,
252
+ },
197
253
  };
198
254
  }
@@ -1,4 +1,58 @@
1
1
  import type { GameServerStatus, SessionVisibility } from "./hostPersistence.js";
2
+ /**
3
+ * Hard ceiling on `slotsPerServer` for a single hosted server row.
4
+ *
5
+ * A hosted server keeps its whole roster, every member's session state, and the world row in one
6
+ * backing document. On Convex that document caps at 1 MiB with at most 1024 entries in an object, and
7
+ * the host rewrites the session map on every persist — so member count is a per-transaction cost, not
8
+ * just a number in a config. 256 leaves room for a real world row and a real session blob per member
9
+ * inside those limits. A world that needs more concurrent players needs more than one server row.
10
+ */
11
+ export declare const JG_MAX_MEMBERS_PER_SERVER = 256;
12
+ /** Whether a `slotsPerServer` value is a capacity a single hosted server row can actually hold. */
13
+ export declare function validateSlotsPerServer(slotsPerServer: number): {
14
+ ok: true;
15
+ } | {
16
+ ok: false;
17
+ reason: string;
18
+ };
19
+ /**
20
+ * How auto-match behaves when no `serverId` is supplied.
21
+ *
22
+ * - `"auto"` — fill open public rooms, create a new one when none has capacity (many parallel rooms).
23
+ * - `"singleton"` — one shared world per game: always match the existing room, and refuse the join
24
+ * when it is full rather than silently starting a second world the player would be alone in.
25
+ */
26
+ export type MatchmakingMode = "auto" | "singleton";
27
+ /** A row `selectJoinTarget` can choose between — the matchmaking-relevant fields of a server record. */
28
+ export type JoinCandidate = {
29
+ memberUserIds: readonly string[];
30
+ slotsPerServer: number;
31
+ visibility?: SessionVisibility | undefined;
32
+ createdAt: number;
33
+ };
34
+ /** What a join should do with the candidate rows it found. */
35
+ export type JoinTarget<T extends JoinCandidate> = {
36
+ kind: "join";
37
+ row: T;
38
+ } | {
39
+ kind: "create";
40
+ } | {
41
+ kind: "refuse";
42
+ reason: string;
43
+ };
44
+ /**
45
+ * Resolve an auto-match join against the game's joinable rows under a {@link MatchmakingMode}.
46
+ * Reach for it instead of hand-rolling the "find a room with space, else make one" scan, which is
47
+ * where a singleton world silently shards into per-player copies.
48
+ * @capability host-join-target choose or refuse an auto-matched server under a matchmaking mode
49
+ */
50
+ export declare function selectJoinTarget<T extends JoinCandidate>(rows: readonly T[], args: {
51
+ userId: string;
52
+ mode?: MatchmakingMode;
53
+ /** Capacity to judge rows against — the host's current option, when it supersedes the stored value. */
54
+ slotsPerServer?: number;
55
+ }): JoinTarget<T>;
2
56
  /**
3
57
  * True when a server's `visibility` should surface in public listings / browse results.
4
58
  * @capability host-listing-filter exclude private sessions from public browse results
@@ -1,4 +1,57 @@
1
1
  import { normalizeJoinCode } from "../multiplayer/matchmaking.js";
2
+ /**
3
+ * Hard ceiling on `slotsPerServer` for a single hosted server row.
4
+ *
5
+ * A hosted server keeps its whole roster, every member's session state, and the world row in one
6
+ * backing document. On Convex that document caps at 1 MiB with at most 1024 entries in an object, and
7
+ * the host rewrites the session map on every persist — so member count is a per-transaction cost, not
8
+ * just a number in a config. 256 leaves room for a real world row and a real session blob per member
9
+ * inside those limits. A world that needs more concurrent players needs more than one server row.
10
+ */
11
+ export const JG_MAX_MEMBERS_PER_SERVER = 256;
12
+ /** Whether a `slotsPerServer` value is a capacity a single hosted server row can actually hold. */
13
+ export function validateSlotsPerServer(slotsPerServer) {
14
+ if (!Number.isInteger(slotsPerServer) || slotsPerServer < 1) {
15
+ return { ok: false, reason: `slotsPerServer must be a positive integer, got ${slotsPerServer}` };
16
+ }
17
+ if (slotsPerServer > JG_MAX_MEMBERS_PER_SERVER) {
18
+ return {
19
+ ok: false,
20
+ reason: `slotsPerServer ${slotsPerServer} exceeds JG_MAX_MEMBERS_PER_SERVER (${JG_MAX_MEMBERS_PER_SERVER}). ` +
21
+ "One hosted server row holds its roster, every member's session state, and the world row in a " +
22
+ "single document; past this the host cannot guarantee the write fits. Shard across servers instead.",
23
+ };
24
+ }
25
+ return { ok: true };
26
+ }
27
+ /**
28
+ * Resolve an auto-match join against the game's joinable rows under a {@link MatchmakingMode}.
29
+ * Reach for it instead of hand-rolling the "find a room with space, else make one" scan, which is
30
+ * where a singleton world silently shards into per-player copies.
31
+ * @capability host-join-target choose or refuse an auto-matched server under a matchmaking mode
32
+ */
33
+ export function selectJoinTarget(rows, args) {
34
+ const capacityOf = (row) => args.slotsPerServer ?? row.slotsPerServer;
35
+ const member = rows.find((row) => isServerMember(row.memberUserIds, args.userId));
36
+ if (member)
37
+ return { kind: "join", row: member };
38
+ if ((args.mode ?? "auto") === "singleton") {
39
+ const oldest = rows.reduce((best, row) => (best === null || row.createdAt < best.createdAt ? row : best), null);
40
+ if (oldest === null)
41
+ return { kind: "create" };
42
+ if (oldest.memberUserIds.length >= capacityOf(oldest)) {
43
+ return { kind: "refuse", reason: "Server is full" };
44
+ }
45
+ return { kind: "join", row: oldest };
46
+ }
47
+ const open = rows.find((row) => isAutoJoinCandidate({
48
+ memberUserIds: row.memberUserIds,
49
+ slotsPerServer: capacityOf(row),
50
+ visibility: row.visibility,
51
+ userId: args.userId,
52
+ }));
53
+ return open === undefined ? { kind: "create" } : { kind: "join", row: open };
54
+ }
2
55
  /**
3
56
  * True when a server's `visibility` should surface in public listings / browse results.
4
57
  * @capability host-listing-filter exclude private sessions from public browse results
@@ -0,0 +1,17 @@
1
+ import type { SceneObject } from "../scene/objectStore.js";
2
+ import type { RuntimeObjectRow } from "./snapshot.js";
3
+ /**
4
+ * Convert a live placed object into its persisted row — the bridge between `ctx.scene.object` and
5
+ * `GameRuntimeSnapshot.server.objects`/`chunks[].objects`. Per-instance `state` is carried as
6
+ * `flags`. Presentation overrides (`visual`, `animation`) are catalog- and authoring-resolved and
7
+ * deliberately stay off the persisted row — put anything a command must own in `state`.
8
+ *
9
+ * @capability placed-object-rows a placed object's per-instance state and container contents convert to and from the persisted snapshot row a host stores
10
+ */
11
+ export declare function toRuntimeObjectRow(object: SceneObject): RuntimeObjectRow;
12
+ /**
13
+ * Inverse of {@link toRuntimeObjectRow}: rebuild the live placed object a host persisted.
14
+ *
15
+ * @capability placed-object-rehydrate rebuild live placed objects, with their state and slot contents, from persisted snapshot rows
16
+ */
17
+ export declare function fromRuntimeObjectRow(row: RuntimeObjectRow): SceneObject;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Convert a live placed object into its persisted row — the bridge between `ctx.scene.object` and
3
+ * `GameRuntimeSnapshot.server.objects`/`chunks[].objects`. Per-instance `state` is carried as
4
+ * `flags`. Presentation overrides (`visual`, `animation`) are catalog- and authoring-resolved and
5
+ * deliberately stay off the persisted row — put anything a command must own in `state`.
6
+ *
7
+ * @capability placed-object-rows a placed object's per-instance state and container contents convert to and from the persisted snapshot row a host stores
8
+ */
9
+ export function toRuntimeObjectRow(object) {
10
+ return {
11
+ instanceId: object.instanceId,
12
+ catalogId: object.catalogId,
13
+ position: [object.position[0], object.position[1], object.position[2]],
14
+ rotationY: object.rotationY,
15
+ ...(object.parentSpace !== undefined ? { parentSpace: object.parentSpace } : {}),
16
+ ...(object.state !== undefined ? { flags: object.state } : {}),
17
+ ...(object.slots !== undefined ? { slots: object.slots } : {}),
18
+ };
19
+ }
20
+ /**
21
+ * Inverse of {@link toRuntimeObjectRow}: rebuild the live placed object a host persisted.
22
+ *
23
+ * @capability placed-object-rehydrate rebuild live placed objects, with their state and slot contents, from persisted snapshot rows
24
+ */
25
+ export function fromRuntimeObjectRow(row) {
26
+ return {
27
+ instanceId: row.instanceId,
28
+ catalogId: row.catalogId,
29
+ position: [row.position[0], row.position[1], row.position[2]],
30
+ rotationY: row.rotationY ?? 0,
31
+ ...(row.parentSpace !== undefined ? { parentSpace: row.parentSpace } : {}),
32
+ ...(row.flags !== undefined ? { state: row.flags } : {}),
33
+ ...(row.slots !== undefined ? { slots: row.slots } : {}),
34
+ };
35
+ }
@@ -1,3 +1,4 @@
1
+ import type { InventoryState } from "../inventory/inventoryModel.js";
1
2
  export declare const RUNTIME_SNAPSHOT_VERSION = 1;
2
3
  export type RuntimeEntityRow = {
3
4
  instanceId: string;
@@ -14,13 +15,20 @@ export type RuntimeEntityRow = {
14
15
  targetInstanceId?: string | null;
15
16
  userId?: string;
16
17
  };
18
+ /**
19
+ * The persisted form of a placed `SceneObject`: identity, placement, per-instance state, and slot
20
+ * contents. Convert with `toRuntimeObjectRow`/`fromRuntimeObjectRow` (`runtime/objectRows`).
21
+ */
17
22
  export type RuntimeObjectRow = {
18
23
  instanceId: string;
19
24
  catalogId: string;
20
25
  position: [number, number, number];
21
26
  rotationY?: number;
22
27
  parentSpace?: string;
28
+ /** Opaque per-instance state; the persisted name for `SceneObject.state`. */
23
29
  flags?: Record<string, unknown>;
30
+ /** Container contents for a catalog `slotInventory` object. */
31
+ slots?: InventoryState;
24
32
  };
25
33
  export type RuntimeInventorySlot = {
26
34
  item: string;
@@ -93,3 +101,10 @@ export declare function splitProfilePlayer(player: RuntimePlayerRow): {
93
101
  persistent: RuntimePlayerRow;
94
102
  session: Record<string, unknown>;
95
103
  };
104
+ /**
105
+ * Mark every player plus the server row dirty without bumping `revision` — the "write everything now"
106
+ * preparation a flush needs, because hydrating a snapshot from storage resets the dirty set to empty.
107
+ * Revision stays put so a flush cannot look like a mutation to the revision CAS.
108
+ * @internal
109
+ */
110
+ export declare function markAllPlayersDirty(snapshot: GameRuntimeSnapshot): GameRuntimeSnapshot;
@@ -80,3 +80,15 @@ export function splitProfilePlayer(player) {
80
80
  session,
81
81
  };
82
82
  }
83
+ /**
84
+ * Mark every player plus the server row dirty without bumping `revision` — the "write everything now"
85
+ * preparation a flush needs, because hydrating a snapshot from storage resets the dirty set to empty.
86
+ * Revision stays put so a flush cannot look like a mutation to the revision CAS.
87
+ * @internal
88
+ */
89
+ export function markAllPlayersDirty(snapshot) {
90
+ return {
91
+ ...snapshot,
92
+ dirty: { ...snapshot.dirty, server: true, players: Object.keys(snapshot.players) },
93
+ };
94
+ }
@@ -0,0 +1,39 @@
1
+ import type { GameRuntimeSnapshot, RuntimeChunkRow } from "./snapshot.js";
2
+ /** Default edge length, in world units, of one persisted world chunk. */
3
+ export declare const DEFAULT_CHUNK_SIZE = 64;
4
+ /** Integer cell coordinates of a chunk on the ground plane. */
5
+ export type ChunkCoord = {
6
+ cx: number;
7
+ cz: number;
8
+ };
9
+ /**
10
+ * Chunk key for a cell — the canonical `"cx,cz"` string a `jgWorldChunks` row is stored under.
11
+ * Games used to invent this mapping each time, which made two systems in one game disagree about
12
+ * which row a position belongs to.
13
+ * @capability world-chunk-key map world positions to persisted chunk keys at a declared cell size
14
+ */
15
+ export declare function chunkKeyOf(coord: ChunkCoord): string;
16
+ /** Chunk coordinates containing a world position at `size` units per cell. */
17
+ export declare function chunkCoordAt(position: readonly [number, number, number], size?: number): ChunkCoord;
18
+ /** Chunk key containing a world position at `size` units per cell. */
19
+ export declare function chunkKeyAt(position: readonly [number, number, number], size?: number): string;
20
+ /** Parse a `chunkKeyOf` string back to coordinates; `null` for anything not in that form. */
21
+ export declare function parseChunkKey(key: string): ChunkCoord | null;
22
+ /**
23
+ * Every chunk key whose cell intersects a radius around a world position — the key list a viewer's
24
+ * bounded hydration or client chunk query asks for, instead of loading a whole world.
25
+ */
26
+ export declare function chunkKeysInRadius(center: readonly [number, number, number], radius: number, size?: number): string[];
27
+ /** An empty chunk row for `chunkKey` — the starting value for a cell nothing has been placed in yet. */
28
+ export declare function createEmptyChunkRow(chunkKey: string): RuntimeChunkRow;
29
+ /**
30
+ * Write a chunk into a snapshot and mark it dirty, so the next persist actually stores it.
31
+ * The engine never writes `dirty.chunks` for a raw `snapshot.chunks[key] = …` assignment, so a command
32
+ * that mutated the map by hand silently never saved that chunk.
33
+ * @capability world-chunk-write mutate a snapshot's chunk and have the host persist it
34
+ */
35
+ export declare function setChunk(snapshot: GameRuntimeSnapshot, chunkKey: string, chunk: RuntimeChunkRow): GameRuntimeSnapshot;
36
+ /** Read-modify-write a chunk (creating it empty when absent), marking it dirty. */
37
+ export declare function updateChunk(snapshot: GameRuntimeSnapshot, chunkKey: string, update: (chunk: RuntimeChunkRow) => RuntimeChunkRow): GameRuntimeSnapshot;
38
+ /** Drop a chunk from a snapshot and mark the key dirty so the host deletes its stored row. */
39
+ export declare function deleteChunk(snapshot: GameRuntimeSnapshot, chunkKey: string): GameRuntimeSnapshot;
@@ -0,0 +1,83 @@
1
+ /** Default edge length, in world units, of one persisted world chunk. */
2
+ export const DEFAULT_CHUNK_SIZE = 64;
3
+ /**
4
+ * Chunk key for a cell — the canonical `"cx,cz"` string a `jgWorldChunks` row is stored under.
5
+ * Games used to invent this mapping each time, which made two systems in one game disagree about
6
+ * which row a position belongs to.
7
+ * @capability world-chunk-key map world positions to persisted chunk keys at a declared cell size
8
+ */
9
+ export function chunkKeyOf(coord) {
10
+ return `${coord.cx},${coord.cz}`;
11
+ }
12
+ /** Chunk coordinates containing a world position at `size` units per cell. */
13
+ export function chunkCoordAt(position, size = DEFAULT_CHUNK_SIZE) {
14
+ return { cx: Math.floor(position[0] / size), cz: Math.floor(position[2] / size) };
15
+ }
16
+ /** Chunk key containing a world position at `size` units per cell. */
17
+ export function chunkKeyAt(position, size = DEFAULT_CHUNK_SIZE) {
18
+ return chunkKeyOf(chunkCoordAt(position, size));
19
+ }
20
+ /** Parse a `chunkKeyOf` string back to coordinates; `null` for anything not in that form. */
21
+ export function parseChunkKey(key) {
22
+ const [rawX, rawZ, ...rest] = key.split(",");
23
+ if (rest.length > 0 || rawX === undefined || rawZ === undefined)
24
+ return null;
25
+ const cx = Number(rawX);
26
+ const cz = Number(rawZ);
27
+ if (!Number.isInteger(cx) || !Number.isInteger(cz))
28
+ return null;
29
+ return { cx, cz };
30
+ }
31
+ /**
32
+ * Every chunk key whose cell intersects a radius around a world position — the key list a viewer's
33
+ * bounded hydration or client chunk query asks for, instead of loading a whole world.
34
+ */
35
+ export function chunkKeysInRadius(center, radius, size = DEFAULT_CHUNK_SIZE) {
36
+ const min = chunkCoordAt([center[0] - radius, center[1], center[2] - radius], size);
37
+ const max = chunkCoordAt([center[0] + radius, center[1], center[2] + radius], size);
38
+ const keys = [];
39
+ for (let cx = min.cx; cx <= max.cx; cx += 1) {
40
+ for (let cz = min.cz; cz <= max.cz; cz += 1)
41
+ keys.push(chunkKeyOf({ cx, cz }));
42
+ }
43
+ return keys;
44
+ }
45
+ /** An empty chunk row for `chunkKey` — the starting value for a cell nothing has been placed in yet. */
46
+ export function createEmptyChunkRow(chunkKey) {
47
+ return { chunkKey, objects: [], entities: [] };
48
+ }
49
+ function withChunkDirty(snapshot, chunkKey) {
50
+ return snapshot.dirty.chunks.includes(chunkKey)
51
+ ? snapshot.dirty.chunks
52
+ : [...snapshot.dirty.chunks, chunkKey];
53
+ }
54
+ /**
55
+ * Write a chunk into a snapshot and mark it dirty, so the next persist actually stores it.
56
+ * The engine never writes `dirty.chunks` for a raw `snapshot.chunks[key] = …` assignment, so a command
57
+ * that mutated the map by hand silently never saved that chunk.
58
+ * @capability world-chunk-write mutate a snapshot's chunk and have the host persist it
59
+ */
60
+ export function setChunk(snapshot, chunkKey, chunk) {
61
+ return {
62
+ ...snapshot,
63
+ chunks: { ...snapshot.chunks, [chunkKey]: { ...chunk, chunkKey } },
64
+ revision: snapshot.revision + 1,
65
+ dirty: { ...snapshot.dirty, server: true, chunks: withChunkDirty(snapshot, chunkKey) },
66
+ };
67
+ }
68
+ /** Read-modify-write a chunk (creating it empty when absent), marking it dirty. */
69
+ export function updateChunk(snapshot, chunkKey, update) {
70
+ const current = snapshot.chunks[chunkKey] ?? createEmptyChunkRow(chunkKey);
71
+ return setChunk(snapshot, chunkKey, update(current));
72
+ }
73
+ /** Drop a chunk from a snapshot and mark the key dirty so the host deletes its stored row. */
74
+ export function deleteChunk(snapshot, chunkKey) {
75
+ const chunks = { ...snapshot.chunks };
76
+ delete chunks[chunkKey];
77
+ return {
78
+ ...snapshot,
79
+ chunks,
80
+ revision: snapshot.revision + 1,
81
+ dirty: { ...snapshot.dirty, server: true, chunks: withChunkDirty(snapshot, chunkKey) },
82
+ };
83
+ }
@@ -1,5 +1,5 @@
1
- import type { SceneEntity } from "../scene/entityStore.js";
2
- import type { SnapshotViewer } from "./worldSnapshot.js";
1
+ import type { EntityPosition, SceneEntity } from "../scene/entityStore.js";
2
+ import type { SnapshotViewer, WorldSnapshot } from "./worldSnapshot.js";
3
3
  /**
4
4
  * Host-side interest/privacy policy — how the authoritative world projects to each viewer over the
5
5
  * wire. Unset (the default) means every client receives the whole world, exactly as before. Enabling a
@@ -39,6 +39,20 @@ export declare function visibleEntityIds(entities: readonly SceneEntity[], viewe
39
39
  * @internal host projection plumbing
40
40
  */
41
41
  export declare function projectByVisibleIds<T>(byId: Record<string, T>, visible: Set<string>): Record<string, T>;
42
+ /** Where a viewer is standing, read off the replicated entity set; `undefined` when it has no entity yet.
43
+ * @internal host projection plumbing
44
+ */
45
+ export declare function viewerOrigin(world: WorldSnapshot, viewer: SnapshotViewer): EntityPosition | undefined;
46
+ /**
47
+ * Cull placed objects to a viewer's area of interest. Placed objects are the one large spatial set a
48
+ * world grows without bound — a datacenter of racks, a city of props — so without this every viewer
49
+ * downloads every placement. Fails open (full list) when the viewer has no locatable entity, matching
50
+ * {@link projectEntitiesForViewer}.
51
+ * @internal host projection plumbing
52
+ */
53
+ export declare function projectObjectsForViewer<T extends {
54
+ position: EntityPosition;
55
+ }>(objects: readonly T[], origin: EntityPosition | undefined, radius: number): readonly T[];
42
56
  /**
43
57
  * Narrow a `userId → state` record to only the viewer's own entry — the projection for private
44
58
  * per-user state (inventory, wallets) so one client never receives another player's private data.
@@ -41,6 +41,26 @@ export function projectByVisibleIds(byId, visible) {
41
41
  out[id] = value;
42
42
  return out;
43
43
  }
44
+ /** Where a viewer is standing, read off the replicated entity set; `undefined` when it has no entity yet.
45
+ * @internal host projection plumbing
46
+ */
47
+ export function viewerOrigin(world, viewer) {
48
+ const entities = (world["entities"] ?? []);
49
+ return entities.find((entity) => entity.id === viewer.userId)?.position;
50
+ }
51
+ /**
52
+ * Cull placed objects to a viewer's area of interest. Placed objects are the one large spatial set a
53
+ * world grows without bound — a datacenter of racks, a city of props — so without this every viewer
54
+ * downloads every placement. Fails open (full list) when the viewer has no locatable entity, matching
55
+ * {@link projectEntitiesForViewer}.
56
+ * @internal host projection plumbing
57
+ */
58
+ export function projectObjectsForViewer(objects, origin, radius) {
59
+ if (origin === undefined)
60
+ return objects;
61
+ const radiusSquared = radius * radius;
62
+ return objects.filter((object) => distanceSquared(object.position, origin) <= radiusSquared);
63
+ }
44
64
  /**
45
65
  * Narrow a `userId → state` record to only the viewer's own entry — the projection for private
46
66
  * per-user state (inventory, wallets) so one client never receives another player's private data.
@@ -1,8 +1,11 @@
1
1
  import { seededRng } from "../random/rng.js";
2
2
  import { advancePathFollow, createPathFollow, pathFollowSeek, } from "../nav/pathFollow.js";
3
+ import { resolveWalkerStep } from "../movement/solidObstacles.js";
3
4
  import { perContext } from "../runtime/perContext.js";
4
5
  const DEFAULT_WANDER_SPEED = 1.5;
5
6
  const WANDER_ARRIVAL = 0.6;
7
+ /** Below this share of its intended step a wanderer counts as blocked and re-rolls its target. */
8
+ const WANDER_BLOCKED_FRACTION = 0.25;
6
9
  function patrolOf(entity) {
7
10
  return entity.behaviors.find((b) => b.kind === "patrol") ?? null;
8
11
  }
@@ -72,18 +75,37 @@ function stepWander(ctx, id, nav, dt) {
72
75
  if (dist < 1e-6)
73
76
  return;
74
77
  const step = Math.min(dist, speed * dt);
75
- const nx = px + (dx / dist) * step;
76
- const nz = pz + (dz / dist) * step;
78
+ const desired = resolveWalkerStep(ctx, entity.position, (dx / dist) * step, (dz / dist) * step);
79
+ const nx = px + desired.stepX;
80
+ const nz = pz + desired.stepZ;
81
+ // A wanderer pinned against a solid picks a fresh target next tick instead of grinding into it.
82
+ if (Math.hypot(desired.stepX, desired.stepZ) < step * WANDER_BLOCKED_FRACTION)
83
+ nav.target = null;
77
84
  ctx.scene.entity.setPose(id, {
78
85
  position: [nx, ctx.world.groundHeightAt(nx, nz), nz],
79
86
  rotationY: Math.atan2(dx, dz),
80
87
  dt,
81
88
  });
82
89
  }
90
+ /**
91
+ * Pose a patroller at its path point, with the *move* to that point slid against solids. The path state
92
+ * still advances on its own clock — the route is authored, and rewinding it on contact would stall the
93
+ * whole patrol behind one prop — but the entity can no longer end a tick inside geometry the player
94
+ * cannot walk through. A route drawn through a building shows up as an NPC pressed against its wall,
95
+ * which is the authoring bug made visible rather than hidden.
96
+ */
83
97
  function posePatrol(ctx, id, nav, dt) {
84
98
  const [x, y, z] = nav.state.position;
99
+ const entity = ctx.scene.entity.get(id);
100
+ let px = x;
101
+ let pz = z;
102
+ if (entity !== null) {
103
+ const step = resolveWalkerStep(ctx, entity.position, x - entity.position[0], z - entity.position[2]);
104
+ px = entity.position[0] + step.stepX;
105
+ pz = entity.position[2] + step.stepZ;
106
+ }
85
107
  ctx.scene.entity.setPose(id, {
86
- position: nav.groundClamp ? [x, ctx.world.groundHeightAt(x, z), z] : [x, y, z],
108
+ position: nav.groundClamp ? [px, ctx.world.groundHeightAt(px, pz), pz] : [px, y, pz],
87
109
  rotationY: nav.state.heading,
88
110
  dt,
89
111
  });