@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
@@ -0,0 +1,208 @@
1
+ import { perContext } from "../runtime/perContext.js";
2
+ import { resolveColliders } from "../scene/colliders.js";
3
+ import { DEFAULT_OBSTACLE_PLAYER_RADIUS, resolveObstacleStep, } from "./movementModel.js";
4
+ /**
5
+ * Broadphase reach floor (XZ): the `inBox` query indexes objects by their center *point*, so the query
6
+ * must extend past the walker by at least the largest blocking-collider extent or a wide object's edge
7
+ * is missed. Floored so default unit boxes stay covered.
8
+ */
9
+ const OBSTACLE_MAX_HALF_EXTENT = 4;
10
+ /**
11
+ * Hard cap on XZ broadphase reach. City lots ~12-17 m footprint need ~17-20 m after yaw expansion;
12
+ * beyond that we refuse to grow the query into a city-wide box (and rely on the outer AABB of each
13
+ * solid still blocking once its *center* is in range).
14
+ */
15
+ const OBSTACLE_HORIZONTAL_CAP = 22;
16
+ /**
17
+ * Vertical broadphase floor/cap. A walker only needs nearby ground props / step-ups — never full tower
18
+ * height. Using building height for Y was the Vice Isle pose killer: a 50 m tower turned `inBox` into
19
+ * a 100^3 empty cell walk every frame.
20
+ */
21
+ const OBSTACLE_VERTICAL_FLOOR = 3;
22
+ const OBSTACLE_VERTICAL_CAP = 8;
23
+ /**
24
+ * Mesh compound `boxes` under this count are treated as intentional openings (archways). Above it we
25
+ * walk against the outer fitted AABB only — solid city buildings often ship dense box soups that
26
+ * multiply narrowphase cost without changing the blocked footprint.
27
+ */
28
+ const MOVEMENT_MESH_BOX_BUDGET = 12;
29
+ /** Feet-to-head span a walker is tested over — matches the player's, so both agree on what a wall is. */
30
+ const WALKER_HEIGHT = 1.8;
31
+ /** A fresh {@link ObstacleReachCache} for a caller that owns its own source. @internal */
32
+ export function createObstacleReachCache() {
33
+ return { count: -1, value: { horizontal: OBSTACLE_MAX_HALF_EXTENT, vertical: OBSTACLE_VERTICAL_FLOOR } };
34
+ }
35
+ const reachCache = perContext(() => createObstacleReachCache());
36
+ /**
37
+ * Largest blocking reach in the scene, split into horizontal vs vertical and hard-capped. Cached per
38
+ * ctx and recomputed when the object count changes — the broadphase box every consumer inflates by, so
39
+ * a wall wider than the query box can never be missed.
40
+ * @internal
41
+ */
42
+ export function solidObstacleReach(ctx) {
43
+ return sourceObstacleReach(ctx.scene.object, reachCache(ctx));
44
+ }
45
+ /** {@link solidObstacleReach} against a bare source and caller-owned cache. @internal */
46
+ export function sourceObstacleReach(source, cache) {
47
+ const objects = source.list();
48
+ if (cache.count === objects.length)
49
+ return cache.value;
50
+ let horizontal = OBSTACLE_MAX_HALF_EXTENT;
51
+ let vertical = OBSTACLE_VERTICAL_FLOOR;
52
+ for (const object of objects) {
53
+ const set = source.collidersOf(object.instanceId);
54
+ if (set === null)
55
+ continue;
56
+ for (const collider of resolveColliders(set)) {
57
+ if (!collider.blocks || collider.purpose !== "physical")
58
+ continue;
59
+ const shape = collider.shape;
60
+ const offset = shape.offset;
61
+ const ox = offset !== undefined ? Math.abs(offset[0]) : 0;
62
+ const oy = offset !== undefined ? Math.abs(offset[1]) : 0;
63
+ const oz = offset !== undefined ? Math.abs(offset[2]) : 0;
64
+ if (shape.kind === "sphere") {
65
+ horizontal = Math.max(horizontal, ox + oz + shape.radius);
66
+ vertical = Math.max(vertical, oy + shape.radius);
67
+ }
68
+ else {
69
+ // Horizontal bound sums both axes so any yaw of the box stays covered.
70
+ const h = shape.halfExtents;
71
+ horizontal = Math.max(horizontal, ox + oz + h[0] + h[2]);
72
+ vertical = Math.max(vertical, oy + h[1]);
73
+ }
74
+ }
75
+ }
76
+ const value = {
77
+ horizontal: Math.min(horizontal, OBSTACLE_HORIZONTAL_CAP),
78
+ vertical: Math.min(vertical, OBSTACLE_VERTICAL_CAP),
79
+ };
80
+ cache.count = objects.length;
81
+ cache.value = value;
82
+ return value;
83
+ }
84
+ /** Yaw-rotate an entity-local AABB about the object origin and re-fit it to an axis-aligned box, expressed
85
+ * as `min`/`max` relative to `position`. Conservative: the rotated box is grown to its enclosing AABB. */
86
+ function yawExpandLocalBox(boxMin, boxMax, cos, sin) {
87
+ const centerX = (boxMin[0] + boxMax[0]) / 2;
88
+ const centerY = (boxMin[1] + boxMax[1]) / 2;
89
+ const centerZ = (boxMin[2] + boxMax[2]) / 2;
90
+ const halfX = (boxMax[0] - boxMin[0]) / 2;
91
+ const halfY = (boxMax[1] - boxMin[1]) / 2;
92
+ const halfZ = (boxMax[2] - boxMin[2]) / 2;
93
+ const rcX = centerX * cos + centerZ * sin;
94
+ const rcZ = -centerX * sin + centerZ * cos;
95
+ const rhX = Math.abs(halfX * cos) + Math.abs(halfZ * sin);
96
+ const rhZ = Math.abs(halfX * sin) + Math.abs(halfZ * cos);
97
+ return {
98
+ min: [rcX - rhX, centerY - halfY, rcZ - rhZ],
99
+ max: [rcX + rhX, centerY + halfY, rcZ + rhZ],
100
+ };
101
+ }
102
+ /** One blocking collider as a {@link CollisionObstacle}: a compound `boxes` obstacle for a mesh collider
103
+ * carrying a small sub-box set (openings), else a single half-extents/offset AABB. Yaw is applied by
104
+ * conservatively expanding each AABB to its rotated extent (mirroring `worldOffset`), so the obstruction
105
+ * never under-covers.
106
+ * @internal */
107
+ export function obstacleFromCollider(collider, position, rotationY) {
108
+ const cos = Math.cos(rotationY);
109
+ const sin = Math.sin(rotationY);
110
+ const shape = collider.shape;
111
+ // Dense compound soups (city buildings) collapse to the outer fitted AABB for movement — openings
112
+ // only stay mesh-box accurate when the box count is small (arches, doorways).
113
+ if (shape.kind === "mesh" &&
114
+ shape.boxes !== undefined &&
115
+ shape.boxes.length > 0 &&
116
+ shape.boxes.length <= MOVEMENT_MESH_BOX_BUDGET) {
117
+ return {
118
+ position,
119
+ boxes: shape.boxes.map((b) => yawExpandLocalBox(b.min, b.max, cos, sin)),
120
+ };
121
+ }
122
+ let hx;
123
+ let hy;
124
+ let hz;
125
+ if (shape.kind === "sphere") {
126
+ hx = hy = hz = shape.radius;
127
+ }
128
+ else {
129
+ hx = shape.halfExtents[0];
130
+ hy = shape.halfExtents[1];
131
+ hz = shape.halfExtents[2];
132
+ }
133
+ const offset = shape.offset;
134
+ const ox = offset !== undefined ? offset[0] : 0;
135
+ const oy = offset !== undefined ? offset[1] : 0;
136
+ const oz = offset !== undefined ? offset[2] : 0;
137
+ return {
138
+ position,
139
+ // Rotate the offset by yaw (mirror of colliders.worldOffset), expand the extents to the rotated AABB.
140
+ offset: [ox * cos + oz * sin, oy, -ox * sin + oz * cos],
141
+ halfExtents: [Math.abs(hx * cos) + Math.abs(hz * sin), hy, Math.abs(hx * sin) + Math.abs(hz * cos)],
142
+ };
143
+ }
144
+ /**
145
+ * Every blocking physical obstacle overlapping the box `position` ± `reachX`/`reachZ`, read from the
146
+ * scene's resolved colliders.
147
+ *
148
+ * This is the engine's single answer to "what is solid here". The player resolver, a walking NPC, and
149
+ * anything else that has to stop at a wall read it rather than deriving their own set — three
150
+ * divergent notions of solid (engine colliders / game catalog flags / nothing at all) is what let a
151
+ * pedestrian stroll through the building the player cannot enter.
152
+ *
153
+ * @capability solid-obstacles the one blocking-geometry query the player resolver and NPC movers share
154
+ */
155
+ export function solidObstaclesNear(ctx, position, reachX, reachZ, height = WALKER_HEIGHT) {
156
+ return sourceObstaclesNear(ctx.scene.object, solidObstacleReach(ctx), position, reachX, reachZ, height);
157
+ }
158
+ /** {@link solidObstaclesNear} against a bare source and an already-resolved reach. @internal */
159
+ export function sourceObstaclesNear(source, reach, position, reachX, reachZ, height = WALKER_HEIGHT) {
160
+ const min = [
161
+ position[0] - reachX - reach.horizontal,
162
+ position[1] - reach.vertical,
163
+ position[2] - reachZ - reach.horizontal,
164
+ ];
165
+ const max = [
166
+ position[0] + reachX + reach.horizontal,
167
+ position[1] + height + reach.vertical,
168
+ position[2] + reachZ + reach.horizontal,
169
+ ];
170
+ const obstacles = [];
171
+ for (const object of source.inBox(min, max)) {
172
+ const set = source.collidersOf(object.instanceId);
173
+ if (set === null) {
174
+ // No resolved collider: preserve the default 1×1×1 box.
175
+ obstacles.push({ position: object.position });
176
+ continue;
177
+ }
178
+ for (const collider of resolveColliders(set)) {
179
+ if (!collider.blocks || collider.purpose !== "physical")
180
+ continue;
181
+ obstacles.push(obstacleFromCollider(collider, object.position, object.rotationY));
182
+ }
183
+ }
184
+ return obstacles;
185
+ }
186
+ /**
187
+ * Slide a walker's horizontal step against the same solid geometry that stops the player, returning the
188
+ * X/Z it may actually take this tick. A step into a wall is cut on the blocked axis and preserved on the
189
+ * other, exactly as {@link stepPlayerMovement} resolves the player's.
190
+ *
191
+ * Bounded by the same broadphase as the player's gather: one `inBox` query sized to this step, never a
192
+ * full-world scan, so it stays affordable per NPC per tick.
193
+ *
194
+ * @capability walker-solid-step slide an NPC's step against the geometry that blocks the player
195
+ */
196
+ export function resolveWalkerStep(ctx, position, stepX, stepZ, options = {}) {
197
+ if (stepX === 0 && stepZ === 0)
198
+ return { stepX, stepZ };
199
+ const radius = options.radius ?? DEFAULT_OBSTACLE_PLAYER_RADIUS;
200
+ const obstacles = solidObstaclesNear(ctx, position, Math.abs(stepX) + radius, Math.abs(stepZ) + radius);
201
+ return slideStep(position, stepX, stepZ, obstacles, radius, options.stepUpHeight ?? 0);
202
+ }
203
+ /** {@link resolveWalkerStep} against an already-gathered obstacle set. @internal */
204
+ export function slideStep(position, stepX, stepZ, obstacles, radius = DEFAULT_OBSTACLE_PLAYER_RADIUS, stepUpHeight = 0) {
205
+ if (obstacles.length === 0)
206
+ return { stepX, stepZ };
207
+ return resolveObstacleStep(position, stepX, stepZ, obstacles, radius, stepUpHeight);
208
+ }
@@ -8,4 +8,5 @@ export { createPoseSyncGate, type PlayerPose, type PoseSyncTuning } from "./mult
8
8
  export { type EnsurePresenceResult, type PresenceActions, type PresenceFeeds, type PresenceSession, type PresenceTransport, } from "./multiplayer/presenceContract.js";
9
9
  export { type PoseSyncRules, type PresencePoseState } from "./multiplayer/presenceModel.js";
10
10
  export { createLocalVoiceTransport, createPushToTalk, type PushToTalkMode, type PushToTalkStatus, type VoiceParticipant, type VoiceRoute, type VoiceTransport, } from "./multiplayer/voiceContract.js";
11
+ export { fromRuntimeObjectRow, toRuntimeObjectRow } from "./runtime/objectRows.js";
11
12
  export { type ReplicationPolicy } from "./runtime/worldProjection.js";
@@ -8,4 +8,5 @@ export { createPoseSyncGate } from "./multiplayer/poseSyncGate.js";
8
8
  export {} from "./multiplayer/presenceContract.js";
9
9
  export {} from "./multiplayer/presenceModel.js";
10
10
  export { createLocalVoiceTransport, createPushToTalk, } from "./multiplayer/voiceContract.js";
11
+ export { fromRuntimeObjectRow, toRuntimeObjectRow } from "./runtime/objectRows.js";
11
12
  export {} from "./runtime/worldProjection.js";
@@ -0,0 +1,60 @@
1
+ import { type InventoryLayout, type InventorySet, type InventoryState, type ItemTraits } from "../../inventory/inventoryModel.js";
2
+ import type { ObjectStore } from "../../scene/objectStore.js";
3
+ /** Why an object slot mutation was refused — the instance, its catalog entry, the player inventory, or the declared `accepts`/capacity. */
4
+ export type ObjectSlotRejection = "unknown-object" | "no-slot-inventory" | "unknown-inventory" | "no-space" | "wrong-kind" | "slot-occupied" | "invalid-slot" | "insufficient";
5
+ /** Outcome of an object slot mutation: applied, or refused with a reason and no state change on either side. */
6
+ export type ObjectSlotResult = {
7
+ status: "ok";
8
+ } | {
9
+ status: "rejected";
10
+ reason: ObjectSlotRejection;
11
+ };
12
+ /** Which player inventory a transfer moves between, and what it moves. */
13
+ export interface ObjectSlotTransfer {
14
+ instanceId: string;
15
+ userId: string;
16
+ inventoryId: string;
17
+ itemId: string;
18
+ /** Defaults to `1`. */
19
+ count?: number;
20
+ /** Explicit slot index **on the object**; omit to auto-place (transfer in) or drain the object's matching stacks (transfer out). */
21
+ slot?: number;
22
+ }
23
+ /**
24
+ * Per-instance container contents for placed objects whose catalog entry declares `slotInventory` —
25
+ * the engine side of "install a GPU into a rack", "put an item in a chest". Contents live on
26
+ * `SceneObject.slots`, so they replicate and save with the object rather than needing a game-owned
27
+ * table keyed by `instanceId`, and every mutation is validated against the declared `accepts` and
28
+ * stack limits.
29
+ *
30
+ * @capability object-slot-inventory placed objects hold validated per-instance container contents that replicate and save with the placement
31
+ */
32
+ export interface SceneObjectSlots {
33
+ /** The catalog `slotInventory` layout for a placed instance, or `null` when its catalog entry declares none. */
34
+ layout(instanceId: string): InventoryLayout | null;
35
+ /** Live contents, defaulting to the layout's empty slots. `null` when the instance is unplaced or has no `slotInventory`. */
36
+ state(instanceId: string): InventoryState | null;
37
+ /** How many of `itemId` this object holds; `0` for an unplaced or slot-less instance. */
38
+ count(instanceId: string, itemId: string): number;
39
+ /** Add items directly, bypassing any player inventory — world seeding, loot generation, admin tools. */
40
+ put(instanceId: string, itemId: string, count?: number, options?: {
41
+ slot?: number;
42
+ }): ObjectSlotResult;
43
+ /** Remove items directly, bypassing any player inventory. */
44
+ take(instanceId: string, itemId: string, count?: number): ObjectSlotResult;
45
+ /** Move items from a player inventory into the object's slots; nothing is consumed if the object rejects them. */
46
+ transferIn(transfer: ObjectSlotTransfer): ObjectSlotResult;
47
+ /** Move items from the object's slots into a player inventory; nothing leaves the object if the inventory rejects them. */
48
+ transferOut(transfer: ObjectSlotTransfer): ObjectSlotResult;
49
+ }
50
+ /** @internal */
51
+ export interface ObjectSlotsDeps {
52
+ objects: ObjectStore;
53
+ layoutOf: (instanceId: string) => InventoryLayout | null;
54
+ traits: () => ItemTraits;
55
+ hasInventory: (inventoryId: string) => boolean;
56
+ inventoryFor: (userId: string) => InventorySet<string> | null;
57
+ notify: () => void;
58
+ }
59
+ /** @internal */
60
+ export declare function createObjectSlots(d: ObjectSlotsDeps): SceneObjectSlots;
@@ -0,0 +1,103 @@
1
+ import { countItem, createEmptyInventory, putItem, takeItem, } from "../../inventory/inventoryModel.js";
2
+ /** Drain `count` of `itemId` from one explicit slot, so "pull the GPU out of rack slot 3" is expressible. */
3
+ function takeFromSlot(state, slot, itemId, count) {
4
+ if (!Number.isInteger(slot) || slot < 0 || slot >= state.slots.length) {
5
+ return { status: "rejected", reason: "invalid-slot" };
6
+ }
7
+ const held = state.slots[slot];
8
+ if (held === null || held === undefined || held.itemId !== itemId || held.count < count) {
9
+ return { status: "rejected", reason: "insufficient" };
10
+ }
11
+ const slots = [...state.slots];
12
+ slots[slot] = held.count === count ? null : { itemId, count: held.count - count };
13
+ return { status: "ok", state: { slots } };
14
+ }
15
+ /** @internal */
16
+ export function createObjectSlots(d) {
17
+ function resolve(instanceId) {
18
+ const object = d.objects.get(instanceId);
19
+ if (object === null)
20
+ return { status: "rejected", reason: "unknown-object" };
21
+ const layout = d.layoutOf(instanceId);
22
+ if (layout === null)
23
+ return { status: "rejected", reason: "no-slot-inventory" };
24
+ return { layout, state: object.slots ?? createEmptyInventory(layout) };
25
+ }
26
+ function playerInventory(transfer) {
27
+ if (!d.hasInventory(transfer.inventoryId))
28
+ return null;
29
+ return d.inventoryFor(transfer.userId);
30
+ }
31
+ function commit(instanceId, state) {
32
+ d.objects.setSlots(instanceId, state);
33
+ d.notify();
34
+ return { status: "ok" };
35
+ }
36
+ return {
37
+ layout(instanceId) {
38
+ return d.objects.get(instanceId) === null ? null : d.layoutOf(instanceId);
39
+ },
40
+ state(instanceId) {
41
+ const resolved = resolve(instanceId);
42
+ return "status" in resolved ? null : resolved.state;
43
+ },
44
+ count(instanceId, itemId) {
45
+ const resolved = resolve(instanceId);
46
+ return "status" in resolved ? 0 : countItem(resolved.state, itemId);
47
+ },
48
+ put(instanceId, itemId, count = 1, options) {
49
+ const resolved = resolve(instanceId);
50
+ if ("status" in resolved)
51
+ return resolved;
52
+ const result = putItem(resolved.state, resolved.layout, d.traits(), itemId, count, options);
53
+ if (result.status === "rejected")
54
+ return result;
55
+ return commit(instanceId, result.state);
56
+ },
57
+ take(instanceId, itemId, count = 1) {
58
+ const resolved = resolve(instanceId);
59
+ if ("status" in resolved)
60
+ return resolved;
61
+ const result = takeItem(resolved.state, itemId, count);
62
+ if (result.status === "rejected")
63
+ return result;
64
+ return commit(instanceId, result.state);
65
+ },
66
+ transferIn(transfer) {
67
+ const count = transfer.count ?? 1;
68
+ const resolved = resolve(transfer.instanceId);
69
+ if ("status" in resolved)
70
+ return resolved;
71
+ const inventory = playerInventory(transfer);
72
+ if (inventory === null)
73
+ return { status: "rejected", reason: "unknown-inventory" };
74
+ const options = transfer.slot === undefined ? undefined : { slot: transfer.slot };
75
+ const accepted = putItem(resolved.state, resolved.layout, d.traits(), transfer.itemId, count, options);
76
+ if (accepted.status === "rejected")
77
+ return accepted;
78
+ const removed = inventory.take(transfer.inventoryId, transfer.itemId, count);
79
+ if (removed.status === "rejected")
80
+ return removed;
81
+ return commit(transfer.instanceId, accepted.state);
82
+ },
83
+ transferOut(transfer) {
84
+ const count = transfer.count ?? 1;
85
+ const resolved = resolve(transfer.instanceId);
86
+ if ("status" in resolved)
87
+ return resolved;
88
+ const inventory = playerInventory(transfer);
89
+ if (inventory === null)
90
+ return { status: "rejected", reason: "unknown-inventory" };
91
+ const drained = transfer.slot === undefined
92
+ ? takeItem(resolved.state, transfer.itemId, count)
93
+ : takeFromSlot(resolved.state, transfer.slot, transfer.itemId, count);
94
+ if (drained.status === "rejected" || drained.state === undefined) {
95
+ return drained.status === "rejected" ? drained : { status: "rejected", reason: "insufficient" };
96
+ }
97
+ const given = inventory.put(transfer.inventoryId, transfer.itemId, count);
98
+ if (given.status === "rejected")
99
+ return given;
100
+ return commit(transfer.instanceId, drained.state);
101
+ },
102
+ };
103
+ }
@@ -3,7 +3,7 @@ import type { GameFeatures } from "../../game/defineGame.js";
3
3
  import type { InventoryDeclaration } from "../../game/defineGame.js";
4
4
  import type { GameEvents } from "../../game/events.js";
5
5
  import { type Loadouts } from "../../game/loadout.js";
6
- import { type InventoryLayout, type InventorySet } from "../../inventory/inventoryModel.js";
6
+ import { type InventoryLayout, type InventorySet, type ItemTraits } from "../../inventory/inventoryModel.js";
7
7
  import { type PoseState } from "../../movement/poseState.js";
8
8
  import { type Possession } from "../../scene/possession.js";
9
9
  import type { EntityStore } from "../../scene/entityStore.js";
@@ -28,6 +28,8 @@ export interface PlayerSubsystem {
28
28
  inventoryByUser: ReadonlyMap<string, InventorySet<string>>;
29
29
  inventoryFor: (userId: string) => InventorySet<string>;
30
30
  layouts: Record<string, InventoryLayout>;
31
+ /** Shared stack-limit/kind table for every declared inventory — also what object slot inventories validate against. */
32
+ itemTraits: ItemTraits;
31
33
  wallets: Map<string, WalletState>;
32
34
  walletOf: (userId: string) => WalletState;
33
35
  economy: GameContextEconomy;
@@ -110,6 +110,7 @@ export function createPlayerSubsystem(d) {
110
110
  inventoryByUser,
111
111
  inventoryFor,
112
112
  layouts,
113
+ itemTraits: traits,
113
114
  wallets,
114
115
  walletOf,
115
116
  economy,
@@ -7,6 +7,7 @@ import { type Forms } from "../../scene/form.js";
7
7
  import { type EntityColliderSet, type MeasuredBounds } from "../../scene/colliders.js";
8
8
  import { type CollisionMeshSource } from "../../scene/collisionMesh.js";
9
9
  import { type ObjectStore } from "../../scene/objectStore.js";
10
+ import type { InventorySet, ItemTraits } from "../../inventory/inventoryModel.js";
10
11
  import { type PaintLayer } from "../../scene/paintLayer.js";
11
12
  import { type SceneRaycastApi } from "../../scene/sceneRaycast.js";
12
13
  import { type SpatialApi } from "../../scene/spatial.js";
@@ -57,6 +58,14 @@ export interface SceneSubsystem {
57
58
  setOnAfterSpawn: (fn: (instanceId: string) => void) => void;
58
59
  /** Install post-despawn side effects (pose.clear) after player is constructed. */
59
60
  setOnAfterDespawn: (fn: (instanceId: string) => void) => void;
61
+ /** Hand the player subsystem's inventories to `sceneObjects.slots`, which is built before they exist. */
62
+ setInventoryAccess: (access: ObjectSlotInventoryAccess) => void;
63
+ }
64
+ /** @internal What `sceneObjects.slots` needs from the player subsystem to move items between a player and an object. */
65
+ export interface ObjectSlotInventoryAccess {
66
+ traits: ItemTraits;
67
+ hasInventory: (inventoryId: string) => boolean;
68
+ inventoryFor: (userId: string) => InventorySet<string>;
60
69
  }
61
70
  /** @internal */
62
71
  export declare function createSceneSubsystem(d: SceneSubsystemDeps): SceneSubsystem;
@@ -6,10 +6,13 @@ import { fittedEntityColliders, fittedObjectColliders, hasMeshColliderShape, mea
6
6
  import { prepareCollisionMeshSource } from "../../scene/collisionMesh.js";
7
7
  import { raycastObjects, raycastObjectsAll } from "../../scene/objectQuery.js";
8
8
  import { createObjectStore, objectVisualScale } from "../../scene/objectStore.js";
9
+ import { createObjectSlots } from "./objectSlots.js";
9
10
  import { createPaintLayer } from "../../scene/paintLayer.js";
10
11
  import { createSelectionSet } from "../../scene/selection.js";
11
12
  import { createSceneRaycast, } from "../../scene/sceneRaycast.js";
12
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";
13
16
  import { createTargeting } from "../../scene/targeting.js";
14
17
  import { notifyAfter } from "../../store/changeSignal.js";
15
18
  /** @internal */
@@ -49,11 +52,20 @@ export function createSceneSubsystem(d) {
49
52
  candidateIdsDirty = false;
50
53
  return candidateIds;
51
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
+ };
52
63
  const spatial = createSpatialApi({
53
64
  resolvePosition: (instanceId) => entities.get(instanceId)?.position,
54
65
  candidates: refreshCandidateIds,
55
66
  grid: { cellSize: 8 },
56
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)),
57
69
  ...(occluder !== undefined ? { occluder } : {}),
58
70
  });
59
71
  entities.subscribe(() => {
@@ -316,6 +328,15 @@ export function createSceneSubsystem(d) {
316
328
  return created;
317
329
  }
318
330
  const objectSelection = notifyAfter(createSelectionSet(), ["add", "remove", "toggle", "replace", "clear"], signalNotify);
331
+ let inventoryAccess = null;
332
+ const objectSlots = createObjectSlots({
333
+ objects,
334
+ layoutOf: (instanceId) => catalogObject(instanceId)?.slotInventory ?? null,
335
+ traits: () => inventoryAccess?.traits ?? { stackLimit: () => Number.POSITIVE_INFINITY },
336
+ hasInventory: (inventoryId) => inventoryAccess?.hasInventory(inventoryId) ?? false,
337
+ inventoryFor: (userId) => inventoryAccess?.inventoryFor(userId) ?? null,
338
+ notify: signalNotify,
339
+ });
319
340
  const sceneObjects = {
320
341
  ...objects,
321
342
  remove(instanceId) {
@@ -338,6 +359,7 @@ export function createSceneSubsystem(d) {
338
359
  reportBounds: reportObjectBounds,
339
360
  reportCollisionMesh: reportObjectCollisionMesh,
340
361
  selection: objectSelection,
362
+ slots: objectSlots,
341
363
  };
342
364
  return {
343
365
  entities,
@@ -372,5 +394,8 @@ export function createSceneSubsystem(d) {
372
394
  setOnAfterDespawn(fn) {
373
395
  onAfterDespawn = fn;
374
396
  },
397
+ setInventoryAccess(access) {
398
+ inventoryAccess = access;
399
+ },
375
400
  };
376
401
  }
@@ -4,6 +4,7 @@ import type { InventorySet } from "../../inventory/inventoryModel.js";
4
4
  import type { PoseState } from "../../movement/poseState.js";
5
5
  import { type StatValueMap } from "../../scene/entityStats.js";
6
6
  import type { EntityStore } from "../../scene/entityStore.js";
7
+ import type { ObjectStore } from "../../scene/objectStore.js";
7
8
  import type { Possession } from "../../scene/possession.js";
8
9
  import type { ObservableKeyedStore } from "../../store/observableKeyedStore.js";
9
10
  import type { SimClock } from "../../time/simClock.js";
@@ -13,6 +14,7 @@ import type { SnapshotModule } from "../worldSnapshot.js";
13
14
  export interface BaselineDeps {
14
15
  signalNotify: () => void;
15
16
  entities: EntityStore;
17
+ objects: ObjectStore;
16
18
  statsByInstance: Map<string, StatValueMap>;
17
19
  store: ObservableKeyedStore<unknown>;
18
20
  feed: GameFeed;
@@ -35,7 +37,7 @@ export interface BaselineBuild {
35
37
  }
36
38
  /**
37
39
  * Always-on counterpart to {@link featureDescriptors}: each baseline subsystem (entities, stats,
38
- * store, feed, inventory, economy, time, pose, possession, motion) owns its own serialization here
40
+ * store, feed, inventory, economy, time, pose, possession, motion, objects) owns its own serialization here
39
41
  * instead of `createGameContext` hand-maintaining two distant module arrays. Order is
40
42
  * load-bearing — it fixes the snapshot key order, so it must stay stable.
41
43
  * @internal
@@ -154,4 +154,15 @@ export const baselineDescriptors = [
154
154
  },
155
155
  }),
156
156
  },
157
+ {
158
+ key: "objects",
159
+ create: (d) => ({
160
+ replicate: {
161
+ key: "objects",
162
+ snapshot: () => d.objects.snapshot(),
163
+ decode: (raw) => decodeArray(raw),
164
+ hydrate: (data) => d.objects.hydrate(data),
165
+ },
166
+ }),
167
+ },
157
168
  ];
@@ -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";
@@ -119,6 +119,11 @@ export function createGameContext(options) {
119
119
  });
120
120
  const { inventoryIds, inventoryByUser, inventoryFor, layouts, wallets, walletOf, economy, putIntoAnyInventory, playerStatsFor, pose, possession, motionByUser, motionFor, loadouts, seedUserPool, } = playerSys;
121
121
  scene.setOnAfterDespawn((instanceId) => pose.clear(instanceId));
122
+ scene.setInventoryAccess({
123
+ traits: playerSys.itemTraits,
124
+ hasInventory: (inventoryId) => layouts[inventoryId] !== undefined,
125
+ inventoryFor,
126
+ });
122
127
  // Built-in inventory mutation commands so a HUD grid drives moves/splits through the notifying
123
128
  // command path (a raw InventorySet mutation would not re-render React). Each resolves the acting
124
129
  // user's own inventory, so it respects `privatePerUser` replication.
@@ -264,6 +269,7 @@ export function createGameContext(options) {
264
269
  const baselineDeps = {
265
270
  signalNotify: signal.notify,
266
271
  entities,
272
+ objects,
267
273
  statsByInstance,
268
274
  store,
269
275
  feed,
@@ -293,6 +299,9 @@ export function createGameContext(options) {
293
299
  if (aoiRadius !== undefined && key === "stats") {
294
300
  return typedProject((data, viewer, world) => projectByVisibleIds(data, visibleEntityIds((world["entities"] ?? []), viewer, aoiRadius)));
295
301
  }
302
+ if (aoiRadius !== undefined && key === "objects") {
303
+ return typedProject((data, viewer, world) => projectObjectsForViewer(data, viewerOrigin(world, viewer), aoiRadius));
304
+ }
296
305
  if (replication?.privatePerUser === true && key === "inventory") {
297
306
  return typedProject((data, viewer) => projectPerUserForViewer(data, viewer));
298
307
  }
@@ -23,6 +23,7 @@ import type { Social } from "../game/social.js";
23
23
  import type { TradeField, TradeSystem } from "../game/trade.js";
24
24
  import type { Unlocks } from "../game/unlocks.js";
25
25
  import type { InventoryLayout, InventorySet } from "../inventory/inventoryModel.js";
26
+ import type { SceneObjectSlots } from "./context/objectSlots.js";
26
27
  import type { ContextVerb } from "../interaction/contextMenu.js";
27
28
  import type { ProximityPrompt } from "../interaction/proximityPrompt.js";
28
29
  import type { ItemUseHandler, ItemUseInput, ItemUseRejection, ItemUseResult } from "../item/use.js";
@@ -87,6 +88,11 @@ export interface GameContextObjectEntry {
87
88
  breakable?: false | {
88
89
  baseBreakTime: number;
89
90
  };
91
+ /**
92
+ * Declares this object a container: every placement gets its own slots, held on `SceneObject.slots`
93
+ * and mutated through `ctx.scene.object.slots`, which enforces the declared `slots` count and
94
+ * `accepts` kind filter.
95
+ */
90
96
  slotInventory?: InventoryLayout;
91
97
  /** Right-click context-menu verbs for this object (#31). */
92
98
  verbs?: readonly ContextVerb[];
@@ -174,6 +180,13 @@ export interface SceneObjectContext extends ObjectStore {
174
180
  * (`@jgengine/shell/world/WorldHud`).
175
181
  */
176
182
  selection: SelectionSet;
183
+ /**
184
+ * Per-instance container contents for objects whose catalog entry declares {@link GameContextObjectEntry.slotInventory}
185
+ * — chests, racks, machines. Contents live on `SceneObject.slots`, so they replicate and save with
186
+ * the placement, and `transferIn`/`transferOut` move items to and from a player inventory with the
187
+ * declared `accepts` and stack limits enforced.
188
+ */
189
+ slots: SceneObjectSlots;
177
190
  }
178
191
  export interface FloatTextInput {
179
192
  instanceId?: string;
@@ -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,