@jgengine/core 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/dist/game/playableGame.d.ts +36 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +23 -1
- package/dist/multiplayer.d.ts +1 -0
- package/dist/multiplayer.js +1 -0
- package/dist/runtime/context/objectSlots.d.ts +60 -0
- package/dist/runtime/context/objectSlots.js +103 -0
- package/dist/runtime/context/player.d.ts +3 -1
- package/dist/runtime/context/player.js +1 -0
- package/dist/runtime/context/scene.d.ts +9 -0
- package/dist/runtime/context/scene.js +14 -0
- package/dist/runtime/descriptors/baseline.d.ts +3 -1
- package/dist/runtime/descriptors/baseline.js +11 -0
- package/dist/runtime/gameContext.js +6 -0
- package/dist/runtime/gameContextTypes.d.ts +13 -0
- package/dist/runtime/hostPersistence.js +9 -5
- package/dist/runtime/objectRows.d.ts +17 -0
- package/dist/runtime/objectRows.js +35 -0
- package/dist/runtime/snapshot.d.ts +15 -0
- package/dist/runtime/snapshot.js +12 -0
- package/dist/scene/objectStore.d.ts +26 -0
- package/dist/scene/objectStore.js +51 -9
- package/dist/world/buildingKit.d.ts +110 -0
- package/dist/world/buildingKit.js +93 -0
- package/dist/world/buildings.d.ts +25 -3
- package/dist/world/buildings.js +39 -5
- package/dist/world/cityKind.d.ts +6 -0
- package/dist/world/cityKind.js +3 -0
- package/dist/world/environmentSummary.js +13 -3
- package/dist/world/features.d.ts +7 -1
- package/dist/world/features.js +1 -0
- package/dist/world.d.ts +1 -0
- package/dist/world.js +1 -0
- package/package.json +1 -1
- package/skills/jgengine/api.md +15 -3
- package/skills/jgengine/capabilities.md +12 -0
- package/skills/jgengine-gameplay/api.md +2 -1
- package/skills/jgengine-world/SKILL.md +5 -0
- package/skills/jgengine-world/api.md +21 -2
- package/skills/jgengine-world/capabilities.md +16 -0
- package/skills/jgengine-world/recipes/building-kit.md +78 -0
package/CHANGELOG.md
CHANGED
|
@@ -34,6 +34,38 @@ At publish, rename this heading to the new version and mirror the entries into
|
|
|
34
34
|
|
|
35
35
|
### Fixed
|
|
36
36
|
|
|
37
|
+
## 0.16.0
|
|
38
|
+
|
|
39
|
+
### Migrate
|
|
40
|
+
|
|
41
|
+
- **Bump lockstep SDK packages to `^0.16.0`:** `@jgengine/{core,react,ws,node,sql,convex,shell,editor,assets}`. CLI `jgengine` is `0.13.0`; `@jgengine/github` is `0.3.0`.
|
|
42
|
+
- **Placed objects now replicate.** `objects` joined the always-on baseline snapshot modules, so `ctx.hydrate` replaces the client's placed object set with the host's, the way `entities` already did. A game whose peers all run the same authored placement is unaffected; a game that places props on the client only, after hydrate, must move that placement to the host or keep it out of `ctx.scene.object`.
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
|
|
46
|
+
- **`GeneratedBuilding`'s `kit` prop is now the `BuildingKit`**; the per-part React escape hatch it used to name moved to `partRenderer`, so `kit` means the same thing on `GeneratedBuilding` and `InstancedBuildings`. Rename the prop if you passed a `BuildingKitRenderer`.
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
|
|
50
|
+
- **`modelLoadFallbacks()` from `@jgengine/shell/render/modelLoad`** lists every model the shared loader diagnosed as broken and replaced with a magenta placeholder. A placeholder was only ever a console warning, so a shipped game could render one in its starting area and nobody found out until someone looked at a screenshot; a capture host or smoke test can now require the list to be empty. `clearModelLoadFallbacks()` resets it between runs.
|
|
51
|
+
- **`worldHealthBars.occlude` / `WorldHealthBars`+`WorldNameplates` `occlude` prop.** World bars and nameplates are a screen-space overlay drawn outside the WebGL depth buffer, so nothing in the scene can hide them and an enemy's health bar reads straight through the building it stands behind. Set `occlude: true` to drop a bar when world geometry blocks the line from the player to the entity. Default `false` preserves the always-visible behaviour every shipped game currently relies on.
|
|
52
|
+
|
|
53
|
+
- **`DustField` + `WeatherLayer` mode `"dust"` (`@jgengine/shell/weather`) — wind-borne particulate.** `WeatherLayer` could only do `clear`/`rain`/`snow`/`mixed`, so an arid, volcanic, ashen, or blown-out world had no way to put anything in the air. Dust drifts on horizontal wind with a slow vertical bob rather than falling; `groundBias` packs density toward the surface, motes fade near the top of the volume so recycled ones do not pop against the sky, and faster motes ride higher. Set `dustAlways` to keep it in the air alongside rain or snow instead of replacing them. Defaults `DEFAULT_DUST_COUNT`/`DEFAULT_DUST_DENSITY` sit beside the rain and snow pair.
|
|
54
|
+
|
|
55
|
+
- **Authored `city` volumes render their near-LOD facades with a `BuildingKit`.** `CityRules.buildingKit` names a kit; a game registers the bound art with `registerBuildingKit(kit)` from `@jgengine/shell/structures/buildingKitRegistry` and the scene document carries only the name, so a city stays portable and free of model paths. Unset or unknown names keep the palette blocks. This puts the whole `city` primitive — not just the `building()` feature — on the kit path.
|
|
56
|
+
- **`BuildingKit` from `@jgengine/core/world/buildingKit`** binds a generated building's facade part slots to real models, so procedural buildings render as an instanced asset kit instead of untextured blocks. `defineBuildingKit({ parts: { wall: ["Wall_A.glb", …] } })` maps a part kind — or a `${facade}.${kind}` slot — to ordered variants that `kit.variant` indexes, with `fit`/`offset`/`rotation`/`scale`/`tint`/`autoOrient` per variant. Kinds a kit leaves unbound keep rendering the palette block, so a partial kit is a valid kit, and `omit` drops a kind entirely. Pass one through `building({ kit })` or straight to `InstancedBuildings`; `buildingKitVariantCounts(kit)` feeds `generateBuilding` so its seeded picks span exactly the models the kit binds.
|
|
57
|
+
- **`generateBuilding`, `generateBuildingDistrict`, and `createBuildingConfig` are now documented public API** (`@jgengine/core/world/buildings`) and appear in `capabilities.md` under `building-generator`.
|
|
58
|
+
- **`capture.views` on `defineGame`** — named framings (`GameCaptureView`: `look`, `lookFrom`, `spawn`, `state`, `run`, `settleMs`) that `shoot <game> --view <name>` and `drive --view <name>` replay exactly, so a before/after pair is the same view by construction. `shoot <game> --list-views` prints what a game declares; an unknown name fails listing them, and any explicit capture flag still wins over the view's value.
|
|
59
|
+
- **`modelLoadIdleMs()` from `@jgengine/shell/render/modelLoad`** reports how long the shared GLB loader has been idle (`0` while a model is in flight). Capture hosts wait on it instead of guessing a settle delay, so an establishing shot no longer races streaming models.
|
|
60
|
+
- **Per-instance state on placed objects** — `SceneObject.state` (opaque `Record`), set through `ctx.scene.object.setState`/`patchState`/`place({ state })`. It survives move/rotate/setVisual, replicates and saves with the object, and round-trips as `RuntimeObjectRow.flags` via `toRuntimeObjectRow`/`fromRuntimeObjectRow` (`@jgengine/core/multiplayer`). A chest's locked flag or a machine's fill level no longer needs a game-owned map keyed by `instanceId`.
|
|
61
|
+
- **`slotInventory` is a working feature** — `ctx.scene.object.slots` gives objects whose catalog entry declares it real per-instance container contents: `layout`/`state`/`count`, `put`/`take`, and `transferIn`/`transferOut` between a player inventory and the object, all validated against the declared slot count, `accepts` kind filter, and stack limits, with neither side mutated on a rejection. Contents live on `SceneObject.slots`, so they replicate and save with the placement.
|
|
62
|
+
- **`loadServerSnapshot` and `persistServerSnapshot` are public** (`@jgengine/convex/server`), and `createGameServerFunctions` also returns `helpers` (`loadSnapshot`, `applyCommand`, `persistSnapshot`, `runCommand`) bound to the same runtimes and auth mode. A host mutation can now pair snapshot work with its own table writes in one transaction; the registered `runCommand` mutation delegates to `helpers.runCommand`, so there is one implementation.
|
|
63
|
+
|
|
64
|
+
### Fixed
|
|
65
|
+
|
|
66
|
+
- **Open storefronts are no longer holes through the building.** `generateBuilding` emitted an open ground-floor store as a lone `storefront` panel with no wall behind it, while every upper-floor window was correctly backed by one. Storefront and window materials render at 0.56 opacity and a generated building is a hollow shell, so those bays let you see straight through the building to the world beyond — and open stores only land on the street-facing facade, so a row of shops along a road was the worst case. The bay now emits its wall first, exactly as an upper floor does.
|
|
67
|
+
- **`joinServer`, `flushSave`, `flushDirtyServers`, and the `auto` autosave can write player profiles again.** All four cleared the snapshot's dirty set before handing it to the persist plan, which gates profile writes on exactly that set — so the functions named "flush" flushed the server row and chunks only, and an `onNewPlayer` grant (starting money, starting items) never reached `jgPlayerProfiles`. `joinServer` now persists the snapshot it just marked; the flush paths mark every player dirty first, since hydrating from storage resets the dirty set. `planServerPersist` also clears `dirtyAt` once everything outstanding is written, so a successful flush stops autosave from re-firing forever, and a profile patch no longer clobbers a host-maintained `dirtyAt`.
|
|
68
|
+
|
|
37
69
|
## 0.15.0
|
|
38
70
|
|
|
39
71
|
### Migrate
|
|
@@ -340,6 +340,33 @@ export interface PresentationEffectsConfig {
|
|
|
340
340
|
shake?: boolean;
|
|
341
341
|
}
|
|
342
342
|
/** How a screenshot host reaches live gameplay in this game — the data behind `shoot --mode play`. */
|
|
343
|
+
/**
|
|
344
|
+
* One named framing in {@link GameCaptureConfig.views}: everything about a capture except the host-side
|
|
345
|
+
* viewport (`--device`, `--size`) and `--mode`, which the runner routes on before the game module loads.
|
|
346
|
+
* Every field mirrors the equivalent capture flag, and an explicit flag on the command line wins over the
|
|
347
|
+
* shot's value so a declared framing stays a starting point rather than a cage.
|
|
348
|
+
*/
|
|
349
|
+
export interface GameCaptureView {
|
|
350
|
+
/** One line naming what this shot is for — printed by `shoot <game> --list-views`. */
|
|
351
|
+
description?: string;
|
|
352
|
+
/** `--look`: `x,z`, `x,y,z`, `@marker:<id>`, or `@entity:<id>`. */
|
|
353
|
+
look?: string;
|
|
354
|
+
/** `--look-from`: `dist[,height[,angle]]`. */
|
|
355
|
+
lookFrom?: string;
|
|
356
|
+
/** `--spawn`: `x,y,z` or `x,y,z,yaw`, for a shot framed from the player's vantage instead of a detached camera. */
|
|
357
|
+
spawn?: string;
|
|
358
|
+
/** `--state`: a {@link GameCaptureConfig.states} entry to reach before capturing. */
|
|
359
|
+
state?: string;
|
|
360
|
+
/** `--run`: commands to dispatch before capturing, when the shot needs staging no declared state covers. */
|
|
361
|
+
run?: readonly string[];
|
|
362
|
+
/** `--settle`: milliseconds to wait before the frame, for a shot behind an animation. */
|
|
363
|
+
settleMs?: number;
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* How a game makes itself screenshottable: the commands that reach live play or a named screen, the
|
|
367
|
+
* framings worth re-capturing, and the progress metrics a bot playtest samples. Declaring these once
|
|
368
|
+
* is what lets a capture host reproduce a view instead of re-deriving it by hand every run.
|
|
369
|
+
*/
|
|
343
370
|
export interface GameCaptureConfig {
|
|
344
371
|
/**
|
|
345
372
|
* Commands run (in order, via `ctx.game.commands.run`) right after boot when a capture host requests the
|
|
@@ -363,6 +390,13 @@ export interface GameCaptureConfig {
|
|
|
363
390
|
name: string;
|
|
364
391
|
input?: unknown;
|
|
365
392
|
})[]>;
|
|
393
|
+
/**
|
|
394
|
+
* Named framings worth re-capturing — the views a reviewer actually asks about (`street`, `keep-exterior`,
|
|
395
|
+
* `boss-arena`), each pinning the camera, the staging, and the settle for that shot. `shoot <game> --view <name>`
|
|
396
|
+
* reproduces one exactly, so a before/after pair is the same framing by construction instead of two hand-typed
|
|
397
|
+
* flag sets that drifted. An unknown name fails the capture listing the declared views.
|
|
398
|
+
*/
|
|
399
|
+
views?: Record<string, GameCaptureView>;
|
|
366
400
|
/** Extra milliseconds the capture host waits after `play` commands before taking the play-mode screenshot — cover an intro cinematic or spawn-in fade. Default 2500. */
|
|
367
401
|
settleMs?: number;
|
|
368
402
|
/** Bot-playtest read hook: maps live game state to a flat vector of numeric progress metrics the `drive --playtest` harness samples over time (e.g. `{ x, z, score, phase }`). Genre-agnostic — "progress" is whatever the game reports; the engine stays neutral. The harness watches these numbers move under scripted input: any metric changing beyond an epsilon counts as forward progress, all metrics flat past the softlock threshold under active input flags a softlock. Return every value that should count as advancing; omit to opt the game out of the playtest rung. */
|
|
@@ -422,6 +456,8 @@ export interface PlayableGame<TUi = unknown, TWorldOverlay = unknown, TRenderEnt
|
|
|
422
456
|
statId?: string;
|
|
423
457
|
roles?: readonly CatalogEntityRole[];
|
|
424
458
|
maxDistance?: number;
|
|
459
|
+
/** Hide a bar when world geometry stands between the player and the entity. These bars are a screen-space overlay outside the depth buffer, so by default they draw through walls. Default false. */
|
|
460
|
+
occlude?: boolean;
|
|
425
461
|
};
|
|
426
462
|
/**
|
|
427
463
|
* Opt in to billboarded nameplates (name + optional HP bar) floating over non-local entities — the
|
package/dist/meta/changelog.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
|
|
2
|
-
export declare const VERSION = "0.
|
|
2
|
+
export declare const VERSION = "0.16.0";
|
|
3
3
|
/** One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md). */
|
|
4
4
|
export interface ChangelogEntry {
|
|
5
5
|
migrate: readonly string[];
|
package/dist/meta/changelog.js
CHANGED
|
@@ -1,7 +1,29 @@
|
|
|
1
1
|
/** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
|
|
2
|
-
export const VERSION = "0.
|
|
2
|
+
export const VERSION = "0.16.0";
|
|
3
3
|
/** Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`). */
|
|
4
4
|
export const CHANGELOG = {
|
|
5
|
+
"0.16.0": {
|
|
6
|
+
migrate: [
|
|
7
|
+
"Bump lockstep SDK packages to `^0.16.0`: `@jgengine/{core,react,ws,node,sql,convex,shell,editor,assets}`. CLI `jgengine` is `0.13.0`; `@jgengine/github` is `0.3.0`.",
|
|
8
|
+
"Placed objects now replicate. `objects` joined the always-on baseline snapshot modules, so `ctx.hydrate` replaces the client's placed object set with the host's, the way `entities` already did. A game whose peers all run the same authored placement is unaffected; a game that places props on the client only, after hydrate, must move that placement to the host or keep it out of `ctx.scene.object`.",
|
|
9
|
+
],
|
|
10
|
+
added: [
|
|
11
|
+
"`worldHealthBars.occlude` / `WorldHealthBars`+`WorldNameplates` `occlude` prop. World bars and nameplates are a screen-space overlay drawn outside the WebGL depth buffer, so nothing in the scene can hide them and an enemy's health bar reads straight through the building it stands behind. Set `occlude: true` to drop a bar when world geometry blocks the line from the player to the entity. Default `false` preserves the always-visible behaviour every shipped game currently relies on.",
|
|
12
|
+
"`DustField` + `WeatherLayer` mode `\"dust\"` (`@jgengine/shell/weather`) \u2014 wind-borne particulate. `WeatherLayer` could only do `clear`/`rain`/`snow`/`mixed`, so an arid, volcanic, ashen, or blown-out world had no way to put anything in the air. Dust drifts on horizontal wind with a slow vertical bob rather than falling; `groundBias` packs density toward the surface, motes fade near the top of the volume so recycled ones do not pop against the sky, and faster motes ride higher. Set `dustAlways` to keep it in the air alongside rain or snow instead of replacing them. Defaults `DEFAULT_DUST_COUNT`/`DEFAULT_DUST_DENSITY` sit beside the rain and snow pair.",
|
|
13
|
+
"Authored `city` volumes render their near-LOD facades with a `BuildingKit`. `CityRules.buildingKit` names a kit; a game registers the bound art with `registerBuildingKit(kit)` from `@jgengine/shell/structures/buildingKitRegistry` and the scene document carries only the name, so a city stays portable and free of model paths. Unset or unknown names keep the palette blocks. This puts the whole `city` primitive \u2014 not just the `building()` feature \u2014 on the kit path.",
|
|
14
|
+
"`BuildingKit` from `@jgengine/core/world/buildingKit` binds a generated building's facade part slots to real models, so procedural buildings render as an instanced asset kit instead of untextured blocks. `defineBuildingKit({ parts: { wall: [\"Wall_A.glb\", \u2026] } })` maps a part kind \u2014 or a `${facade}.${kind}` slot \u2014 to ordered variants that `kit.variant` indexes, with `fit`/`offset`/`rotation`/`scale`/`tint`/`autoOrient` per variant. Kinds a kit leaves unbound keep rendering the palette block, so a partial kit is a valid kit, and `omit` drops a kind entirely. Pass one through `building({ kit })` or straight to `InstancedBuildings`; `buildingKitVariantCounts(kit)` feeds `generateBuilding` so its seeded picks span exactly the models the kit binds.",
|
|
15
|
+
"`generateBuilding`, `generateBuildingDistrict`, and `createBuildingConfig` are now documented public API (`@jgengine/core/world/buildings`) and appear in `capabilities.md` under `building-generator`.",
|
|
16
|
+
"`capture.views` on `defineGame` \u2014 named framings (`GameCaptureView`: `look`, `lookFrom`, `spawn`, `state`, `run`, `settleMs`) that `shoot <game> --view <name>` and `drive --view <name>` replay exactly, so a before/after pair is the same view by construction. `shoot <game> --list-views` prints what a game declares; an unknown name fails listing them, and any explicit capture flag still wins over the view's value.",
|
|
17
|
+
"`modelLoadIdleMs()` from `@jgengine/shell/render/modelLoad` reports how long the shared GLB loader has been idle (`0` while a model is in flight). Capture hosts wait on it instead of guessing a settle delay, so an establishing shot no longer races streaming models.",
|
|
18
|
+
"Per-instance state on placed objects \u2014 `SceneObject.state` (opaque `Record`), set through `ctx.scene.object.setState`/`patchState`/`place({ state })`. It survives move/rotate/setVisual, replicates and saves with the object, and round-trips as `RuntimeObjectRow.flags` via `toRuntimeObjectRow`/`fromRuntimeObjectRow` (`@jgengine/core/multiplayer`). A chest's locked flag or a machine's fill level no longer needs a game-owned map keyed by `instanceId`.",
|
|
19
|
+
"`slotInventory` is a working feature \u2014 `ctx.scene.object.slots` gives objects whose catalog entry declares it real per-instance container contents: `layout`/`state`/`count`, `put`/`take`, and `transferIn`/`transferOut` between a player inventory and the object, all validated against the declared slot count, `accepts` kind filter, and stack limits, with neither side mutated on a rejection. Contents live on `SceneObject.slots`, so they replicate and save with the placement.",
|
|
20
|
+
"`loadServerSnapshot` and `persistServerSnapshot` are public (`@jgengine/convex/server`), and `createGameServerFunctions` also returns `helpers` (`loadSnapshot`, `applyCommand`, `persistSnapshot`, `runCommand`) bound to the same runtimes and auth mode. A host mutation can now pair snapshot work with its own table writes in one transaction; the registered `runCommand` mutation delegates to `helpers.runCommand`, so there is one implementation.",
|
|
21
|
+
],
|
|
22
|
+
changed: [
|
|
23
|
+
"`GeneratedBuilding`'s `kit` prop is now the `BuildingKit`; the per-part React escape hatch it used to name moved to `partRenderer`, so `kit` means the same thing on `GeneratedBuilding` and `InstancedBuildings`. Rename the prop if you passed a `BuildingKitRenderer`.",
|
|
24
|
+
],
|
|
25
|
+
removed: [],
|
|
26
|
+
},
|
|
5
27
|
"0.15.0": {
|
|
6
28
|
migrate: [
|
|
7
29
|
"Bump lockstep SDK packages to ^0.15.0: @jgengine/{core,react,ws,node,sql,convex,shell,editor,assets}. CLI jgengine is 0.12.0; @jgengine/github is 0.2.0.",
|
package/dist/multiplayer.d.ts
CHANGED
|
@@ -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";
|
package/dist/multiplayer.js
CHANGED
|
@@ -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;
|
|
@@ -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,6 +6,7 @@ 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";
|
|
@@ -316,6 +317,15 @@ export function createSceneSubsystem(d) {
|
|
|
316
317
|
return created;
|
|
317
318
|
}
|
|
318
319
|
const objectSelection = notifyAfter(createSelectionSet(), ["add", "remove", "toggle", "replace", "clear"], signalNotify);
|
|
320
|
+
let inventoryAccess = null;
|
|
321
|
+
const objectSlots = createObjectSlots({
|
|
322
|
+
objects,
|
|
323
|
+
layoutOf: (instanceId) => catalogObject(instanceId)?.slotInventory ?? null,
|
|
324
|
+
traits: () => inventoryAccess?.traits ?? { stackLimit: () => Number.POSITIVE_INFINITY },
|
|
325
|
+
hasInventory: (inventoryId) => inventoryAccess?.hasInventory(inventoryId) ?? false,
|
|
326
|
+
inventoryFor: (userId) => inventoryAccess?.inventoryFor(userId) ?? null,
|
|
327
|
+
notify: signalNotify,
|
|
328
|
+
});
|
|
319
329
|
const sceneObjects = {
|
|
320
330
|
...objects,
|
|
321
331
|
remove(instanceId) {
|
|
@@ -338,6 +348,7 @@ export function createSceneSubsystem(d) {
|
|
|
338
348
|
reportBounds: reportObjectBounds,
|
|
339
349
|
reportCollisionMesh: reportObjectCollisionMesh,
|
|
340
350
|
selection: objectSelection,
|
|
351
|
+
slots: objectSlots,
|
|
341
352
|
};
|
|
342
353
|
return {
|
|
343
354
|
entities,
|
|
@@ -372,5 +383,8 @@ export function createSceneSubsystem(d) {
|
|
|
372
383
|
setOnAfterDespawn(fn) {
|
|
373
384
|
onAfterDespawn = fn;
|
|
374
385
|
},
|
|
386
|
+
setInventoryAccess(access) {
|
|
387
|
+
inventoryAccess = access;
|
|
388
|
+
},
|
|
375
389
|
};
|
|
376
390
|
}
|
|
@@ -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
|
];
|
|
@@ -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,
|
|
@@ -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;
|
|
@@ -147,6 +147,8 @@ export function planServerPersist(server, snapshot, save, now) {
|
|
|
147
147
|
const leaderboard = drained.increments.map((entry) => entry.scope === "server" && entry.serverId === undefined
|
|
148
148
|
? { ...entry, serverId: server.serverId }
|
|
149
149
|
: entry);
|
|
150
|
+
const canWritePlayers = isSaveEnabled(save) && saveScopeIncludesPlayer(save.scope);
|
|
151
|
+
const canWriteChunks = isSaveEnabled(save) && saveScopeIncludesChunks(save.scope);
|
|
150
152
|
const sessionPlayers = {};
|
|
151
153
|
const profiles = [];
|
|
152
154
|
for (const userId of Object.keys(snapshot.players)) {
|
|
@@ -155,9 +157,7 @@ export function planServerPersist(server, snapshot, save, now) {
|
|
|
155
157
|
continue;
|
|
156
158
|
const { persistent, session } = splitProfilePlayer(player);
|
|
157
159
|
sessionPlayers[userId] = { ...persistent, session };
|
|
158
|
-
if (
|
|
159
|
-
saveScopeIncludesPlayer(save.scope) &&
|
|
160
|
-
snapshot.dirty.players.includes(userId)) {
|
|
160
|
+
if (canWritePlayers && snapshot.dirty.players.includes(userId)) {
|
|
161
161
|
profiles.push({
|
|
162
162
|
userId,
|
|
163
163
|
gameId: server.gameId,
|
|
@@ -169,7 +169,7 @@ export function planServerPersist(server, snapshot, save, now) {
|
|
|
169
169
|
}
|
|
170
170
|
const chunks = [];
|
|
171
171
|
const deletedChunks = [];
|
|
172
|
-
if (
|
|
172
|
+
if (canWriteChunks) {
|
|
173
173
|
for (const chunkKey of snapshot.dirty.chunks) {
|
|
174
174
|
const chunk = snapshot.chunks[chunkKey];
|
|
175
175
|
if (chunk) {
|
|
@@ -180,13 +180,17 @@ export function planServerPersist(server, snapshot, save, now) {
|
|
|
180
180
|
}
|
|
181
181
|
}
|
|
182
182
|
}
|
|
183
|
+
// Server state is written unconditionally, so only unwritable player/chunk dirt keeps the row dirty;
|
|
184
|
+
// anything else would make shouldAutoSave re-fire forever after a successful full flush.
|
|
185
|
+
const unwritten = (snapshot.dirty.players.length > 0 && !canWritePlayers) ||
|
|
186
|
+
(snapshot.dirty.chunks.length > 0 && !canWriteChunks);
|
|
183
187
|
return {
|
|
184
188
|
server: {
|
|
185
189
|
...server,
|
|
186
190
|
serverState,
|
|
187
191
|
sessionPlayers,
|
|
188
192
|
revision: snapshot.revision,
|
|
189
|
-
dirtyAt:
|
|
193
|
+
dirtyAt: unwritten ? (server.dirtyAt ?? now) : undefined,
|
|
190
194
|
updatedAt: now,
|
|
191
195
|
lastSavedAt: now,
|
|
192
196
|
},
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { SceneObject } from "../scene/objectStore.js";
|
|
2
|
+
import type { RuntimeObjectRow } from "./snapshot.js";
|
|
3
|
+
/**
|
|
4
|
+
* Convert a live placed object into its persisted row — the bridge between `ctx.scene.object` and
|
|
5
|
+
* `GameRuntimeSnapshot.server.objects`/`chunks[].objects`. Per-instance `state` is carried as
|
|
6
|
+
* `flags`. Presentation overrides (`visual`, `animation`) are catalog- and authoring-resolved and
|
|
7
|
+
* deliberately stay off the persisted row — put anything a command must own in `state`.
|
|
8
|
+
*
|
|
9
|
+
* @capability placed-object-rows a placed object's per-instance state and container contents convert to and from the persisted snapshot row a host stores
|
|
10
|
+
*/
|
|
11
|
+
export declare function toRuntimeObjectRow(object: SceneObject): RuntimeObjectRow;
|
|
12
|
+
/**
|
|
13
|
+
* Inverse of {@link toRuntimeObjectRow}: rebuild the live placed object a host persisted.
|
|
14
|
+
*
|
|
15
|
+
* @capability placed-object-rehydrate rebuild live placed objects, with their state and slot contents, from persisted snapshot rows
|
|
16
|
+
*/
|
|
17
|
+
export declare function fromRuntimeObjectRow(row: RuntimeObjectRow): SceneObject;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert a live placed object into its persisted row — the bridge between `ctx.scene.object` and
|
|
3
|
+
* `GameRuntimeSnapshot.server.objects`/`chunks[].objects`. Per-instance `state` is carried as
|
|
4
|
+
* `flags`. Presentation overrides (`visual`, `animation`) are catalog- and authoring-resolved and
|
|
5
|
+
* deliberately stay off the persisted row — put anything a command must own in `state`.
|
|
6
|
+
*
|
|
7
|
+
* @capability placed-object-rows a placed object's per-instance state and container contents convert to and from the persisted snapshot row a host stores
|
|
8
|
+
*/
|
|
9
|
+
export function toRuntimeObjectRow(object) {
|
|
10
|
+
return {
|
|
11
|
+
instanceId: object.instanceId,
|
|
12
|
+
catalogId: object.catalogId,
|
|
13
|
+
position: [object.position[0], object.position[1], object.position[2]],
|
|
14
|
+
rotationY: object.rotationY,
|
|
15
|
+
...(object.parentSpace !== undefined ? { parentSpace: object.parentSpace } : {}),
|
|
16
|
+
...(object.state !== undefined ? { flags: object.state } : {}),
|
|
17
|
+
...(object.slots !== undefined ? { slots: object.slots } : {}),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Inverse of {@link toRuntimeObjectRow}: rebuild the live placed object a host persisted.
|
|
22
|
+
*
|
|
23
|
+
* @capability placed-object-rehydrate rebuild live placed objects, with their state and slot contents, from persisted snapshot rows
|
|
24
|
+
*/
|
|
25
|
+
export function fromRuntimeObjectRow(row) {
|
|
26
|
+
return {
|
|
27
|
+
instanceId: row.instanceId,
|
|
28
|
+
catalogId: row.catalogId,
|
|
29
|
+
position: [row.position[0], row.position[1], row.position[2]],
|
|
30
|
+
rotationY: row.rotationY ?? 0,
|
|
31
|
+
...(row.parentSpace !== undefined ? { parentSpace: row.parentSpace } : {}),
|
|
32
|
+
...(row.flags !== undefined ? { state: row.flags } : {}),
|
|
33
|
+
...(row.slots !== undefined ? { slots: row.slots } : {}),
|
|
34
|
+
};
|
|
35
|
+
}
|