@jgengine/core 0.16.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.
@@ -37,6 +37,12 @@ export type HydrateInput = {
37
37
  export type GameRuntime = {
38
38
  gameId: string;
39
39
  save: SaveConfig;
40
+ /**
41
+ * Whether this runtime declares `loop.onTick`. A host's tick cron reads it to skip hydrating and
42
+ * persisting a server whose `tick` is a no-op by construction — the difference between a world that
43
+ * costs a full read/write every second while idle and one that costs nothing.
44
+ */
45
+ hasTick: boolean;
40
46
  hydrate: (input: HydrateInput) => GameRuntimeSnapshot;
41
47
  runCommand: (snapshot: GameRuntimeSnapshot, actorUserId: string, commandName: string, input: unknown) => ReturnType<typeof runCommand>;
42
48
  tick: (snapshot: GameRuntimeSnapshot, dtSeconds: number) => GameRuntimeSnapshot;
@@ -7,6 +7,7 @@ export function createGameRuntime(definition) {
7
7
  return {
8
8
  gameId: definition.gameId,
9
9
  save: definition.save,
10
+ hasTick: loop?.onTick !== undefined,
10
11
  hydrate(input) {
11
12
  let current = createRuntimeSnapshot({
12
13
  gameId: input.gameId,
@@ -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);
@@ -149,14 +177,22 @@ export function planServerPersist(server, snapshot, save, now) {
149
177
  : entry);
150
178
  const canWritePlayers = isSaveEnabled(save) && saveScopeIncludesPlayer(save.scope);
151
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.
152
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
+ }
153
188
  const profiles = [];
154
189
  for (const userId of Object.keys(snapshot.players)) {
155
190
  const player = snapshot.players[userId];
156
191
  if (!player)
157
192
  continue;
158
193
  const { persistent, session } = splitProfilePlayer(player);
159
- sessionPlayers[userId] = { ...persistent, session };
194
+ if (roster.has(userId))
195
+ sessionPlayers[userId] = { ...persistent, session };
160
196
  if (canWritePlayers && snapshot.dirty.players.includes(userId)) {
161
197
  profiles.push({
162
198
  userId,
@@ -184,6 +220,9 @@ export function planServerPersist(server, snapshot, save, now) {
184
220
  // anything else would make shouldAutoSave re-fire forever after a successful full flush.
185
221
  const unwritten = (snapshot.dirty.players.length > 0 && !canWritePlayers) ||
186
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;
187
226
  return {
188
227
  server: {
189
228
  ...server,
@@ -198,5 +237,18 @@ export function planServerPersist(server, snapshot, save, now) {
198
237
  chunks,
199
238
  deletedChunks,
200
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
+ },
201
253
  };
202
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,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
  });
@@ -17,6 +17,12 @@ export interface MoveTowardOptions {
17
17
  speed: number;
18
18
  stopDistance?: number;
19
19
  dt: number;
20
+ /**
21
+ * Slide the step against the solid geometry that blocks the player instead of lerping through it
22
+ * (default `true` wherever the runtime wires a resolver). Set `false` for a mover that is meant to
23
+ * ignore the world — a flier, a camera rig, a decorative drift.
24
+ */
25
+ avoidSolids?: boolean;
20
26
  }
21
27
  export interface SpatialGridOptions {
22
28
  cellSize: number;
@@ -37,6 +43,16 @@ export interface SpatialApiOptions {
37
43
  grid?: SpatialGridOptions | false;
38
44
  /** When this number changes between queries, the grid rebuilds without an explicit `invalidate()`. */
39
45
  getVersion?: () => number;
46
+ /**
47
+ * Slide a horizontal step against the scene's solid geometry, returning the step actually allowed.
48
+ * Wired by the runtime to the same blocking-collider query the player resolver reads, so a chaser
49
+ * calling `moveToward` stops at the wall the player stops at. Omitted (tests, bare indexes) leaves
50
+ * `moveToward` a pure lerp.
51
+ */
52
+ resolveStep?: (position: EntityPosition, stepX: number, stepZ: number) => {
53
+ stepX: number;
54
+ stepZ: number;
55
+ };
40
56
  }
41
57
  export interface SpatialApi {
42
58
  distance(aInstanceId: string, bInstanceId: string): number | null;
@@ -23,7 +23,7 @@ function packCell(cx, cz) {
23
23
  }
24
24
  const DEFAULT_CELL_SIZE = 8;
25
25
  export function createSpatialApi(options) {
26
- const { resolvePosition, candidates, occluder, getVersion } = options;
26
+ const { resolvePosition, candidates, occluder, getVersion, resolveStep } = options;
27
27
  const gridConfig = options.grid === false ? undefined : (options.grid ?? { cellSize: DEFAULT_CELL_SIZE });
28
28
  const cellSize = gridConfig?.cellSize;
29
29
  let gridIndex = null;
@@ -198,7 +198,7 @@ export function createSpatialApi(options) {
198
198
  invalidate() {
199
199
  gridDirty = true;
200
200
  },
201
- moveToward(instanceId, target, { speed, stopDistance = 0, dt }) {
201
+ moveToward(instanceId, target, { speed, stopDistance = 0, dt, avoidSolids = true }) {
202
202
  const current = resolvePosition(instanceId);
203
203
  const destination = resolveTarget(target);
204
204
  if (current === undefined || destination === undefined)
@@ -209,10 +209,13 @@ export function createSpatialApi(options) {
209
209
  return current;
210
210
  const step = Math.min(speed * dt, remaining);
211
211
  const scale = step / total;
212
+ const stepX = (destination[0] - current[0]) * scale;
213
+ const stepZ = (destination[2] - current[2]) * scale;
214
+ const slid = avoidSolids && resolveStep !== undefined ? resolveStep(current, stepX, stepZ) : { stepX, stepZ };
212
215
  return [
213
- current[0] + (destination[0] - current[0]) * scale,
216
+ current[0] + slid.stepX,
214
217
  current[1] + (destination[1] - current[1]) * scale,
215
- current[2] + (destination[2] - current[2]) * scale,
218
+ current[2] + slid.stepZ,
216
219
  ];
217
220
  },
218
221
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/core",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "jgengine core — pure-TypeScript game framework SDK (npm @jgengine/core). Entity stores, commands, combat, inventory, multiplayer seams. Zero dependencies. Not an ECS. Agent skills ship in the tarball. https://jgengine.com",
5
5
  "keywords": [
6
6
  "jgengine",