@jgengine/core 0.11.0 → 0.12.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 (219) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/ai/populationDirector.d.ts +103 -0
  3. package/dist/ai/populationDirector.js +190 -0
  4. package/dist/ai/pursuit.d.ts +56 -0
  5. package/dist/ai/pursuit.js +53 -0
  6. package/dist/authoring.d.ts +1 -1
  7. package/dist/authoring.js +1 -1
  8. package/dist/combat.d.ts +1 -0
  9. package/dist/combat.js +1 -0
  10. package/dist/devtools/controls.d.ts +38 -0
  11. package/dist/devtools/controls.js +181 -0
  12. package/dist/devtools/devtools.d.ts +5 -197
  13. package/dist/devtools/devtools.js +38 -774
  14. package/dist/devtools/discover.d.ts +32 -0
  15. package/dist/devtools/discover.js +240 -0
  16. package/dist/devtools/fallbackSeams.d.ts +50 -0
  17. package/dist/devtools/fallbackSeams.js +111 -0
  18. package/dist/devtools/frame.d.ts +15 -0
  19. package/dist/devtools/frame.js +247 -0
  20. package/dist/devtools/logs.d.ts +15 -0
  21. package/dist/devtools/logs.js +86 -0
  22. package/dist/devtools/overrides.d.ts +16 -0
  23. package/dist/devtools/overrides.js +66 -0
  24. package/dist/devtools/types.d.ts +212 -0
  25. package/dist/devtools/types.js +1 -0
  26. package/dist/economy/auctionBook.d.ts +122 -0
  27. package/dist/economy/auctionBook.js +213 -0
  28. package/dist/economy/priceHistory.d.ts +44 -0
  29. package/dist/economy/priceHistory.js +89 -0
  30. package/dist/economy/unlockPoints.d.ts +92 -0
  31. package/dist/economy/unlockPoints.js +103 -0
  32. package/dist/editor/commandApply.d.ts +5 -0
  33. package/dist/editor/commandApply.js +755 -0
  34. package/dist/editor/commands.d.ts +48 -3
  35. package/dist/editor/commands.js +40 -754
  36. package/dist/editor/directives.d.ts +94 -0
  37. package/dist/editor/directives.js +206 -0
  38. package/dist/editor/document.d.ts +37 -2
  39. package/dist/editor/document.js +422 -6
  40. package/dist/editor/entityCatalog.d.ts +42 -0
  41. package/dist/editor/entityCatalog.js +101 -0
  42. package/dist/editor/environment.d.ts +74 -0
  43. package/dist/editor/environment.js +169 -0
  44. package/dist/editor/index.d.ts +26 -1
  45. package/dist/editor/index.js +8 -1
  46. package/dist/editor/minimap.d.ts +31 -0
  47. package/dist/editor/minimap.js +73 -0
  48. package/dist/editor/streamer.d.ts +66 -0
  49. package/dist/editor/streamer.js +68 -0
  50. package/dist/editor/types.d.ts +143 -0
  51. package/dist/editor/world.d.ts +136 -0
  52. package/dist/editor/world.js +303 -0
  53. package/dist/game/breeding.d.ts +161 -0
  54. package/dist/game/breeding.js +181 -0
  55. package/dist/game/cameraConfig.d.ts +451 -0
  56. package/dist/game/cameraConfig.js +1 -0
  57. package/dist/game/defineGame.d.ts +16 -9
  58. package/dist/game/defineGame.js +14 -6
  59. package/dist/game/defineSystem.d.ts +2 -0
  60. package/dist/game/defineSystem.js +2 -0
  61. package/dist/game/events.d.ts +28 -0
  62. package/dist/game/playableGame.d.ts +34 -435
  63. package/dist/game/playableGame.js +1 -1
  64. package/dist/game/progression.d.ts +35 -0
  65. package/dist/game/progression.js +19 -0
  66. package/dist/game/quest.js +1 -3
  67. package/dist/game/tribe.d.ts +184 -0
  68. package/dist/game/tribe.js +432 -0
  69. package/dist/gameplay.d.ts +3 -1
  70. package/dist/gameplay.js +3 -1
  71. package/dist/inventory/encumbrance.d.ts +69 -0
  72. package/dist/inventory/encumbrance.js +68 -0
  73. package/dist/item/durability.js +1 -3
  74. package/dist/meta/changelog.d.ts +1 -1
  75. package/dist/meta/changelog.js +24 -1
  76. package/dist/movement/movementModel.d.ts +15 -1
  77. package/dist/movement/movementModel.js +33 -15
  78. package/dist/movement/playerMovement.js +102 -2
  79. package/dist/multiplayer/combatSnapshot.js +13 -8
  80. package/dist/multiplayer/presenceModel.d.ts +2 -0
  81. package/dist/multiplayer/presenceModel.js +8 -0
  82. package/dist/physics/drivableVehicle.d.ts +2 -0
  83. package/dist/physics/drivableVehicle.js +1 -1
  84. package/dist/physics/flightDynamics.d.ts +82 -0
  85. package/dist/physics/flightDynamics.js +165 -0
  86. package/dist/physics/gravityField.d.ts +25 -0
  87. package/dist/physics/gravityField.js +41 -0
  88. package/dist/physics/kinematicVehicle.d.ts +87 -0
  89. package/dist/physics/kinematicVehicle.js +193 -11
  90. package/dist/physics/physicsWorld.d.ts +26 -0
  91. package/dist/physics/physicsWorld.js +109 -117
  92. package/dist/physics/vehicleObstacles.d.ts +44 -0
  93. package/dist/physics/vehicleObstacles.js +115 -0
  94. package/dist/random/rng.d.ts +4 -1
  95. package/dist/random/rng.js +4 -1
  96. package/dist/runtime/adapter.d.ts +13 -0
  97. package/dist/runtime/adapter.js +13 -0
  98. package/dist/runtime/context/combat.d.ts +51 -0
  99. package/dist/runtime/context/combat.js +130 -0
  100. package/dist/runtime/context/deathLoot.d.ts +30 -0
  101. package/dist/runtime/context/deathLoot.js +39 -0
  102. package/dist/runtime/context/player.d.ts +48 -0
  103. package/dist/runtime/context/player.js +125 -0
  104. package/dist/runtime/context/scene.d.ts +59 -0
  105. package/dist/runtime/context/scene.js +291 -0
  106. package/dist/runtime/context/snapshotCodecs.d.ts +12 -0
  107. package/dist/runtime/context/snapshotCodecs.js +26 -0
  108. package/dist/runtime/descriptors/baseline.js +26 -3
  109. package/dist/runtime/descriptors/features.d.ts +5 -0
  110. package/dist/runtime/descriptors/features.js +20 -2
  111. package/dist/runtime/gameContext.d.ts +2 -428
  112. package/dist/runtime/gameContext.js +88 -501
  113. package/dist/runtime/gameContextTypes.d.ts +467 -0
  114. package/dist/runtime/gameContextTypes.js +1 -0
  115. package/dist/runtime/headlessRunner.d.ts +4 -2
  116. package/dist/runtime/headlessRunner.js +1 -0
  117. package/dist/runtime/hostPolicy.d.ts +51 -0
  118. package/dist/runtime/hostPolicy.js +68 -0
  119. package/dist/runtime/hostedGameRunner.d.ts +3 -1
  120. package/dist/runtime/hostedGameRunner.js +1 -0
  121. package/dist/runtime/hostedWorldSession.d.ts +3 -1
  122. package/dist/runtime/hostedWorldSession.js +1 -0
  123. package/dist/runtime/worldSnapshot.d.ts +13 -1
  124. package/dist/runtime/worldSnapshot.js +15 -3
  125. package/dist/scene/assetCatalog.d.ts +5 -0
  126. package/dist/scene/assetSpace.js +1 -3
  127. package/dist/scene/authoredTriggers.d.ts +50 -0
  128. package/dist/scene/authoredTriggers.js +114 -0
  129. package/dist/scene/builtinSceneKinds.js +8 -3
  130. package/dist/scene/colliders.d.ts +58 -0
  131. package/dist/scene/colliders.js +108 -0
  132. package/dist/scene/collisionMesh.d.ts +94 -0
  133. package/dist/scene/collisionMesh.js +699 -0
  134. package/dist/scene/companion.d.ts +147 -0
  135. package/dist/scene/companion.js +193 -0
  136. package/dist/scene/entityStats.d.ts +7 -9
  137. package/dist/scene/entityStats.js +10 -14
  138. package/dist/scene/sceneKinds.d.ts +16 -0
  139. package/dist/scene/sceneKinds.js +4 -0
  140. package/dist/scene/sceneRaycast.js +15 -0
  141. package/dist/stats/spawnLevelStats.d.ts +110 -0
  142. package/dist/stats/spawnLevelStats.js +140 -0
  143. package/dist/stats/statPool.d.ts +70 -0
  144. package/dist/stats/statPool.js +59 -0
  145. package/dist/store/changeSignal.d.ts +3 -0
  146. package/dist/store/changeSignal.js +2 -0
  147. package/dist/store/observableKeyedStore.d.ts +2 -0
  148. package/dist/store/observableKeyedStore.js +1 -0
  149. package/dist/survival/decayMeter.js +1 -3
  150. package/dist/survival/regionHealth.js +1 -3
  151. package/dist/ui/hudLayout.d.ts +5 -0
  152. package/dist/ui/hudLayout.js +17 -1
  153. package/dist/world/authoredEntities.d.ts +29 -0
  154. package/dist/world/authoredEntities.js +29 -0
  155. package/dist/world/authoredObjects.d.ts +15 -1
  156. package/dist/world/authoredObjects.js +13 -1
  157. package/dist/world/authoredSpawn.d.ts +39 -0
  158. package/dist/world/authoredSpawn.js +37 -0
  159. package/dist/world/buildSockets.d.ts +152 -0
  160. package/dist/world/buildSockets.js +188 -0
  161. package/dist/world/cityBlocks.d.ts +152 -0
  162. package/dist/world/cityBlocks.js +670 -0
  163. package/dist/world/cityContent.d.ts +77 -0
  164. package/dist/world/cityContent.js +261 -0
  165. package/dist/world/cityGeometry.d.ts +114 -0
  166. package/dist/world/cityGeometry.js +573 -0
  167. package/dist/world/cityKind.d.ts +314 -0
  168. package/dist/world/cityKind.js +1578 -0
  169. package/dist/world/features.d.ts +18 -3
  170. package/dist/world/features.js +10 -1
  171. package/dist/world/grassKind.js +2 -2
  172. package/dist/world/markers.d.ts +50 -4
  173. package/dist/world/markers.js +49 -0
  174. package/dist/world/massing.js +1 -1
  175. package/dist/world/minimapBake.d.ts +78 -0
  176. package/dist/world/minimapBake.js +208 -0
  177. package/dist/world/minimapTrack.d.ts +17 -0
  178. package/dist/world/minimapTrack.js +22 -0
  179. package/dist/world/pathNetwork.d.ts +159 -0
  180. package/dist/world/pathNetwork.js +684 -0
  181. package/dist/world/place.d.ts +166 -0
  182. package/dist/world/place.js +96 -0
  183. package/dist/world/placeAsset.js +3 -1
  184. package/dist/world/poleLineKind.d.ts +40 -0
  185. package/dist/world/poleLineKind.js +97 -0
  186. package/dist/world/polyline.js +9 -2
  187. package/dist/world/resourceNode.d.ts +109 -0
  188. package/dist/world/resourceNode.js +140 -0
  189. package/dist/world/scatter.js +2 -20
  190. package/dist/world/streets.d.ts +29 -0
  191. package/dist/world/streets.js +55 -0
  192. package/dist/world/terraform.js +58 -88
  193. package/dist/world/terrain.d.ts +45 -0
  194. package/dist/world/terrain.js +78 -0
  195. package/dist/world.d.ts +14 -5
  196. package/dist/world.js +12 -3
  197. package/package.json +1 -1
  198. package/skills/game-design/SKILL.md +28 -0
  199. package/skills/game-design/agents/openai.yaml +4 -0
  200. package/skills/game-design/references/application-playbook.md +170 -0
  201. package/skills/game-design/references/field-guide.md +288 -0
  202. package/skills/jgengine/SKILL.md +7 -4
  203. package/skills/jgengine/api.md +172 -79
  204. package/skills/jgengine/capabilities.md +104 -2
  205. package/skills/jgengine/recipes/minimal-game.md +88 -0
  206. package/skills/jgengine-combat/SKILL.md +5 -1
  207. package/skills/jgengine-combat/api.md +36 -0
  208. package/skills/jgengine-combat/capabilities.md +44 -0
  209. package/skills/jgengine-combat/recipes/portable-stat-pools.md +122 -0
  210. package/skills/jgengine-combat/reference.md +12 -2
  211. package/skills/jgengine-gameplay/api.md +152 -26
  212. package/skills/jgengine-gameplay/capabilities.md +78 -2
  213. package/skills/jgengine-gameplay/reference-systems.md +1 -5
  214. package/skills/jgengine-world/SKILL.md +13 -0
  215. package/skills/jgengine-world/api.md +419 -15
  216. package/skills/jgengine-world/capabilities.md +108 -2
  217. package/skills/jgengine-world/recipes/commanding-entities.md +128 -0
  218. package/skills/jgengine-world/reference.md +43 -4
  219. package/skills/jgengine/reference-cartridge.md +0 -72
@@ -0,0 +1,140 @@
1
+ import { clamp } from "../math/scalar.js";
2
+ function resolveQuantity(quantity, rng) {
3
+ if (typeof quantity === "number")
4
+ return quantity;
5
+ const [lo, hi] = quantity;
6
+ return lo + rng() * (hi - lo);
7
+ }
8
+ function toState(node) {
9
+ return {
10
+ id: node.def.id,
11
+ budget: node.budget,
12
+ maxBudget: node.maxBudget,
13
+ depleted: node.depleted,
14
+ respawnTimer: node.respawnTimer,
15
+ respawnBlocked: node.respawnBlocked,
16
+ fraction: node.maxBudget > 0 ? clamp(node.budget / node.maxBudget, 0, 1) : 0,
17
+ };
18
+ }
19
+ /**
20
+ * A field of depletable, respawning harvest nodes with tool-dependent yields — the generic rock / tree /
21
+ * bush / vein / corpse. Each node holds a finite budget across one or more resource kinds. A harvest applies
22
+ * a {@link ToolProfile} whose per-resource biases decide *which* resources and *how much* the same node
23
+ * returns (pick favors ore, hatchet favors wood), scaled by a creature/harvester multiplier and optional
24
+ * deterministic rng variance; the budget drops by the tool's power. When a node empties it starts a respawn
25
+ * timer that `tick(dt)` counts down and refills — unless respawn is suppressed by the per-node flag or the
26
+ * injected predicate (a structure claimed the spot). All state is plain and serializable for save-load and
27
+ * multiplayer sync via {@link snapshot}/{@link hydrate}.
28
+ *
29
+ * @capability resource-node depletable, respawning harvest nodes with tool-dependent yields
30
+ */
31
+ export function createResourceNodeField(config) {
32
+ const rng = config.rng ?? Math.random;
33
+ const nodes = new Map();
34
+ const order = [];
35
+ for (const def of config.nodes) {
36
+ if (nodes.has(def.id))
37
+ throw new Error(`duplicate resource node "${def.id}"`);
38
+ const respawnBudget = def.respawnBudget ?? def.budget;
39
+ nodes.set(def.id, {
40
+ def,
41
+ budget: def.budget,
42
+ maxBudget: def.budget,
43
+ respawnBudget,
44
+ depleted: def.budget <= 0,
45
+ respawnTimer: 0,
46
+ respawnBlocked: false,
47
+ });
48
+ order.push(def.id);
49
+ }
50
+ const getNode = (id) => {
51
+ const node = nodes.get(id);
52
+ if (node === undefined)
53
+ throw new Error(`unknown resource node "${id}"`);
54
+ return node;
55
+ };
56
+ const isSuppressed = (node) => {
57
+ if (node.respawnBlocked)
58
+ return true;
59
+ if (config.respawnSuppressed === undefined)
60
+ return false;
61
+ return config.respawnSuppressed(toState(node));
62
+ };
63
+ return {
64
+ harvest(nodeId, tool, options = {}) {
65
+ const node = getNode(nodeId);
66
+ if (node.depleted || node.budget <= 0) {
67
+ return { nodeId, granted: [], spent: 0, harvested: false, depleted: node.depleted };
68
+ }
69
+ const power = tool.power !== undefined && tool.power > 0 ? tool.power : 1;
70
+ const spent = Math.min(power, node.budget);
71
+ const multiplier = options.multiplier ?? 1;
72
+ const defaultBias = tool.defaultBias ?? 1;
73
+ const granted = [];
74
+ for (const resource of node.def.resources) {
75
+ // Resolve variance for every resource so the rng stream stays independent of tool choice.
76
+ const base = resolveQuantity(resource.amount, rng);
77
+ const bias = tool.biases?.[resource.kind] ?? defaultBias;
78
+ const amount = base * bias * multiplier;
79
+ if (amount > 0)
80
+ granted.push({ kind: resource.kind, amount });
81
+ }
82
+ node.budget -= spent;
83
+ let depleted = false;
84
+ if (node.budget <= 0) {
85
+ node.budget = 0;
86
+ node.depleted = true;
87
+ depleted = true;
88
+ node.respawnTimer = node.def.respawn ?? 0;
89
+ }
90
+ return { nodeId, granted, spent, harvested: true, depleted };
91
+ },
92
+ tick(dt) {
93
+ if (dt <= 0)
94
+ return;
95
+ for (const id of order) {
96
+ const node = nodes.get(id);
97
+ if (!node.depleted || node.def.respawn === undefined)
98
+ continue;
99
+ if (isSuppressed(node))
100
+ continue;
101
+ node.respawnTimer -= dt;
102
+ if (node.respawnTimer <= 0) {
103
+ node.budget = node.respawnBudget;
104
+ node.depleted = false;
105
+ node.respawnTimer = 0;
106
+ }
107
+ }
108
+ },
109
+ state(nodeId) {
110
+ return toState(getNode(nodeId));
111
+ },
112
+ isDepleted(nodeId) {
113
+ return getNode(nodeId).depleted;
114
+ },
115
+ setRespawnBlocked(nodeId, blocked) {
116
+ getNode(nodeId).respawnBlocked = blocked;
117
+ },
118
+ ids() {
119
+ return order;
120
+ },
121
+ snapshot() {
122
+ const out = {};
123
+ for (const id of order)
124
+ out[id] = toState(nodes.get(id));
125
+ return out;
126
+ },
127
+ hydrate(snapshot) {
128
+ for (const id of order) {
129
+ const saved = snapshot[id];
130
+ if (saved === undefined)
131
+ continue;
132
+ const node = nodes.get(id);
133
+ node.budget = saved.budget;
134
+ node.depleted = saved.depleted;
135
+ node.respawnTimer = saved.respawnTimer;
136
+ node.respawnBlocked = saved.respawnBlocked;
137
+ }
138
+ },
139
+ };
140
+ }
@@ -1,4 +1,5 @@
1
1
  import { expandAabb, pointInAabb } from "./geometry.js";
2
+ import { seededRng } from "../random/rng.js";
2
3
  function isAabb(area) {
3
4
  return (typeof area.minX === "number" &&
4
5
  typeof area.minZ === "number" &&
@@ -15,25 +16,6 @@ export function scatterAabb(area) {
15
16
  const halfD = area.d / 2;
16
17
  return { minX: cx - halfW, minZ: cz - halfD, maxX: cx + halfW, maxZ: cz + halfD };
17
18
  }
18
- function hashSeed(seed) {
19
- const text = typeof seed === "number" ? seed.toString() : seed;
20
- let h = 2166136261 >>> 0;
21
- for (let i = 0; i < text.length; i++) {
22
- h ^= text.charCodeAt(i);
23
- h = Math.imul(h, 16777619);
24
- }
25
- return h >>> 0;
26
- }
27
- function mulberry32(state) {
28
- let a = state >>> 0;
29
- return () => {
30
- a = (a + 0x6d2b79f5) >>> 0;
31
- let t = a;
32
- t = Math.imul(t ^ (t >>> 15), t | 1);
33
- t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
34
- return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
35
- };
36
- }
37
19
  export function scatter(config) {
38
20
  const aabb = scatterAabb(config.area);
39
21
  const width = aabb.maxX - aabb.minX;
@@ -47,7 +29,7 @@ export function scatter(config) {
47
29
  const jitter = config.jitter ?? 1;
48
30
  const maxAttempts = config.maxAttempts ?? target * 30;
49
31
  const avoid = (config.avoid ?? []).map((a) => (avoidMargin !== 0 ? expandAabb(a, avoidMargin) : a));
50
- const rng = mulberry32(hashSeed(config.seed ?? "scatter"));
32
+ const rng = seededRng(config.seed ?? "scatter");
51
33
  const useDistance = minDistance > 0;
52
34
  const buckets = new Map();
53
35
  const bucketKey = (x, z) => `${Math.floor(x / minDistance)}:${Math.floor(z / minDistance)}`;
@@ -73,6 +73,35 @@ export interface ParkingSpot {
73
73
  * coordinates in the middle of the carriageway.
74
74
  */
75
75
  export declare function parkingSpots(road: RoadEnvironmentDescriptor, options?: ParkingSpotOptions): readonly ParkingSpot[];
76
+ /**
77
+ * Signed planar distance from `(x, z)` to the nearest road *surface edge* across every segment of
78
+ * every road (#1051): negative inside the asphalt (magnitude = how far in from the closest edge, so
79
+ * `-width/2` on the centerline), `~0` right at the curb, and positive off the road (the gap to the
80
+ * nearest edge). Point-to-segment distance minus the road's half width, minimised over all segments —
81
+ * the "am I on the road, and by how much" seam driving grip, tyre audio, and off-road penalties.
82
+ *
83
+ * Allocation-free per call (only scalars — no arrays or objects) so a per-tick car sim can sample it
84
+ * on the hot path; degenerate roads (fewer than two path points) contribute nothing, and with no road
85
+ * surface at all it returns {@link Number.POSITIVE_INFINITY} (everywhere is off-road).
86
+ */
87
+ export declare function distanceToRoadEdge(roads: readonly RoadEnvironmentDescriptor[], x: number, z: number): number;
88
+ /** Grip levels and blend width for {@link roadSurfaceSampler}; each field is optional and defaulted. */
89
+ export interface RoadSurfaceOptions {
90
+ /** Grip multiplier on the asphalt. Default 1. */
91
+ onRoad?: number;
92
+ /** Grip multiplier once fully off the road past the shoulder. Default 0.72. */
93
+ offRoad?: number;
94
+ /** Width (world units) of the blend band outside the curb from full to off-road grip. Default 3. */
95
+ shoulder?: number;
96
+ }
97
+ /**
98
+ * A `surfaceFriction`-compatible sampler (the `(x, z) => grip` hook a kinematic car reads each tick)
99
+ * that turns road geometry into grip (#1051): full `onRoad` grip on the asphalt, a monotonic linear ramp
100
+ * down across a `shoulder`-wide band at the curb, and flat `offRoad` grip beyond it — so a car that
101
+ * clips onto the grass loses bite progressively instead of snapping. Closes over {@link
102
+ * distanceToRoadEdge}, so the returned sampler is itself allocation-free per call.
103
+ */
104
+ export declare function roadSurfaceSampler(roads: readonly RoadEnvironmentDescriptor[], options?: RoadSurfaceOptions): (x: number, z: number) => number;
76
105
  /**
77
106
  * A deterministic point on one of a road's sidewalks at a normalized position — `side` picks the
78
107
  * band, `fraction` (0..1) picks how far along. The canonical pedestrian spawn helper.
@@ -130,6 +130,61 @@ export function parkingSpots(road, options = {}) {
130
130
  }
131
131
  return spots;
132
132
  }
133
+ /**
134
+ * Signed planar distance from `(x, z)` to the nearest road *surface edge* across every segment of
135
+ * every road (#1051): negative inside the asphalt (magnitude = how far in from the closest edge, so
136
+ * `-width/2` on the centerline), `~0` right at the curb, and positive off the road (the gap to the
137
+ * nearest edge). Point-to-segment distance minus the road's half width, minimised over all segments —
138
+ * the "am I on the road, and by how much" seam driving grip, tyre audio, and off-road penalties.
139
+ *
140
+ * Allocation-free per call (only scalars — no arrays or objects) so a per-tick car sim can sample it
141
+ * on the hot path; degenerate roads (fewer than two path points) contribute nothing, and with no road
142
+ * surface at all it returns {@link Number.POSITIVE_INFINITY} (everywhere is off-road).
143
+ */
144
+ export function distanceToRoadEdge(roads, x, z) {
145
+ let best = Number.POSITIVE_INFINITY;
146
+ for (let r = 0; r < roads.length; r += 1) {
147
+ const road = roads[r];
148
+ const path = road.path;
149
+ if (path.length < 2)
150
+ continue;
151
+ const half = road.width / 2;
152
+ for (let i = 0; i < path.length - 1; i += 1) {
153
+ const a = path[i];
154
+ const b = path[i + 1];
155
+ const abx = b[0] - a[0];
156
+ const abz = b[1] - a[1];
157
+ const lengthSq = abx * abx + abz * abz;
158
+ const t = lengthSq === 0 ? 0 : Math.max(0, Math.min(1, ((x - a[0]) * abx + (z - a[1]) * abz) / lengthSq));
159
+ const px = a[0] + abx * t;
160
+ const pz = a[1] + abz * t;
161
+ const edge = Math.hypot(x - px, z - pz) - half;
162
+ if (edge < best)
163
+ best = edge;
164
+ }
165
+ }
166
+ return best;
167
+ }
168
+ /**
169
+ * A `surfaceFriction`-compatible sampler (the `(x, z) => grip` hook a kinematic car reads each tick)
170
+ * that turns road geometry into grip (#1051): full `onRoad` grip on the asphalt, a monotonic linear ramp
171
+ * down across a `shoulder`-wide band at the curb, and flat `offRoad` grip beyond it — so a car that
172
+ * clips onto the grass loses bite progressively instead of snapping. Closes over {@link
173
+ * distanceToRoadEdge}, so the returned sampler is itself allocation-free per call.
174
+ */
175
+ export function roadSurfaceSampler(roads, options = {}) {
176
+ const onRoad = options.onRoad ?? 1;
177
+ const offRoad = options.offRoad ?? 0.72;
178
+ const shoulder = options.shoulder ?? 3;
179
+ return (x, z) => {
180
+ const edge = distanceToRoadEdge(roads, x, z);
181
+ if (edge <= 0)
182
+ return onRoad;
183
+ if (shoulder <= 0 || edge >= shoulder)
184
+ return offRoad;
185
+ return onRoad + (offRoad - onRoad) * (edge / shoulder);
186
+ };
187
+ }
133
188
  /**
134
189
  * A deterministic point on one of a road's sidewalks at a normalized position — `side` picks the
135
190
  * band, `fraction` (0..1) picks how far along. The canonical pedestrian spawn helper.
@@ -218,13 +218,18 @@ export function createEditableTerrain(config) {
218
218
  record?.(index, before, surface);
219
219
  return true;
220
220
  }
221
- function paintSurface(edit, record) {
222
- const surface = edit.surface ?? null;
221
+ /**
222
+ * Visits every cell in a brush footprint on the CELL grid, yielding each cell's flat `index`, its
223
+ * {@link brushWeight} `weight`, world center (`wx`,`wz`), and raw `dist` from the brush center.
224
+ * Shared by the cell-grid brushes ({@link paintSurface}, {@link blendPaint}); each applies its own
225
+ * inclusion test on the yielded values (paint uses `dist <= radius`, blend uses `weight > 0`).
226
+ */
227
+ function forEachBrushCell(edit, visit) {
228
+ const falloff = edit.falloff ?? "smooth";
223
229
  const shape = edit.shape ?? "circle";
224
230
  const rCells = Math.ceil(edit.radius / cellSize) + 1;
225
231
  const cx = gridX(edit.center[0]);
226
232
  const cz = gridZ(edit.center[1]);
227
- let painted = 0;
228
233
  for (let iz = -rCells; iz <= rCells; iz += 1) {
229
234
  const gz = Math.floor(cz) + iz;
230
235
  if (gz < 0 || gz >= rows)
@@ -233,15 +238,25 @@ export function createEditableTerrain(config) {
233
238
  const gx = Math.floor(cx) + ix;
234
239
  if (gx < 0 || gx >= cols)
235
240
  continue;
236
- const dx = cellWorldX(gx) - edit.center[0];
237
- const dz = cellWorldZ(gz) - edit.center[1];
241
+ const wx = cellWorldX(gx);
242
+ const wz = cellWorldZ(gz);
243
+ const dx = wx - edit.center[0];
244
+ const dz = wz - edit.center[1];
238
245
  const dist = shape === "square" ? Math.max(Math.abs(dx), Math.abs(dz)) : Math.hypot(dx, dz);
239
- if (dist > edit.radius)
240
- continue;
241
- if (writeSurface(gz * cols + gx, surface, record))
242
- painted += 1;
246
+ visit(gz * cols + gx, brushWeight(dist, edit.radius, falloff), wx, wz, dist);
243
247
  }
244
248
  }
249
+ }
250
+ function paintSurface(edit, record) {
251
+ const surface = edit.surface ?? null;
252
+ let painted = 0;
253
+ // Binary inclusion by raw distance (includes dist == radius), independent of falloff shaping.
254
+ forEachBrushCell(edit, (index, _weight, _wx, _wz, dist) => {
255
+ if (dist > edit.radius)
256
+ return;
257
+ if (writeSurface(index, surface, record))
258
+ painted += 1;
259
+ });
245
260
  return painted;
246
261
  }
247
262
  function localNormal(wx, wz) {
@@ -371,31 +386,14 @@ export function createEditableTerrain(config) {
371
386
  const target = layers.findIndex((entry) => entry.surface === surface);
372
387
  if (target < 0)
373
388
  return 0;
374
- const falloff = edit.falloff ?? "smooth";
375
- const shape = edit.shape ?? "circle";
376
389
  const strength = edit.strength ?? 1;
377
- const rCells = Math.ceil(edit.radius / cellSize) + 1;
378
- const cx = gridX(edit.center[0]);
379
- const cz = gridZ(edit.center[1]);
380
390
  let painted = 0;
381
- for (let iz = -rCells; iz <= rCells; iz += 1) {
382
- const gz = Math.floor(cz) + iz;
383
- if (gz < 0 || gz >= rows)
384
- continue;
385
- for (let ix = -rCells; ix <= rCells; ix += 1) {
386
- const gx = Math.floor(cx) + ix;
387
- if (gx < 0 || gx >= cols)
388
- continue;
389
- const dx = cellWorldX(gx) - edit.center[0];
390
- const dz = cellWorldZ(gz) - edit.center[1];
391
- const dist = shape === "square" ? Math.max(Math.abs(dx), Math.abs(dz)) : Math.hypot(dx, dz);
392
- const weight = brushWeight(dist, edit.radius, falloff);
393
- if (weight <= 0)
394
- continue;
395
- if (blendCell(gz * cols + gx, target, weight * strength, record))
396
- painted += 1;
397
- }
398
- }
391
+ forEachBrushCell(edit, (index, weight) => {
392
+ if (weight <= 0)
393
+ return;
394
+ if (blendCell(index, target, weight * strength, record))
395
+ painted += 1;
396
+ });
399
397
  return painted;
400
398
  }
401
399
  function runHeightEdit(edit, record) {
@@ -545,23 +543,29 @@ export function createEditableTerrain(config) {
545
543
  };
546
544
  return field;
547
545
  }
548
- /** Opens a stroke recorder over `terrain`; stamp edits into it, then read one net delta. */
549
- export function beginTerraformStroke(terrain) {
546
+ /**
547
+ * The shared first/latest Map accumulator behind every `begin*Stroke`. Records each key's first
548
+ * `before` and latest `after` across many stamps, then assembles the net changed entries (skipping
549
+ * keys whose latest equals their first). Each stroke supplies its own recording call via `recordStamp`
550
+ * and wraps {@link StrokeChanges} into its concrete delta shape.
551
+ */
552
+ function createStroke(recordStamp) {
550
553
  const first = new Map();
551
554
  const latest = new Map();
552
555
  return {
553
556
  stamp(edit) {
554
- return terrain.applyRecording(edit, (index, before, after) => {
557
+ return recordStamp(edit, (index, before, after) => {
555
558
  if (!first.has(index))
556
559
  first.set(index, before);
557
560
  latest.set(index, after);
558
561
  });
559
562
  },
560
- delta() {
563
+ changes() {
561
564
  const indices = [];
562
565
  const before = [];
563
566
  const after = [];
564
567
  for (const [index, next] of latest) {
568
+ // Every latest key was set in `first` on the same callback, so this is always present.
565
569
  const prev = first.get(index);
566
570
  if (next === prev)
567
571
  continue;
@@ -576,35 +580,22 @@ export function beginTerraformStroke(terrain) {
576
580
  },
577
581
  };
578
582
  }
583
+ /** Opens a stroke recorder over `terrain`; stamp edits into it, then read one net delta. */
584
+ export function beginTerraformStroke(terrain) {
585
+ const stroke = createStroke((edit, record) => terrain.applyRecording(edit, record));
586
+ return {
587
+ stamp: stroke.stamp,
588
+ delta: () => stroke.changes(),
589
+ isEmpty: stroke.isEmpty,
590
+ };
591
+ }
579
592
  /** Opens a paint-stroke recorder over `terrain`; stamp paint edits into it, then read one net delta. */
580
593
  export function beginSurfaceStroke(terrain) {
581
- const first = new Map();
582
- const latest = new Map();
594
+ const stroke = createStroke((edit, record) => terrain.paintRecording(edit, record));
583
595
  return {
584
- stamp(edit) {
585
- return terrain.paintRecording(edit, (index, before, after) => {
586
- if (!first.has(index))
587
- first.set(index, before);
588
- latest.set(index, after);
589
- });
590
- },
591
- delta() {
592
- const indices = [];
593
- const before = [];
594
- const after = [];
595
- for (const [index, next] of latest) {
596
- const prev = first.get(index) ?? null;
597
- if (next === prev)
598
- continue;
599
- indices.push(index);
600
- before.push(prev);
601
- after.push(next);
602
- }
603
- return { indices, before, after };
604
- },
605
- isEmpty() {
606
- return latest.size === 0;
607
- },
596
+ stamp: stroke.stamp,
597
+ delta: () => stroke.changes(),
598
+ isEmpty: stroke.isEmpty,
608
599
  };
609
600
  }
610
601
  /** Returns a new snapshot with a surface delta's `after` ids applied (copy-on-write). */
@@ -700,33 +691,12 @@ export function revertWeightDeltaFromSnapshot(snapshot, delta) {
700
691
  * @internal — the stroke recorder behind the blend brush and the `blendTerrain` command.
701
692
  */
702
693
  export function beginBlendStroke(terrain) {
703
- const first = new Map();
704
- const latest = new Map();
694
+ const stroke = createStroke((edit, record) => terrain.blendRecording(edit, record));
705
695
  return {
706
- stamp(edit) {
707
- return terrain.blendRecording(edit, (index, before, after) => {
708
- if (!first.has(index))
709
- first.set(index, before);
710
- latest.set(index, after);
711
- });
712
- },
713
- delta() {
714
- const indices = [];
715
- const before = [];
716
- const after = [];
717
- for (const [index, next] of latest) {
718
- const prev = first.get(index);
719
- if (next === prev)
720
- continue;
721
- indices.push(index);
722
- before.push(prev);
723
- after.push(next);
724
- }
725
- return { layerCount: terrain.layers.length, indices, before, after };
726
- },
727
- isEmpty() {
728
- return latest.size === 0;
729
- },
696
+ stamp: stroke.stamp,
697
+ // layerCount is read at delta() time, guarding replay after the layer stack changed shape.
698
+ delta: () => ({ layerCount: terrain.layers.length, ...stroke.changes() }),
699
+ isEmpty: stroke.isEmpty,
730
700
  };
731
701
  }
732
702
  /**
@@ -124,6 +124,51 @@ export declare function sampleSlope(field: TerrainField, x: number, z: number):
124
124
  * @internal
125
125
  */
126
126
  export declare function slopeForce(field: TerrainField, x: number, z: number, scale?: number): readonly [number, number];
127
+ /** XZ region a {@link raycastHeightField} march is clipped to. */
128
+ export interface HeightFieldRayBounds {
129
+ minX: number;
130
+ minZ: number;
131
+ maxX: number;
132
+ maxZ: number;
133
+ }
134
+ /** Tuning for {@link raycastHeightField}: march resolution and refinement depth. */
135
+ export interface HeightFieldRaycastOptions {
136
+ /** XZ region to march within; the ray is clipped to it before any sampling. */
137
+ bounds: HeightFieldRayBounds;
138
+ /**
139
+ * March step in world units. Use about half the field's cell size — features narrower than one
140
+ * step can be skipped. Defaults to 1/256 of the larger bounds span, clamped to `[0.25, 4]`.
141
+ */
142
+ step?: number;
143
+ /** Bisection iterations refining the crossing once found (default 8 ≈ step/256 precision). */
144
+ refine?: number;
145
+ /** Stop marching past this ray distance (default: the full bounds crossing). */
146
+ maxDistance?: number;
147
+ /**
148
+ * Hard cap on march samples (default 4096). Bounds the work for rays the XZ slab cannot clip —
149
+ * a near-vertical ray inside bounds has an enormous slab crossing, and an upward ray never
150
+ * meets the surface at all; the cap turns both into a prompt miss instead of a stall.
151
+ */
152
+ maxSteps?: number;
153
+ }
154
+ /** Where a {@link raycastHeightField} march crossed the surface. */
155
+ export interface HeightFieldRayHit {
156
+ x: number;
157
+ y: number;
158
+ z: number;
159
+ /** Ray-parameter distance from the origin to the hit (direction is normalized internally). */
160
+ distance: number;
161
+ }
162
+ /**
163
+ * Intersects a ray with a `sampleHeight` field by fixed-step raymarching plus bisection — the
164
+ * O(steps) editor/gameplay picking seam that replaces brute-force triangle raycasts against a
165
+ * tessellated ground mesh (tens of thousands of triangle tests per pick on a sculpt-sized grid).
166
+ * The ray is clipped to `bounds` on XZ first, marched at `step`, and the first above→below
167
+ * crossing is bisected. An origin already under the surface hits immediately at entry. Returns
168
+ * null when the clipped ray never crosses the surface.
169
+ * @capability terrain-raycast pick a point on a heightfield along a camera/pointer ray
170
+ */
171
+ export declare function raycastHeightField(sampleHeight: (x: number, z: number) => number, origin: readonly [number, number, number], direction: readonly [number, number, number], options: HeightFieldRaycastOptions): HeightFieldRayHit | null;
127
172
  export interface TerrainPalette {
128
173
  low: string;
129
174
  high: string;
@@ -174,6 +174,10 @@ export function arenaField(config = {}) {
174
174
  export function groundFieldFor(world) {
175
175
  if (world !== undefined && world.kind === "environment")
176
176
  return resolveEnvironmentField(world);
177
+ // Place worlds (`world()` from world/place) stand on a flat baseline field: flat/board grounds are
178
+ // exactly y=0, while round/voxel grounds keep their sampled shape in the game's own generation
179
+ // systems (the substrate declaration never bakes a heightfield). Editor-authored sculpt still
180
+ // layers through the environment path above.
177
181
  return flatField();
178
182
  }
179
183
  /** Whether a world declares real terrain (base heightfield or islands) rather than a flat plane — gates terrain-floor sampling in the movement controllers.
@@ -338,6 +342,80 @@ export function slopeForce(field, x, z, scale = 9.8) {
338
342
  return [0, 0];
339
343
  return [nx * scale, nz * scale];
340
344
  }
345
+ /**
346
+ * Intersects a ray with a `sampleHeight` field by fixed-step raymarching plus bisection — the
347
+ * O(steps) editor/gameplay picking seam that replaces brute-force triangle raycasts against a
348
+ * tessellated ground mesh (tens of thousands of triangle tests per pick on a sculpt-sized grid).
349
+ * The ray is clipped to `bounds` on XZ first, marched at `step`, and the first above→below
350
+ * crossing is bisected. An origin already under the surface hits immediately at entry. Returns
351
+ * null when the clipped ray never crosses the surface.
352
+ * @capability terrain-raycast pick a point on a heightfield along a camera/pointer ray
353
+ */
354
+ export function raycastHeightField(sampleHeight, origin, direction, options) {
355
+ const length = Math.hypot(direction[0], direction[1], direction[2]);
356
+ if (length < 1e-12)
357
+ return null;
358
+ const dx = direction[0] / length;
359
+ const dy = direction[1] / length;
360
+ const dz = direction[2] / length;
361
+ const { bounds } = options;
362
+ // Clip [t0, t1] to the XZ slab of bounds.
363
+ let t0 = 0;
364
+ let t1 = options.maxDistance ?? Infinity;
365
+ for (const [o, d, min, max] of [
366
+ [origin[0], dx, bounds.minX, bounds.maxX],
367
+ [origin[2], dz, bounds.minZ, bounds.maxZ],
368
+ ]) {
369
+ if (Math.abs(d) < 1e-12) {
370
+ if (o < min || o > max)
371
+ return null;
372
+ continue;
373
+ }
374
+ const ta = (min - o) / d;
375
+ const tb = (max - o) / d;
376
+ const near = Math.min(ta, tb);
377
+ const far = Math.max(ta, tb);
378
+ if (near > t0)
379
+ t0 = near;
380
+ if (far < t1)
381
+ t1 = far;
382
+ }
383
+ if (t1 <= t0)
384
+ return null;
385
+ const spanX = bounds.maxX - bounds.minX;
386
+ const spanZ = bounds.maxZ - bounds.minZ;
387
+ const step = options.step ?? Math.min(4, Math.max(0.25, Math.max(spanX, spanZ) / 256));
388
+ const refine = options.refine ?? 8;
389
+ const maxSteps = options.maxSteps ?? 4096;
390
+ const above = (t) => origin[1] + dy * t - sampleHeight(origin[0] + dx * t, origin[2] + dz * t);
391
+ let prevT = t0;
392
+ if (above(t0) <= 0) {
393
+ return { x: origin[0] + dx * t0, y: origin[1] + dy * t0, z: origin[2] + dz * t0, distance: t0 };
394
+ }
395
+ let steps = 0;
396
+ while (prevT < t1 && steps < maxSteps) {
397
+ steps += 1;
398
+ const t = Math.min(prevT + step, t1);
399
+ if (above(t) <= 0) {
400
+ let lo = prevT;
401
+ let hi = t;
402
+ for (let i = 0; i < refine; i += 1) {
403
+ const mid = (lo + hi) / 2;
404
+ if (above(mid) <= 0)
405
+ hi = mid;
406
+ else
407
+ lo = mid;
408
+ }
409
+ const x = origin[0] + dx * hi;
410
+ const z = origin[2] + dz * hi;
411
+ return { x, y: sampleHeight(x, z), z, distance: hi };
412
+ }
413
+ if (t >= t1)
414
+ break;
415
+ prevT = t;
416
+ }
417
+ return null;
418
+ }
341
419
  export const DEFAULT_TERRAIN_MATERIAL = "grass";
342
420
  export const TERRAIN_MATERIAL_PALETTES = {
343
421
  grass: { low: "#30402c", high: "#7f8b50", waterline: "#1d4c6e" },