@jgengine/core 0.12.0 → 0.14.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 (94) hide show
  1. package/CHANGELOG.md +73 -1
  2. package/dist/combat/effects.d.ts +11 -4
  3. package/dist/combat/effects.js +24 -17
  4. package/dist/combat/magazine.d.ts +13 -0
  5. package/dist/combat/magazine.js +27 -0
  6. package/dist/combat/vfxPresets.d.ts +176 -0
  7. package/dist/combat/vfxPresets.js +74 -0
  8. package/dist/combat/weaponFire.d.ts +114 -0
  9. package/dist/combat/weaponFire.js +96 -0
  10. package/dist/combat.d.ts +4 -2
  11. package/dist/combat.js +3 -1
  12. package/dist/devtools/urlFlags.d.ts +22 -0
  13. package/dist/devtools/urlFlags.js +62 -0
  14. package/dist/game/clipRoles.d.ts +51 -0
  15. package/dist/game/clipRoles.js +142 -0
  16. package/dist/game/defineGame.d.ts +2 -2
  17. package/dist/game/partAnimation.d.ts +78 -0
  18. package/dist/game/partAnimation.js +147 -0
  19. package/dist/game/playableGame.d.ts +38 -5
  20. package/dist/game/progression.d.ts +2 -2
  21. package/dist/game/progression.js +4 -2
  22. package/dist/inventory/inventoryModel.d.ts +24 -8
  23. package/dist/inventory/inventoryModel.js +47 -8
  24. package/dist/meta/changelog.d.ts +1 -1
  25. package/dist/meta/changelog.js +45 -1
  26. package/dist/runtime/context/combat.js +2 -1
  27. package/dist/runtime/context/combatFx.js +15 -5
  28. package/dist/runtime/context/scene.d.ts +4 -1
  29. package/dist/runtime/context/scene.js +86 -1
  30. package/dist/runtime/gameContext.js +51 -4
  31. package/dist/runtime/gameContextTypes.d.ts +58 -4
  32. package/dist/runtime/headlessRunner.d.ts +13 -0
  33. package/dist/runtime/headlessRunner.js +14 -0
  34. package/dist/scene/assetCatalog.d.ts +2 -0
  35. package/dist/scene/builtinSceneKinds.d.ts +6 -1
  36. package/dist/scene/builtinSceneKinds.js +10 -3
  37. package/dist/scene/colliders.d.ts +41 -0
  38. package/dist/scene/colliders.js +140 -0
  39. package/dist/scene/collisionMesh.d.ts +8 -0
  40. package/dist/scene/collisionMesh.js +48 -0
  41. package/dist/scene/entityStats.js +9 -1
  42. package/dist/scene/sceneKinds.d.ts +19 -0
  43. package/dist/stats/statModifiers.d.ts +16 -0
  44. package/dist/stats/statModifiers.js +30 -1
  45. package/dist/stats/statPool.js +5 -2
  46. package/dist/ui/hudScale.d.ts +16 -1
  47. package/dist/ui/hudScale.js +22 -1
  48. package/dist/ui/panelModel.d.ts +120 -0
  49. package/dist/ui/panelModel.js +168 -0
  50. package/dist/ui.d.ts +1 -0
  51. package/dist/ui.js +1 -0
  52. package/dist/world/buildingLots.d.ts +84 -0
  53. package/dist/world/buildingLots.js +119 -0
  54. package/dist/world/buildings.d.ts +6 -0
  55. package/dist/world/cityGenerator.d.ts +41 -0
  56. package/dist/world/cityGenerator.js +58 -0
  57. package/dist/world/cityKind.d.ts +2 -2
  58. package/dist/world/cityKind.js +12 -6
  59. package/dist/world/environmentSummary.js +57 -1
  60. package/dist/world/features.d.ts +35 -2
  61. package/dist/world/features.js +5 -2
  62. package/dist/world/grassKind.js +4 -1
  63. package/dist/world/roads.d.ts +19 -2
  64. package/dist/world/roads.js +60 -3
  65. package/dist/world/scatterCoverage.d.ts +98 -0
  66. package/dist/world/scatterCoverage.js +101 -0
  67. package/dist/world/scatterRegion.js +6 -0
  68. package/dist/world/{pathNetwork.d.ts → streetGenerator.d.ts} +37 -37
  69. package/dist/world/{pathNetwork.js → streetGenerator.js} +9 -9
  70. package/dist/world/streets.d.ts +74 -1
  71. package/dist/world/streets.js +218 -0
  72. package/dist/world/terrain.d.ts +20 -1
  73. package/dist/world/terrain.js +41 -2
  74. package/dist/world.d.ts +1 -0
  75. package/dist/world.js +1 -0
  76. package/package.json +2 -2
  77. package/skills/jgengine/SKILL.md +5 -2
  78. package/skills/jgengine/api.md +13 -5
  79. package/skills/jgengine-combat/SKILL.md +15 -1
  80. package/skills/jgengine-combat/api.md +39 -2
  81. package/skills/jgengine-combat/capabilities.md +17 -1
  82. package/skills/jgengine-combat/recipes/portable-damage-effects.md +197 -0
  83. package/skills/jgengine-combat/recipes/portable-runtime-state.md +154 -0
  84. package/skills/jgengine-combat/recipes/portable-weapon-plumbing.md +156 -0
  85. package/skills/jgengine-combat/recipes/visible-attacks.md +81 -0
  86. package/skills/jgengine-combat/reference.md +22 -3
  87. package/skills/jgengine-gameplay/SKILL.md +8 -0
  88. package/skills/jgengine-gameplay/api.md +35 -5
  89. package/skills/jgengine-gameplay/capabilities.md +18 -0
  90. package/skills/jgengine-gameplay/recipes/portable-xp-leveling.md +168 -0
  91. package/skills/jgengine-gameplay/reference-systems.md +9 -0
  92. package/skills/jgengine-world/api.md +82 -25
  93. package/skills/jgengine-world/capabilities.md +12 -4
  94. package/skills/jgengine-world/reference.md +1 -1
@@ -2,7 +2,8 @@ import { createBodyBind } from "../../scene/bodyBind.js";
2
2
  import { createEntityStatsApi, seedStatValues, } from "../../scene/entityStats.js";
3
3
  import { createEntityStore, } from "../../scene/entityStore.js";
4
4
  import { createForms } from "../../scene/form.js";
5
- import { fittedEntityColliders, fittedObjectColliders, scaledEntityColliders, scaledObjectColliders, } from "../../scene/colliders.js";
5
+ import { fittedEntityColliders, fittedObjectColliders, hasMeshColliderShape, measuredEntityColliders, measuredObjectColliders, meshEntityColliders, meshObjectColliders, scaledEntityColliders, scaledObjectColliders, } from "../../scene/colliders.js";
6
+ import { prepareCollisionMeshSource } from "../../scene/collisionMesh.js";
6
7
  import { raycastObjects, raycastObjectsAll } from "../../scene/objectQuery.js";
7
8
  import { createObjectStore, objectVisualScale } from "../../scene/objectStore.js";
8
9
  import { createPaintLayer } from "../../scene/paintLayer.js";
@@ -82,6 +83,68 @@ export function createSceneSubsystem(d) {
82
83
  fittedObjectByCatalogId.set(catalogId, fitted);
83
84
  return fitted;
84
85
  }
86
+ const measuredEntityByKind = new Map();
87
+ const measuredObjectByCatalogId = new Map();
88
+ function reportEntityBounds(kind, bounds) {
89
+ if (bounds === null) {
90
+ const existed = measuredEntityByKind.delete(kind);
91
+ if (existed)
92
+ signalNotify();
93
+ return existed;
94
+ }
95
+ const measured = measuredEntityColliders(bounds);
96
+ if (measured === null)
97
+ return false;
98
+ measuredEntityByKind.set(kind, measured);
99
+ signalNotify();
100
+ return true;
101
+ }
102
+ const meshEntityByKind = new Map();
103
+ const meshObjectByCatalogId = new Map();
104
+ function reportEntityCollisionMesh(kind, mesh) {
105
+ if (mesh === null) {
106
+ const existed = meshEntityByKind.delete(kind);
107
+ if (existed)
108
+ signalNotify();
109
+ return existed;
110
+ }
111
+ const prepared = prepareCollisionMeshSource(mesh);
112
+ const set = prepared === null ? null : meshEntityColliders(prepared);
113
+ if (set === null)
114
+ return false;
115
+ meshEntityByKind.set(kind, set);
116
+ signalNotify();
117
+ return true;
118
+ }
119
+ function reportObjectCollisionMesh(catalogId, mesh) {
120
+ if (mesh === null) {
121
+ const existed = meshObjectByCatalogId.delete(catalogId);
122
+ if (existed)
123
+ signalNotify();
124
+ return existed;
125
+ }
126
+ const prepared = prepareCollisionMeshSource(mesh);
127
+ const set = prepared === null ? null : meshObjectColliders(prepared);
128
+ if (set === null)
129
+ return false;
130
+ meshObjectByCatalogId.set(catalogId, set);
131
+ signalNotify();
132
+ return true;
133
+ }
134
+ function reportObjectBounds(catalogId, bounds) {
135
+ if (bounds === null) {
136
+ const existed = measuredObjectByCatalogId.delete(catalogId);
137
+ if (existed)
138
+ signalNotify();
139
+ return existed;
140
+ }
141
+ const measured = measuredObjectColliders(bounds);
142
+ if (measured === null)
143
+ return false;
144
+ measuredObjectByCatalogId.set(catalogId, measured);
145
+ signalNotify();
146
+ return true;
147
+ }
85
148
  function entityCollidersOf(instanceId) {
86
149
  const override = entityColliders.get(instanceId);
87
150
  if (override !== undefined)
@@ -92,8 +155,18 @@ export function createSceneSubsystem(d) {
92
155
  const kind = entities.get(instanceId)?.name;
93
156
  if (kind !== undefined) {
94
157
  const fitted = fittedEntitySetFor(kind);
158
+ // Index-derived mesh shapes win (identical on a headless host); otherwise a runtime-reported
159
+ // collision mesh upgrades the fitted/measured bounding box to the rendered triangles.
160
+ if (fitted !== null && hasMeshColliderShape(fitted))
161
+ return fitted;
162
+ const runtimeMesh = meshEntityByKind.get(kind);
163
+ if (runtimeMesh !== undefined)
164
+ return runtimeMesh;
95
165
  if (fitted !== null)
96
166
  return fitted;
167
+ const measured = measuredEntityByKind.get(kind);
168
+ if (measured !== undefined)
169
+ return measured;
97
170
  }
98
171
  const scale = entry?.scale;
99
172
  if (scale !== undefined && scale !== 1)
@@ -116,8 +189,16 @@ export function createSceneSubsystem(d) {
116
189
  if (object === null)
117
190
  return null;
118
191
  const fitted = fittedObjectSetFor(object.catalogId);
192
+ if (fitted !== null && hasMeshColliderShape(fitted))
193
+ return fitted;
194
+ const runtimeMesh = meshObjectByCatalogId.get(object.catalogId);
195
+ if (runtimeMesh !== undefined)
196
+ return runtimeMesh;
119
197
  if (fitted !== null)
120
198
  return fitted;
199
+ const measured = measuredObjectByCatalogId.get(object.catalogId);
200
+ if (measured !== undefined)
201
+ return measured;
121
202
  if (object.visual?.scale === undefined)
122
203
  return null;
123
204
  return scaledObjectColliders(objectVisualScale(object.visual));
@@ -254,6 +335,8 @@ export function createSceneSubsystem(d) {
254
335
  signalNotify();
255
336
  },
256
337
  collidersOf: objectCollidersOf,
338
+ reportBounds: reportObjectBounds,
339
+ reportCollisionMesh: reportObjectCollisionMesh,
257
340
  selection: objectSelection,
258
341
  };
259
342
  return {
@@ -272,6 +355,8 @@ export function createSceneSubsystem(d) {
272
355
  objectColliders,
273
356
  entityCollidersOf,
274
357
  objectCollidersOf,
358
+ reportEntityBounds,
359
+ reportEntityCollisionMesh,
275
360
  entityVisualScaleOf,
276
361
  forms,
277
362
  paintLayer,
@@ -6,6 +6,7 @@ import { createGameFeed } from "../game/feed.js";
6
6
  import { setGamePhase } from "../game/gamePhase.js";
7
7
  import { createLootRegistry, grantDrops } from "../game/lootTable.js";
8
8
  import { createSocial } from "../game/social.js";
9
+ import { splitStack } from "../inventory/inventoryModel.js";
9
10
  import { createItemUse } from "../item/use.js";
10
11
  import { createWeaponStats } from "../item/weapon.js";
11
12
  import {} from "../scene/entityStats.js";
@@ -55,7 +56,7 @@ export function createGameContext(options) {
55
56
  ...(occluder !== undefined ? { occluder } : {}),
56
57
  ...(options.models !== undefined ? { models: options.models } : {}),
57
58
  });
58
- const { entities, objects, statsByInstance, entityStats, ensureInstanceStats, catalogEntry, spatial, combatSpatial, sceneRaycast, targeting, entityColliders, entityCollidersOf, objectCollidersOf, entityVisualScaleOf, forms, paintLayer, spawnEntity, despawnEntity, moveEntityTowardCommit, resetAllToSpawn, bind, sceneObjects, } = scene;
59
+ const { entities, objects, statsByInstance, entityStats, ensureInstanceStats, catalogEntry, spatial, combatSpatial, sceneRaycast, targeting, entityColliders, entityCollidersOf, objectCollidersOf, reportEntityBounds, reportEntityCollisionMesh, entityVisualScaleOf, forms, paintLayer, spawnEntity, despawnEntity, moveEntityTowardCommit, resetAllToSpawn, bind, sceneObjects, } = scene;
59
60
  // --- Shared game services (weapon, feed, features bag, commands, item use) ---
60
61
  const weapon = createWeaponStats((itemId) => content.itemById?.(itemId));
61
62
  const feed = createGameFeed(definition.feed);
@@ -114,6 +115,45 @@ export function createGameContext(options) {
114
115
  });
115
116
  const { inventoryIds, inventoryByUser, inventoryFor, layouts, wallets, walletOf, economy, putIntoAnyInventory, playerStatsFor, pose, possession, motionByUser, motionFor, loadouts, seedUserPool, } = playerSys;
116
117
  scene.setOnAfterDespawn((instanceId) => pose.clear(instanceId));
118
+ // Built-in inventory mutation commands so a HUD grid drives moves/splits through the notifying
119
+ // command path (a raw InventorySet mutation would not re-render React). Each resolves the acting
120
+ // user's own inventory, so it respects `privatePerUser` replication.
121
+ const knownInventory = (inventoryId) => layouts[inventoryId] !== undefined;
122
+ const slotInRange = (inventoryId, slot) => typeof slot === "number" && Number.isInteger(slot) && slot >= 0 && slot < layouts[inventoryId].slots;
123
+ commandRegistry.define("inventory.move", {
124
+ validate(_state, input) {
125
+ if (!knownInventory(input.inventoryId))
126
+ return { reason: `unknown inventory "${input.inventoryId}"` };
127
+ if (!slotInRange(input.inventoryId, input.from) || !slotInRange(input.inventoryId, input.to)) {
128
+ return { reason: "invalid-slot" };
129
+ }
130
+ return null;
131
+ },
132
+ apply(_state, input) {
133
+ const inventory = inventoryFor(activeUserId());
134
+ // A rejected move (empty source, wrong-kind, no-space) is a harmless no-op: the item bounces back.
135
+ inventory.move(input.inventoryId, input.from, input.inventoryId, input.to);
136
+ },
137
+ });
138
+ commandRegistry.define("inventory.split", {
139
+ validate(_state, input) {
140
+ if (!knownInventory(input.inventoryId))
141
+ return { reason: `unknown inventory "${input.inventoryId}"` };
142
+ if (!slotInRange(input.inventoryId, input.slot))
143
+ return { reason: "invalid-slot" };
144
+ if (input.toSlot !== undefined && !slotInRange(input.inventoryId, input.toSlot)) {
145
+ return { reason: "invalid-slot" };
146
+ }
147
+ return null;
148
+ },
149
+ apply(_state, input) {
150
+ const inventory = inventoryFor(activeUserId());
151
+ const result = splitStack(inventory.state(input.inventoryId), input.slot, input.amount, input.toSlot);
152
+ if (result.status === "rejected")
153
+ return;
154
+ inventory.replaceState(input.inventoryId, result.state);
155
+ },
156
+ });
117
157
  const loot = {
118
158
  register: lootRegistry.register,
119
159
  has: lootRegistry.has,
@@ -328,6 +368,8 @@ export function createGameContext(options) {
328
368
  signal.notify();
329
369
  },
330
370
  collidersOf: entityCollidersOf,
371
+ reportBounds: reportEntityBounds,
372
+ reportCollisionMesh: reportEntityCollisionMesh,
331
373
  visualScaleOf: entityVisualScaleOf,
332
374
  form: forms,
333
375
  paint: paintLayer,
@@ -472,16 +514,21 @@ function saveKey(name) {
472
514
  const slug = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
473
515
  return `jgengine:save:${slug === "" ? "game" : slug}`;
474
516
  }
475
- /** Explicit `options.save` wins (the shell can inject any backend, e.g. cloud); otherwise `defineGame({ persist })` auto-wires a localStorage save for offline/single-player worlds only. */
517
+ /**
518
+ * Explicit `options.save` wins (the shell can inject any backend, e.g. cloud); otherwise an offline /
519
+ * single-player world auto-wires a `localStorage` whole-world save **by default** — a game gets `ctx.game.save`
520
+ * and autosave without touching `persist`. Only an explicit `persist: false` opts out; a host-authoritative
521
+ * multiplayer world never persists to `localStorage` (its host owns persistence) regardless of `persist`.
522
+ */
476
523
  function resolveSaveOptions(definition, options) {
477
524
  if (options.save !== undefined)
478
525
  return options.save;
479
526
  const persist = definition.persist;
480
- if (persist === undefined || persist === false)
527
+ if (persist === false)
481
528
  return undefined;
482
529
  if (!isOffline(definition.multiplayer))
483
530
  return undefined;
484
- const config = persist === true ? {} : persist;
531
+ const config = persist === undefined || persist === true ? {} : persist;
485
532
  return {
486
533
  backend: config.storage === "memory" ? memorySaveBackend() : localSaveBackend(),
487
534
  mode: config.mode,
@@ -2,6 +2,7 @@ import type { CardPile, CardPileConfig } from "../cards/cardPile.js";
2
2
  import type { EffectInput, EffectResult, EffectVia, ReceiveMap, SingleTargetEffectInput } from "../combat/effects.js";
3
3
  import type { ProjectileSystem } from "../combat/projectiles.js";
4
4
  import type { HitReaction, HitReactionConfig, ImpactPresetName } from "../combat/hitReaction.js";
5
+ import type { VfxPresetName } from "../combat/vfxPresets.js";
5
6
  import type { TelegraphShape } from "../combat/telegraph.js";
6
7
  import type { CommandDefinition, CommandResult } from "../commands/commandRegistry.js";
7
8
  import type { ChargeOptions as WalletChargeOptions } from "../economy/wallet.js";
@@ -33,7 +34,8 @@ import type { PaintLayer } from "../scene/paintLayer.js";
33
34
  import type { EntityStatsApi, StatCatalog } from "../scene/entityStats.js";
34
35
  import type { EntityBlackboard, EntityPose, EntityPosition, EntityStore, SceneEntity, SpawnOptions, SpawnPose } from "../scene/entityStore.js";
35
36
  import type { Forms } from "../scene/form.js";
36
- import type { EntityColliderSet, ModelBodySource } from "../scene/colliders.js";
37
+ import type { EntityColliderSet, MeasuredBounds, ModelBodySource } from "../scene/colliders.js";
38
+ import type { CollisionMeshSource } from "../scene/collisionMesh.js";
37
39
  import type { ObjectRaycastHit, ObjectRaycastInput } from "../scene/objectQuery.js";
38
40
  import type { ObjectStore } from "../scene/objectStore.js";
39
41
  import type { Roster } from "../scene/roster.js";
@@ -135,6 +137,24 @@ export interface SceneObjectContext extends ObjectStore {
135
137
  raycastAll(input: ObjectRaycastInput): readonly ObjectRaycastHit[];
136
138
  setColliders(instanceId: string, colliders: EntityColliderSet | null): void;
137
139
  collidersOf(instanceId: string): EntityColliderSet | null;
140
+ /**
141
+ * Report the entity-local AABB the renderer actually mounted for `catalogId` — stored per catalog
142
+ * id as a blocking physical body and consulted after authored `colliders` and index-`dims` fitted
143
+ * models, before the visual-scale fallback. The shell reports custom `renderObject` content and
144
+ * dims-less models automatically, so a placed prop's body wraps its real rendered shape instead of
145
+ * a unit-cube guess. Pass `null` to clear. Returns `false` when the bounds are degenerate.
146
+ * @capability measured-colliders hitboxes and blocking bodies wrap what the renderer actually mounted — custom render props and dims-less models stop falling back to fixed-size boxes.
147
+ */
148
+ reportBounds(catalogId: string, bounds: MeasuredBounds | null): boolean;
149
+ /**
150
+ * Report the entity-local triangle soup the renderer actually mounted for `catalogId` — stored per
151
+ * catalog id as a blocking physical body whose raycasts hit the real surface (a BVH over the
152
+ * triangles) while movement obstruction keeps the conservative AABB. Consulted right after
153
+ * authored `colliders` / index collision meshes and before the fitted-box, measured-bounds, and
154
+ * visual-scale fallbacks. The shell reports rendered models automatically. Pass `null` to clear.
155
+ * Returns `false` when no valid triangle survives.
156
+ */
157
+ reportCollisionMesh(catalogId: string, mesh: CollisionMeshSource | null): boolean;
138
158
  /**
139
159
  * Per-instance selection/highlight state for placed objects — the reactive counterpart to
140
160
  * `worldHealthBars`/`nameplates` (entities), so a build-mode or RTS selection ring reads
@@ -156,10 +176,22 @@ export interface FloatTextInput {
156
176
  crit?: boolean;
157
177
  scale?: number;
158
178
  }
159
- /** Request a transient spell/ability VFX burst; `from`/`to` accept an instance id or a world point, `color` is a `0xRRGGBB` tint, and `durationMs` defaults per `kind`. */
179
+ /**
180
+ * Request a transient spell/ability VFX burst. The easy path is a named `preset` —
181
+ * `vfx({ preset: "arrow", from: caster, to: enemy })` renders a visible bolt with no color
182
+ * or archetype tuning; `"lightning"`, `"web"`, `"slash"`, `"shield"`, `"heal"`, `"explosion"`
183
+ * and the rest of {@link vfxPresets} likewise just work. `from`/`to` accept an instance id
184
+ * (the shell follows its live pose) or a fixed world point. Anything you also pass — `kind`,
185
+ * `color` (`0xRRGGBB`), `radius`, `durationMs` — overrides the preset; supply `kind` + `color`
186
+ * yourself for a fully custom burst with no preset. `durationMs` defaults per `kind`.
187
+ */
160
188
  export interface VfxInput {
161
- kind: VfxKind;
162
- color: number;
189
+ /** A named visual flavor from {@link vfxPresets}. Fills in `kind`/`color`/defaults; explicit fields below still win. */
190
+ preset?: VfxPresetName | (string & {});
191
+ /** Visual archetype. Optional when `preset` is given; required otherwise. */
192
+ kind?: VfxKind;
193
+ /** `0xRRGGBB` tint. Optional when `preset` is given; required otherwise. */
194
+ color?: number;
163
195
  from?: string | readonly [number, number, number];
164
196
  to?: string | readonly [number, number, number];
165
197
  radius?: number;
@@ -242,6 +274,28 @@ export interface SceneEntityContext {
242
274
  invalidateSpatial: SpatialApi["invalidate"];
243
275
  setColliders(instanceId: string, colliders: EntityColliderSet | null): void;
244
276
  collidersOf(instanceId: string): EntityColliderSet | null;
277
+ /**
278
+ * Report the entity-local AABB the renderer actually mounted for `kind` — stored per kind as a
279
+ * tight damage hitbox and consulted after authored `colliders` and index-`dims` fitted models,
280
+ * before the scale/humanoid-default fallbacks. The shell reports custom `renderEntity` content and
281
+ * dims-less models automatically, so hitboxes wrap the rendered mesh instead of the fixed
282
+ * 0.7×1.8×0.7 box. Runtime-measured on the client; authoritative hosts that never render should
283
+ * author `colliders` or model `dims` instead. Pass `null` to clear. Returns `false` when the
284
+ * bounds are degenerate.
285
+ * @capability measured-hitboxes an entity rendered with a custom mesh takes hits in a box matching its rendered bounds, not the fixed humanoid rectangle.
286
+ */
287
+ reportBounds(kind: string, bounds: MeasuredBounds | null): boolean;
288
+ /**
289
+ * Report the entity-local triangle soup the renderer actually mounted for `kind` — stored per kind
290
+ * as a damage hitbox that raycasts the model's own triangles (BVH), so shots aimed between a
291
+ * character's feet or past its shoulder miss instead of hitting a bounding box. Consulted right
292
+ * after authored `colliders` / index collision meshes and before the fitted-box and
293
+ * measured-bounds fallbacks. The shell reports rendered models automatically; authoritative hosts
294
+ * that never render should author `colliders` or index collision meshes for identical results.
295
+ * Pass `null` to clear. Returns `false` when no valid triangle survives.
296
+ * @capability conforming-hitboxes shots aimed between a character's feet or past its shoulder MISS — damage raycasts hit the rendered model's triangles, not its bounding box.
297
+ */
298
+ reportCollisionMesh(kind: string, mesh: CollisionMeshSource | null): boolean;
245
299
  /** Uniform visual scale from the entity's catalog entry (1 when unscaled). */
246
300
  visualScaleOf(instanceId: string): number;
247
301
  form: Forms;
@@ -36,6 +36,17 @@ export interface HeadlessRunnerOptions<TAssetRef extends ModelAssetRef, TMultipl
36
36
  /** Render-model lookup for collider auto-fit — pass what the shell would derive from `entityModels`/`objectModels` so headless raycasts hit the same boxes as play. */
37
37
  models?: GameContextModels;
38
38
  }
39
+ /** Canvas-free command/intent surface for {@link HeadlessRunner} — invoke a game's registered UI commands and read the resulting reactive state off `ctx`. */
40
+ export interface HeadlessCommandInterface {
41
+ /** Invoke a registered command/intent by name against the live context. Throws on `unknown-command` or a validation rejection so a headless UI-flow test fails loudly instead of silently no-op'ing. */
42
+ invoke(name: string, input?: unknown): void;
43
+ /** Non-throwing {@link invoke} returning the raw command result (inspect `status`). */
44
+ tryInvoke(name: string, input?: unknown): ReturnType<GameContext["game"]["commands"]["run"]>;
45
+ /** Registered command names. */
46
+ names(): string[];
47
+ /** Is a command registered? */
48
+ has(name: string): boolean;
49
+ }
39
50
  /**
40
51
  * A renderer-free driver for a game loop: builds a {@link GameContext} from a {@link GameDefinition}, runs the init
41
52
  * hooks, then advances the simulation one step at a time from injected input. No React, R3F, or three.js — the whole
@@ -58,5 +69,7 @@ export interface HeadlessRunner {
58
69
  step(dtSeconds: number, input?: HeadlessInput): number;
59
70
  /** Publish input without advancing — for pre-seeding the held set before the first {@link step}. */
60
71
  publishInput(input: HeadlessInput): void;
72
+ /** Drive the game's own UI commands / intents headlessly — the same registry `ctx.game.commands` and the shell's command sink dispatch into. No renderer, no pointer simulation: dispatch an intent, then read reactive state off `ctx`. */
73
+ readonly ui: HeadlessCommandInterface;
61
74
  }
62
75
  export declare function createHeadlessRunner<TAssetRef extends ModelAssetRef, TMultiplayer>(options: HeadlessRunnerOptions<TAssetRef, TMultiplayer>): HeadlessRunner;
@@ -29,6 +29,20 @@ export function createHeadlessRunner(options) {
29
29
  input: ctx.input,
30
30
  userId: player.userId,
31
31
  publishInput,
32
+ ui: {
33
+ invoke(name, input) {
34
+ const result = ctx.game.commands.run(name, input);
35
+ if (result.status === "unknown-command") {
36
+ throw new Error(`headless ui.invoke: unknown command "${name}" — define it via ctx.game.commands.define`);
37
+ }
38
+ if (result.status === "rejected") {
39
+ throw new Error(`headless ui.invoke: command "${name}" rejected — ${result.reason}`);
40
+ }
41
+ },
42
+ tryInvoke: (name, input) => ctx.game.commands.run(name, input),
43
+ names: () => ctx.game.commands.names(),
44
+ has: (name) => ctx.game.commands.has(name),
45
+ },
32
46
  step(dtSeconds, input) {
33
47
  if (input !== undefined)
34
48
  publishInput(input);
@@ -22,6 +22,8 @@ export interface ModelAssetRef {
22
22
  collisionMesh?: CollisionMeshData;
23
23
  /** Authored asset-space metadata — canonical facing (degrees), source-unit scale, footprint, anchor, bounds, and rotation policy. The upstream owner of placement corrections; see {@link "scene/assetSpace".AssetSpace}. */
24
24
  space?: AssetSpace;
25
+ /** Animation clip names read from the GLB at asset reindex. Lets the shell auto-animate rigged catalog assets (semantic clip roles → default idle/walk/run + one-shots) with no game-side clip strings. */
26
+ clips?: readonly string[];
25
27
  }
26
28
  /**
27
29
  * A catalog entry backed by a registered {@link registerAssetGenerator} instead of a GLB URL — a
@@ -1,5 +1,10 @@
1
1
  import { type ParamSchema } from "./sceneKinds.js";
2
- /** The scatter/foliage region schema — the fields the inspector exposed as hand-written `ScatterFields`. */
2
+ /**
3
+ * The scatter/foliage region schema. `palette` (what fills the area) leads the schema body and
4
+ * `density` is the single coverage slider hoisted into the shared `CoverageSection` (see the kind's
5
+ * `coverage` descriptor), so a scatter region reads as Area → Assets → Density like every other
6
+ * scatterable kind; the rest is kind-specific tuning.
7
+ */
3
8
  export declare const SCATTER_SCHEMA: ParamSchema;
4
9
  /** Idempotently registers the engine's built-in environment scene kinds + generators. @internal */
5
10
  export declare function registerBuiltinSceneKinds(): void;
@@ -15,10 +15,17 @@ import { registerSoilKind } from "../world/soilKind.js";
15
15
  import { registerPoleLineKind } from "../world/poleLineKind.js";
16
16
  import { registerBuildingGenerator } from "../world/buildingGenerator.js";
17
17
  import { registerCityKind } from "../world/cityKind.js";
18
+ import { describeScatterCoverage, densityCoverage } from "../world/scatterCoverage.js";
18
19
  import { registerSceneKind } from "./sceneKinds.js";
19
- /** The scatter/foliage region schema — the fields the inspector exposed as hand-written `ScatterFields`. */
20
+ /**
21
+ * The scatter/foliage region schema. `palette` (what fills the area) leads the schema body and
22
+ * `density` is the single coverage slider hoisted into the shared `CoverageSection` (see the kind's
23
+ * `coverage` descriptor), so a scatter region reads as Area → Assets → Density like every other
24
+ * scatterable kind; the rest is kind-specific tuning.
25
+ */
20
26
  export const SCATTER_SCHEMA = {
21
27
  fields: [
28
+ { type: "weightedList", key: "palette", label: "species (weighted)", itemLabel: "grass / tree id", default: [{ item: "grass", weight: 1 }] },
22
29
  { type: "range", key: "density", label: "density", min: 0, max: 2, step: 0.01, default: 0.15, unit: "/m²" },
23
30
  { type: "number", key: "minSpacing", label: "spacing", step: 0.25, default: 1.5, min: 0 },
24
31
  { type: "number", key: "minScale", label: "min scale", step: 0.05, default: 0.8, min: 0 },
@@ -27,7 +34,6 @@ export const SCATTER_SCHEMA = {
27
34
  { type: "number", key: "edgeFalloff", label: "edge fade", step: 0.5, default: 0, min: 0 },
28
35
  { type: "bool", key: "alignToNormal", label: "align to slope", default: false },
29
36
  { type: "bool", key: "autoAvoid", label: "auto-avoid gameplay spots", default: true },
30
- { type: "weightedList", key: "palette", label: "species (weighted)", itemLabel: "grass / tree id", default: [{ item: "grass", weight: 1 }] },
31
37
  { type: "seed", key: "seed", label: "seed", default: "" },
32
38
  ],
33
39
  };
@@ -45,6 +51,7 @@ export function registerBuiltinSceneKinds() {
45
51
  addCategory: "Studios",
46
52
  accent: "#34d399",
47
53
  schema: SCATTER_SCHEMA,
54
+ coverage: { spec: "scatter", densityKey: "density", assetsKey: "palette" },
48
55
  note: (object, params) => {
49
56
  const points = object.points ?? [];
50
57
  if (points.length < 3)
@@ -55,7 +62,7 @@ export function registerBuiltinSceneKinds() {
55
62
  }
56
63
  area = Math.abs(area) / 2;
57
64
  const density = typeof params["density"] === "number" ? params["density"] : 0;
58
- return `≈ ${Math.floor(area * density).toLocaleString()} placements over ${Math.round(area).toLocaleString()} m²`;
65
+ return describeScatterCoverage(densityCoverage("scatter", area, density));
59
66
  },
60
67
  });
61
68
  registerWaterKind();
@@ -107,6 +107,47 @@ export declare function fittedEntityColliders(model: ModelBodySource): EntityCol
107
107
  * @internal
108
108
  */
109
109
  export declare function fittedObjectColliders(model: ModelBodySource): EntityColliderSet | null;
110
+ /**
111
+ * Entity-local AABB of what the renderer actually mounted for a kind — the runtime counterpart to
112
+ * index-measured `dims`. Reported through `ctx.scene.entity.reportBounds` /
113
+ * `ctx.scene.object.reportBounds` (the shell does this automatically for custom
114
+ * `renderEntity`/`renderObject` content and models without index dims) so collider resolution can
115
+ * wrap the real rendered shape instead of a fixed-size guess.
116
+ */
117
+ export interface MeasuredBounds {
118
+ min: EntityPosition;
119
+ max: EntityPosition;
120
+ }
121
+ /** Damage hitbox exactly wrapping runtime-measured render bounds — what `reportBounds` stores for an
122
+ * entity kind. `null` when the bounds are degenerate, keeping the caller's existing fallback.
123
+ * @internal
124
+ */
125
+ export declare function measuredEntityColliders(bounds: MeasuredBounds): EntityColliderSet | null;
126
+ /** Blocking physical body exactly wrapping runtime-measured render bounds — what `reportBounds`
127
+ * stores for an object catalog id. `null` when the bounds are degenerate.
128
+ * @internal
129
+ */
130
+ export declare function measuredObjectColliders(bounds: MeasuredBounds): EntityColliderSet | null;
131
+ /** Damage hitbox raycasting the actual triangles the renderer mounted for an entity kind — what
132
+ * `reportCollisionMesh` stores. Triangles are entity-local (scale/placement already applied), so the
133
+ * shape composes with identity `meshScale`/`meshTranslate`; bounds/broadphase read the clamped AABB.
134
+ * `null` when the mesh is degenerate, keeping the caller's existing fallback.
135
+ * @capability conforming-hitboxes shots aimed between a character's feet or past its shoulder MISS — the damage hitbox is the rendered model's own triangles, not a bounding box.
136
+ * @internal
137
+ */
138
+ export declare function meshEntityColliders(mesh: PreparedCollisionMesh): EntityColliderSet | null;
139
+ /** Blocking physical body raycasting the actual triangles the renderer mounted for an object catalog
140
+ * id — the mesh-accurate counterpart to `measuredObjectColliders`. Movement obstruction stays on the
141
+ * conservative AABB (no `boxes` decomposition at runtime); rays hit the real surface. `null` when
142
+ * degenerate.
143
+ * @internal
144
+ */
145
+ export declare function meshObjectColliders(mesh: PreparedCollisionMesh): EntityColliderSet | null;
146
+ /** True when any collider in the set already raycasts real triangles (index-derived mesh shapes win
147
+ * over runtime-measured ones — they are identical on a headless host).
148
+ * @internal
149
+ */
150
+ export declare function hasMeshColliderShape(set: EntityColliderSet | null | undefined): boolean;
110
151
  /** @internal */
111
152
  export declare function resolveColliders(set: EntityColliderSet | null | undefined): ResolvedCollider[];
112
153
  /** @internal */
@@ -188,6 +188,146 @@ export function fittedObjectColliders(model) {
188
188
  },
189
189
  };
190
190
  }
191
+ /** Thin measured axes (decals, blades, flat panels) keep at least this half-extent so the box stays hittable. */
192
+ const MIN_MEASURED_HALF_EXTENT = 0.01;
193
+ /** Tight AABB from measured bounds — `null` when inverted, non-finite, or a degenerate point. */
194
+ function measuredBox(bounds) {
195
+ const { min, max } = bounds;
196
+ let volume = 0;
197
+ for (let axis = 0; axis < 3; axis += 1) {
198
+ const lo = min[axis];
199
+ const hi = max[axis];
200
+ if (!Number.isFinite(lo) || !Number.isFinite(hi) || hi < lo)
201
+ return null;
202
+ volume = Math.max(volume, hi - lo);
203
+ }
204
+ if (!(volume > 0))
205
+ return null;
206
+ return {
207
+ halfExtents: [
208
+ Math.max((max[0] - min[0]) / 2, MIN_MEASURED_HALF_EXTENT),
209
+ Math.max((max[1] - min[1]) / 2, MIN_MEASURED_HALF_EXTENT),
210
+ Math.max((max[2] - min[2]) / 2, MIN_MEASURED_HALF_EXTENT),
211
+ ],
212
+ offset: [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2],
213
+ };
214
+ }
215
+ /** Damage hitbox exactly wrapping runtime-measured render bounds — what `reportBounds` stores for an
216
+ * entity kind. `null` when the bounds are degenerate, keeping the caller's existing fallback.
217
+ * @internal
218
+ */
219
+ export function measuredEntityColliders(bounds) {
220
+ const box = measuredBox(bounds);
221
+ if (box === null)
222
+ return null;
223
+ return {
224
+ hitboxes: [
225
+ {
226
+ name: "body",
227
+ purpose: "damage",
228
+ shape: { kind: "aabb", halfExtents: box.halfExtents, offset: box.offset },
229
+ damageEligible: true,
230
+ blocks: false,
231
+ },
232
+ ],
233
+ };
234
+ }
235
+ /** Blocking physical body exactly wrapping runtime-measured render bounds — what `reportBounds`
236
+ * stores for an object catalog id. `null` when the bounds are degenerate.
237
+ * @internal
238
+ */
239
+ export function measuredObjectColliders(bounds) {
240
+ const box = measuredBox(bounds);
241
+ if (box === null)
242
+ return null;
243
+ return {
244
+ body: {
245
+ name: "body",
246
+ purpose: "physical",
247
+ shape: { kind: "aabb", halfExtents: box.halfExtents, offset: box.offset },
248
+ damageEligible: false,
249
+ blocks: true,
250
+ },
251
+ };
252
+ }
253
+ /** Root-node AABB of a prepared mesh (the BVH root bounds the whole soup) as a clamped
254
+ * entity-local box — `null` when degenerate. */
255
+ function preparedMeshShape(mesh) {
256
+ const bounds = mesh.nodeBounds;
257
+ if (bounds.length < 6)
258
+ return null;
259
+ const min = [bounds[0], bounds[1], bounds[2]];
260
+ const max = [bounds[3], bounds[4], bounds[5]];
261
+ const box = measuredBox({ min, max });
262
+ if (box === null)
263
+ return null;
264
+ return {
265
+ kind: "mesh",
266
+ mesh,
267
+ meshScale: 1,
268
+ meshTranslate: [0, 0, 0],
269
+ halfExtents: box.halfExtents,
270
+ offset: box.offset,
271
+ };
272
+ }
273
+ /** Damage hitbox raycasting the actual triangles the renderer mounted for an entity kind — what
274
+ * `reportCollisionMesh` stores. Triangles are entity-local (scale/placement already applied), so the
275
+ * shape composes with identity `meshScale`/`meshTranslate`; bounds/broadphase read the clamped AABB.
276
+ * `null` when the mesh is degenerate, keeping the caller's existing fallback.
277
+ * @capability conforming-hitboxes shots aimed between a character's feet or past its shoulder MISS — the damage hitbox is the rendered model's own triangles, not a bounding box.
278
+ * @internal
279
+ */
280
+ export function meshEntityColliders(mesh) {
281
+ const shape = preparedMeshShape(mesh);
282
+ if (shape === null)
283
+ return null;
284
+ return {
285
+ hitboxes: [
286
+ {
287
+ name: "body",
288
+ purpose: "damage",
289
+ shape,
290
+ damageEligible: true,
291
+ blocks: false,
292
+ },
293
+ ],
294
+ };
295
+ }
296
+ /** Blocking physical body raycasting the actual triangles the renderer mounted for an object catalog
297
+ * id — the mesh-accurate counterpart to `measuredObjectColliders`. Movement obstruction stays on the
298
+ * conservative AABB (no `boxes` decomposition at runtime); rays hit the real surface. `null` when
299
+ * degenerate.
300
+ * @internal
301
+ */
302
+ export function meshObjectColliders(mesh) {
303
+ const shape = preparedMeshShape(mesh);
304
+ if (shape === null)
305
+ return null;
306
+ return {
307
+ body: {
308
+ name: "body",
309
+ purpose: "physical",
310
+ shape,
311
+ damageEligible: false,
312
+ blocks: true,
313
+ },
314
+ };
315
+ }
316
+ /** True when any collider in the set already raycasts real triangles (index-derived mesh shapes win
317
+ * over runtime-measured ones — they are identical on a headless host).
318
+ * @internal
319
+ */
320
+ export function hasMeshColliderShape(set) {
321
+ if (set === null || set === undefined)
322
+ return false;
323
+ if (set.body?.shape.kind === "mesh")
324
+ return true;
325
+ for (const hitbox of set.hitboxes ?? []) {
326
+ if (hitbox.shape.kind === "mesh")
327
+ return true;
328
+ }
329
+ return false;
330
+ }
191
331
  /** @internal */
192
332
  export function resolveColliders(set) {
193
333
  if (set === null || set === undefined)
@@ -77,6 +77,14 @@ export declare function voxelizeToBoxes(source: CollisionMeshSource, cellSize?:
77
77
  min: [number, number, number];
78
78
  max: [number, number, number];
79
79
  }[];
80
+ /**
81
+ * Build a prepared BVH mesh straight from a runtime triangle soup (float positions + indices) —
82
+ * the renderer-fed counterpart to {@link prepareCollisionMesh}, feeding
83
+ * `ctx.scene.entity.reportCollisionMesh` / `ctx.scene.object.reportCollisionMesh` without a
84
+ * quantize/encode round-trip. Copies and sanitizes the input: triangles referencing out-of-range,
85
+ * non-finite, or repeated vertices are dropped. Returns `null` when no triangle survives.
86
+ */
87
+ export declare function prepareCollisionMeshSource(source: CollisionMeshSource): PreparedCollisionMesh | null;
80
88
  /**
81
89
  * Decode a stored collision mesh and index it with a BVH. Memoized per data object, so the shell and
82
90
  * a headless host preparing the same catalog entry share one build. Returns `null` (also memoized)