@jgengine/core 0.16.0 → 0.18.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.
@@ -1,10 +1,46 @@
1
1
  export type CommandValidationError = {
2
2
  reason: string;
3
3
  };
4
+ /**
5
+ * Which slice of a server has to be hydrated before some work runs against it. Both fields default to
6
+ * "everything", which costs a read per member plus one per chunk — the cost that makes a large shared
7
+ * world unaffordable per mutation. Narrow them when the caller knows what it will touch.
8
+ */
9
+ export type CommandScope = {
10
+ /** Member ids to hydrate; omit for the whole roster. */
11
+ players?: readonly string[];
12
+ /** Chunk keys to hydrate; omit for every chunk of the server, `[]` for none. */
13
+ chunkKeys?: readonly string[];
14
+ };
4
15
  export type CommandDef<TInput = unknown> = {
16
+ /**
17
+ * What this command reads and writes, derived from its own input. A host that hydrates through a
18
+ * scope loads only this slice instead of the whole world, and refuses the command if `apply` then
19
+ * dirties a player or chunk the scope did not name — writing an unhydrated chunk would overwrite
20
+ * the stored one with a blank. Omit it to keep the whole-world default.
21
+ */
22
+ scope?: (input: TInput, actorUserId: string) => CommandScope;
5
23
  validate: (snapshot: import("./snapshot.js").GameRuntimeSnapshot, input: TInput, actorUserId: string) => CommandValidationError | null;
6
24
  apply: (snapshot: import("./snapshot.js").GameRuntimeSnapshot, input: TInput, actorUserId: string) => import("./snapshot.js").GameRuntimeSnapshot;
7
25
  };
26
+ /**
27
+ * The scope a command declares for this input, or `undefined` when it declares none (hydrate everything).
28
+ * Evaluate it before hydration: the actor and the command's own input are both known by then.
29
+ * @internal
30
+ */
31
+ export declare function resolveCommandScope<TInput>(commands: Record<string, CommandDef<TInput>>, commandName: string, input: TInput, actorUserId: string): CommandScope | undefined;
32
+ /**
33
+ * Widen a scope so the actor is always hydrated. A command's own player row is dirtied by
34
+ * {@link runCommand} whatever the command did, and an unhydrated player is dropped from the persist.
35
+ * @internal
36
+ */
37
+ export declare function scopeWithActor(scope: CommandScope | undefined, actorUserId: string): CommandScope | undefined;
38
+ /**
39
+ * Why a command's writes escaped the scope it was hydrated under, or `null` when they did not.
40
+ * A write outside the scope is a silent overwrite of unread state, so the host refuses it.
41
+ * @internal
42
+ */
43
+ export declare function commandScopeEscape(scope: CommandScope | undefined, dirty: import("./snapshot.js").GameRuntimeSnapshot["dirty"], commandName: string): string | null;
8
44
  export type RunCommandResult = {
9
45
  ok: true;
10
46
  snapshot: import("./snapshot.js").GameRuntimeSnapshot;
@@ -1,3 +1,39 @@
1
+ /**
2
+ * The scope a command declares for this input, or `undefined` when it declares none (hydrate everything).
3
+ * Evaluate it before hydration: the actor and the command's own input are both known by then.
4
+ * @internal
5
+ */
6
+ export function resolveCommandScope(commands, commandName, input, actorUserId) {
7
+ return commands[commandName]?.scope?.(input, actorUserId);
8
+ }
9
+ /**
10
+ * Widen a scope so the actor is always hydrated. A command's own player row is dirtied by
11
+ * {@link runCommand} whatever the command did, and an unhydrated player is dropped from the persist.
12
+ * @internal
13
+ */
14
+ export function scopeWithActor(scope, actorUserId) {
15
+ if (!scope || scope.players === undefined || scope.players.includes(actorUserId))
16
+ return scope;
17
+ return { ...scope, players: [...scope.players, actorUserId] };
18
+ }
19
+ /**
20
+ * Why a command's writes escaped the scope it was hydrated under, or `null` when they did not.
21
+ * A write outside the scope is a silent overwrite of unread state, so the host refuses it.
22
+ * @internal
23
+ */
24
+ export function commandScopeEscape(scope, dirty, commandName) {
25
+ if (!scope)
26
+ return null;
27
+ const strayPlayers = scope.players === undefined ? [] : dirty.players.filter((id) => !scope.players?.includes(id));
28
+ const strayChunks = scope.chunkKeys === undefined ? [] : dirty.chunks.filter((key) => !scope.chunkKeys?.includes(key));
29
+ if (strayPlayers.length === 0 && strayChunks.length === 0)
30
+ return null;
31
+ const parts = [
32
+ strayPlayers.length > 0 ? `players ${strayPlayers.join(", ")}` : null,
33
+ strayChunks.length > 0 ? `chunks ${strayChunks.join(", ")}` : null,
34
+ ].filter((part) => part !== null);
35
+ return `Command "${commandName}" wrote outside its declared scope: ${parts.join("; ")}`;
36
+ }
1
37
  /** @internal */
2
38
  export function runCommand(snapshot, commands, commandName, input, actorUserId) {
3
39
  const command = commands[commandName];
@@ -11,6 +11,8 @@ import { createPaintLayer } from "../../scene/paintLayer.js";
11
11
  import { createSelectionSet } from "../../scene/selection.js";
12
12
  import { createSceneRaycast, } from "../../scene/sceneRaycast.js";
13
13
  import { createSpatialApi } from "../../scene/spatial.js";
14
+ import { DEFAULT_OBSTACLE_PLAYER_RADIUS } from "../../movement/movementModel.js";
15
+ import { createObstacleReachCache, slideStep, sourceObstacleReach, sourceObstaclesNear, } from "../../movement/solidObstacles.js";
14
16
  import { createTargeting } from "../../scene/targeting.js";
15
17
  import { notifyAfter } from "../../store/changeSignal.js";
16
18
  /** @internal */
@@ -50,11 +52,20 @@ export function createSceneSubsystem(d) {
50
52
  candidateIdsDirty = false;
51
53
  return candidateIds;
52
54
  }
55
+ // `moveToward` reads the same blocking colliders the player resolver does, so a chaser stops at the
56
+ // wall that stops the player rather than lerping through it.
57
+ const walkerReach = createObstacleReachCache();
58
+ const solidSource = {
59
+ list: () => objects.list(),
60
+ inBox: (min, max) => objects.inBox(min, max),
61
+ collidersOf: (instanceId) => objectCollidersOf(instanceId),
62
+ };
53
63
  const spatial = createSpatialApi({
54
64
  resolvePosition: (instanceId) => entities.get(instanceId)?.position,
55
65
  candidates: refreshCandidateIds,
56
66
  grid: { cellSize: 8 },
57
67
  getVersion: () => spatialGeneration,
68
+ resolveStep: (position, stepX, stepZ) => slideStep(position, stepX, stepZ, sourceObstaclesNear(solidSource, sourceObstacleReach(solidSource, walkerReach), position, Math.abs(stepX) + DEFAULT_OBSTACLE_PLAYER_RADIUS, Math.abs(stepZ) + DEFAULT_OBSTACLE_PLAYER_RADIUS)),
58
69
  ...(occluder !== undefined ? { occluder } : {}),
59
70
  });
60
71
  entities.subscribe(() => {
@@ -14,7 +14,7 @@ import {} from "../scene/entityStore.js";
14
14
  import { createChangeSignal, notifyAfter } from "../store/changeSignal.js";
15
15
  import { createObservableKeyedStore } from "../store/observableKeyedStore.js";
16
16
  import { applyWorldSnapshot, composeWorldSnapshot, } from "./worldSnapshot.js";
17
- import { policyProjectsViewers, projectByVisibleIds, projectEntitiesForViewer, projectPerUserForViewer, visibleEntityIds, } from "./worldProjection.js";
17
+ import { policyProjectsViewers, projectByVisibleIds, projectEntitiesForViewer, projectObjectsForViewer, projectPerUserForViewer, viewerOrigin, visibleEntityIds, } from "./worldProjection.js";
18
18
  import { createRuntimeSave } from "./runtimeSave.js";
19
19
  import { isOffline } from "./adapter.js";
20
20
  import { localSaveBackend, memorySaveBackend } from "../game/saveStore.js";
@@ -299,6 +299,9 @@ export function createGameContext(options) {
299
299
  if (aoiRadius !== undefined && key === "stats") {
300
300
  return typedProject((data, viewer, world) => projectByVisibleIds(data, visibleEntityIds((world["entities"] ?? []), viewer, aoiRadius)));
301
301
  }
302
+ if (aoiRadius !== undefined && key === "objects") {
303
+ return typedProject((data, viewer, world) => projectObjectsForViewer(data, viewerOrigin(world, viewer), aoiRadius));
304
+ }
302
305
  if (replication?.privatePerUser === true && key === "inventory") {
303
306
  return typedProject((data, viewer) => projectPerUserForViewer(data, viewer));
304
307
  }
@@ -1,11 +1,17 @@
1
1
  import type { SaveConfig } from "./save.js";
2
- import type { CommandDef } from "./commandRunner.js";
2
+ import type { CommandDef, CommandScope } from "./commandRunner.js";
3
3
  import { runCommand } from "./commandRunner.js";
4
4
  import { type GameRuntimeSnapshot, type RuntimeChunkRow, type RuntimePlayerRow, type RuntimeProfileRow, type RuntimeServerRow } from "./snapshot.js";
5
5
  export type ServerLoopHooks = {
6
6
  onInit?: (ctx: RuntimeInitContext) => void;
7
7
  onNewPlayer?: (ctx: RuntimeLoopContext) => void;
8
8
  onTick?: (ctx: RuntimeWorldContext, dtSeconds: number) => void;
9
+ /**
10
+ * What `onNewPlayer` touches, so a join hydrates that instead of the whole world. Only consulted
11
+ * when `onNewPlayer` exists — without the hook a join provably needs the joining member and nothing
12
+ * else. Omit it and a join keeps hydrating everything.
13
+ */
14
+ joinScope?: (userId: string, isNew: boolean) => CommandScope;
9
15
  };
10
16
  export type RuntimeInitContext = {
11
17
  snapshot: GameRuntimeSnapshot;
@@ -37,8 +43,21 @@ export type HydrateInput = {
37
43
  export type GameRuntime = {
38
44
  gameId: string;
39
45
  save: SaveConfig;
46
+ /**
47
+ * Whether this runtime declares `loop.onTick`. A host's tick cron reads it to skip hydrating and
48
+ * persisting a server whose `tick` is a no-op by construction — the difference between a world that
49
+ * costs a full read/write every second while idle and one that costs nothing.
50
+ */
51
+ hasTick: boolean;
40
52
  hydrate: (input: HydrateInput) => GameRuntimeSnapshot;
41
53
  runCommand: (snapshot: GameRuntimeSnapshot, actorUserId: string, commandName: string, input: unknown) => ReturnType<typeof runCommand>;
54
+ /**
55
+ * What a command declares it will touch, evaluated before hydration so a host can load that slice
56
+ * and nothing else. `undefined` means the command declared no scope — hydrate everything.
57
+ */
58
+ commandScope: (commandName: string, input: unknown, actorUserId: string) => CommandScope | undefined;
59
+ /** What a join has to hydrate. `undefined` means the whole world, as when `onNewPlayer` is unscoped. */
60
+ joinScope: (userId: string, isNew: boolean) => CommandScope | undefined;
42
61
  tick: (snapshot: GameRuntimeSnapshot, dtSeconds: number) => GameRuntimeSnapshot;
43
62
  joinPlayer: (snapshot: GameRuntimeSnapshot, userId: string, isNew: boolean) => GameRuntimeSnapshot;
44
63
  toProfileRow: (snapshot: GameRuntimeSnapshot, userId: string) => RuntimeProfileRow | null;
@@ -1,4 +1,4 @@
1
- import { runCommand } from "./commandRunner.js";
1
+ import { resolveCommandScope, runCommand } from "./commandRunner.js";
2
2
  import { createEmptyPlayerRow, createRuntimeSnapshot, } from "./snapshot.js";
3
3
  /** @internal */
4
4
  export function createGameRuntime(definition) {
@@ -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,
@@ -32,6 +33,14 @@ export function createGameRuntime(definition) {
32
33
  runCommand(snapshot, actorUserId, commandName, input) {
33
34
  return runCommand(snapshot, definition.commands, commandName, input, actorUserId);
34
35
  },
36
+ commandScope(commandName, input, actorUserId) {
37
+ return resolveCommandScope(definition.commands, commandName, input, actorUserId);
38
+ },
39
+ joinScope(userId, isNew) {
40
+ if (!loop?.onNewPlayer)
41
+ return { players: [userId], chunkKeys: [] };
42
+ return loop.joinScope?.(userId, isNew);
43
+ },
35
44
  tick(snapshot, dtSeconds) {
36
45
  if (!loop?.onTick)
37
46
  return snapshot;
@@ -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
@@ -62,6 +62,12 @@ export type GameBackend<TPresenceRow = unknown, TPresenceLocation = unknown, TGa
62
62
  };
63
63
  export type PresencePoseRow = {
64
64
  userId: string;
65
+ /** Set when the host tracks presence per session, so one user can hold two rows. */
66
+ sessionId?: string;
67
+ /** Actor class, e.g. `"player"` / `"agent"` — a host may clamp each differently. */
68
+ kind?: string;
69
+ /** Display name carried on the row, so a nameplate needs no second lookup. */
70
+ label?: string;
65
71
  position: {
66
72
  x: number;
67
73
  y: number;
@@ -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.