@jgengine/core 0.19.0 → 0.22.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 (266) hide show
  1. package/CHANGELOG.md +364 -0
  2. package/dist/ai/decisionGraph.d.ts +2 -0
  3. package/dist/ai/decisionGraph.js +6 -0
  4. package/dist/ai/flock.d.ts +1 -1
  5. package/dist/ai/flock.js +7 -4
  6. package/dist/anim/animGraph.d.ts +33 -2
  7. package/dist/anim/animGraph.js +200 -12
  8. package/dist/anim/locomotionGraph.d.ts +11 -3
  9. package/dist/anim/locomotionGraph.js +25 -15
  10. package/dist/audio/audioFalloff.d.ts +4 -0
  11. package/dist/audio/voiceAllocator.d.ts +46 -0
  12. package/dist/audio/voiceAllocator.js +76 -0
  13. package/dist/combat/abilityKit.d.ts +19 -0
  14. package/dist/combat/abilityKit.js +40 -4
  15. package/dist/combat/death.d.ts +4 -3
  16. package/dist/combat/death.js +3 -3
  17. package/dist/combat/magazine.d.ts +9 -0
  18. package/dist/combat/magazine.js +24 -2
  19. package/dist/combat/projectiles.d.ts +94 -2
  20. package/dist/combat/projectiles.js +282 -61
  21. package/dist/combat/regenShield.d.ts +14 -0
  22. package/dist/combat/regenShield.js +23 -7
  23. package/dist/crafting/recipeCatalog.d.ts +16 -0
  24. package/dist/crafting/recipeCatalog.js +55 -0
  25. package/dist/economy/auctionBook.d.ts +1 -1
  26. package/dist/economy/auctionBook.js +11 -1
  27. package/dist/economy/listingBook.d.ts +1 -1
  28. package/dist/economy/listingBook.js +11 -1
  29. package/dist/economy/resourceLedger.js +3 -3
  30. package/dist/economy/shopStock.js +7 -6
  31. package/dist/economy/techTree.js +7 -6
  32. package/dist/economy/unlockPoints.js +3 -6
  33. package/dist/editor/commandApply.js +89 -16
  34. package/dist/editor/commands.d.ts +38 -1
  35. package/dist/editor/commands.js +320 -3
  36. package/dist/editor/creatorStorage.d.ts +59 -0
  37. package/dist/editor/creatorStorage.js +164 -0
  38. package/dist/editor/document.d.ts +4 -0
  39. package/dist/editor/document.js +116 -17
  40. package/dist/editor/index.d.ts +3 -0
  41. package/dist/editor/index.js +1 -0
  42. package/dist/editor/liveSync.d.ts +4 -1
  43. package/dist/editor/liveSync.js +26 -10
  44. package/dist/editor/materialAuthoring.d.ts +14 -0
  45. package/dist/editor/materialAuthoring.js +38 -0
  46. package/dist/editor/modelNodeVisibility.d.ts +11 -0
  47. package/dist/editor/modelNodeVisibility.js +29 -0
  48. package/dist/editor/movementCatalog.d.ts +90 -0
  49. package/dist/editor/movementCatalog.js +144 -0
  50. package/dist/editor/simulation.d.ts +60 -0
  51. package/dist/editor/simulation.js +202 -0
  52. package/dist/editor/staticPrefab.d.ts +17 -0
  53. package/dist/editor/staticPrefab.js +51 -0
  54. package/dist/editor/terrainMaterials.d.ts +10 -0
  55. package/dist/editor/terrainMaterials.js +13 -0
  56. package/dist/editor/types.d.ts +11 -0
  57. package/dist/game/cameraConfig.d.ts +3 -1
  58. package/dist/game/clipRoles.d.ts +2 -1
  59. package/dist/game/clipRoles.js +7 -3
  60. package/dist/game/contentValidation.d.ts +69 -0
  61. package/dist/game/contentValidation.js +118 -0
  62. package/dist/game/controlGate.d.ts +8 -0
  63. package/dist/game/controlGate.js +43 -6
  64. package/dist/game/defineGame.d.ts +32 -3
  65. package/dist/game/dialogueGraph.d.ts +20 -0
  66. package/dist/game/dialogueGraph.js +107 -0
  67. package/dist/game/events.d.ts +5 -0
  68. package/dist/game/keyValueStore.d.ts +17 -4
  69. package/dist/game/keyValueStore.js +35 -23
  70. package/dist/game/lootModifiers.d.ts +17 -0
  71. package/dist/game/lootModifiers.js +26 -0
  72. package/dist/game/narrativeAuthoring.d.ts +67 -0
  73. package/dist/game/narrativeAuthoring.js +122 -0
  74. package/dist/game/playableGame.d.ts +52 -20
  75. package/dist/game/quest.d.ts +15 -0
  76. package/dist/game/quest.js +209 -63
  77. package/dist/game/questCatalog.d.ts +50 -0
  78. package/dist/game/questCatalog.js +205 -0
  79. package/dist/game/saveStore.d.ts +6 -5
  80. package/dist/game/saveStore.js +57 -57
  81. package/dist/game/systemRuntime.d.ts +2 -2
  82. package/dist/game/systemRuntime.js +84 -33
  83. package/dist/gameplay.d.ts +3 -0
  84. package/dist/gameplay.js +3 -0
  85. package/dist/interaction/promptRegistry.d.ts +5 -4
  86. package/dist/interaction/promptRegistry.js +8 -2
  87. package/dist/inventory/inventoryModel.js +9 -6
  88. package/dist/inventory/shapedGrid.d.ts +48 -9
  89. package/dist/inventory/shapedGrid.js +69 -10
  90. package/dist/item/durability.js +4 -0
  91. package/dist/item/itemInstanceRegistry.d.ts +32 -1
  92. package/dist/item/itemInstanceRegistry.js +111 -7
  93. package/dist/item/itemgen.d.ts +1 -1
  94. package/dist/item/itemgen.js +1 -1
  95. package/dist/item/modularItem.js +7 -1
  96. package/dist/material/appearanceSignals.d.ts +53 -0
  97. package/dist/material/appearanceSignals.js +47 -0
  98. package/dist/material/hairCards.d.ts +47 -0
  99. package/dist/material/hairCards.js +136 -0
  100. package/dist/material/materialAsset.d.ts +162 -0
  101. package/dist/material/materialAsset.js +382 -0
  102. package/dist/meta/changelog.d.ts +1 -1
  103. package/dist/meta/changelog.js +348 -1
  104. package/dist/movement/characterController.d.ts +5 -2
  105. package/dist/movement/characterController.js +50 -23
  106. package/dist/movement/freeFlight.d.ts +2 -2
  107. package/dist/movement/freeFlight.js +3 -3
  108. package/dist/movement/movementModel.d.ts +24 -1
  109. package/dist/movement/movementModel.js +74 -7
  110. package/dist/movement/playerMovement.d.ts +28 -1
  111. package/dist/movement/playerMovement.js +425 -102
  112. package/dist/movement/poseState.d.ts +2 -0
  113. package/dist/movement/poseState.js +29 -1
  114. package/dist/movement/solidObstacles.d.ts +3 -1
  115. package/dist/movement/solidObstacles.js +8 -5
  116. package/dist/movement/terrainGrade.d.ts +13 -0
  117. package/dist/movement/terrainGrade.js +33 -0
  118. package/dist/movement/voxelController.d.ts +2 -2
  119. package/dist/movement/voxelController.js +3 -3
  120. package/dist/nav/navGrid.d.ts +19 -1
  121. package/dist/nav/navGrid.js +26 -10
  122. package/dist/nav/solidRoute.d.ts +50 -0
  123. package/dist/nav/solidRoute.js +144 -0
  124. package/dist/physics/ballisticSweep.d.ts +10 -3
  125. package/dist/physics/ballisticSweep.js +53 -29
  126. package/dist/physics/forceVolume.d.ts +41 -0
  127. package/dist/physics/forceVolume.js +88 -0
  128. package/dist/physics/kinematicVehicle.d.ts +8 -3
  129. package/dist/physics/kinematicVehicle.js +2 -2
  130. package/dist/physics/physicsWorldBackend.js +19 -3
  131. package/dist/physics/spatialGrid.d.ts +1 -1
  132. package/dist/physics/spatialGrid.js +8 -9
  133. package/dist/physics/vehicleDynamics.d.ts +3 -2
  134. package/dist/physics/vehicleDynamics.js +2 -2
  135. package/dist/physics/vehicleObstacles.d.ts +3 -2
  136. package/dist/physics/vehicleObstacles.js +29 -5
  137. package/dist/runtime/cameraDirector.d.ts +39 -2
  138. package/dist/runtime/cameraDirector.js +162 -0
  139. package/dist/runtime/commandDispatch.d.ts +14 -0
  140. package/dist/runtime/commandDispatch.js +19 -0
  141. package/dist/runtime/context/combat.d.ts +11 -4
  142. package/dist/runtime/context/combat.js +178 -11
  143. package/dist/runtime/context/deathLoot.d.ts +9 -8
  144. package/dist/runtime/context/deathLoot.js +16 -16
  145. package/dist/runtime/context/scene.d.ts +2 -0
  146. package/dist/runtime/context/scene.js +13 -21
  147. package/dist/runtime/descriptors/baseline.js +34 -3
  148. package/dist/runtime/descriptors/features.d.ts +2 -1
  149. package/dist/runtime/descriptors/features.js +18 -0
  150. package/dist/runtime/gameContext.js +79 -13
  151. package/dist/runtime/gameContextTypes.d.ts +14 -0
  152. package/dist/runtime/headlessRunner.d.ts +3 -1
  153. package/dist/runtime/headlessRunner.js +11 -11
  154. package/dist/runtime/hostedGameRunner.d.ts +24 -1
  155. package/dist/runtime/hostedGameRunner.js +156 -10
  156. package/dist/runtime/hostedWorldSession.d.ts +17 -5
  157. package/dist/runtime/hostedWorldSession.js +154 -16
  158. package/dist/runtime/hostedWorldStore.d.ts +2 -2
  159. package/dist/runtime/inputRecorder.d.ts +1 -1
  160. package/dist/runtime/inputRecorder.js +21 -1
  161. package/dist/runtime/inputSnapshot.d.ts +16 -4
  162. package/dist/runtime/inputSnapshot.js +31 -3
  163. package/dist/runtime/runtimeSave.d.ts +10 -6
  164. package/dist/runtime/runtimeSave.js +11 -5
  165. package/dist/runtime/simContext.d.ts +8 -3
  166. package/dist/runtime/simContext.js +7 -3
  167. package/dist/runtime/simSnapshot.js +2 -0
  168. package/dist/runtime/worldReplication.d.ts +2 -0
  169. package/dist/runtime/worldReplication.js +1 -1
  170. package/dist/scene/assetCatalog.d.ts +2 -0
  171. package/dist/scene/behaviorRuntime.d.ts +26 -5
  172. package/dist/scene/behaviorRuntime.js +263 -16
  173. package/dist/scene/behaviors.d.ts +30 -1
  174. package/dist/scene/behaviors.js +29 -0
  175. package/dist/scene/entityStore.d.ts +14 -3
  176. package/dist/scene/entityStore.js +45 -9
  177. package/dist/scene/mount.d.ts +9 -0
  178. package/dist/scene/mount.js +35 -0
  179. package/dist/scene/possession.d.ts +2 -0
  180. package/dist/scene/possession.js +21 -1
  181. package/dist/scene/sceneRaycast.d.ts +7 -0
  182. package/dist/scene/sceneRaycast.js +55 -0
  183. package/dist/scene/spatial.d.ts +20 -1
  184. package/dist/scene/spatial.js +94 -2
  185. package/dist/scene/vehicleSeat.d.ts +36 -2
  186. package/dist/scene/vehicleSeat.js +89 -2
  187. package/dist/stats/accumulatorMeter.d.ts +10 -0
  188. package/dist/stats/accumulatorMeter.js +6 -0
  189. package/dist/stats/eventMeter.d.ts +9 -2
  190. package/dist/stats/eventMeter.js +5 -2
  191. package/dist/store/defineStore.d.ts +3 -1
  192. package/dist/store/defineStore.js +3 -1
  193. package/dist/store/observableKeyedStore.d.ts +1 -1
  194. package/dist/store/observableKeyedStore.js +8 -6
  195. package/dist/time/simClock.d.ts +6 -2
  196. package/dist/time/simClock.js +5 -0
  197. package/dist/vfx/particleDirector.d.ts +58 -6
  198. package/dist/vfx/particleDirector.js +113 -22
  199. package/dist/vfx/particles.d.ts +68 -5
  200. package/dist/vfx/particles.js +284 -32
  201. package/dist/visibility/assetStreaming.d.ts +21 -3
  202. package/dist/visibility/assetStreaming.js +108 -24
  203. package/dist/visibility/settings.d.ts +8 -2
  204. package/dist/visibility/settings.js +3 -0
  205. package/dist/visibility/visibilitySystem.js +1 -0
  206. package/dist/work/expedition.d.ts +110 -0
  207. package/dist/work/expedition.js +195 -0
  208. package/dist/work/staffedStation.d.ts +29 -0
  209. package/dist/work/staffedStation.js +27 -0
  210. package/dist/world/authoredAnimation.d.ts +38 -0
  211. package/dist/world/authoredAnimation.js +99 -0
  212. package/dist/world/authoredObjects.d.ts +24 -4
  213. package/dist/world/authoredObjects.js +43 -16
  214. package/dist/world/authoredSimulation.d.ts +74 -0
  215. package/dist/world/authoredSimulation.js +302 -0
  216. package/dist/world/authoredWeather.d.ts +120 -0
  217. package/dist/world/authoredWeather.js +314 -0
  218. package/dist/world/buildingKit.d.ts +8 -3
  219. package/dist/world/buildingKit.js +21 -8
  220. package/dist/world/environmentForces.d.ts +21 -0
  221. package/dist/world/environmentForces.js +42 -0
  222. package/dist/world/features.d.ts +3 -3
  223. package/dist/world/footprintGrid.d.ts +55 -0
  224. package/dist/world/footprintGrid.js +201 -0
  225. package/dist/world/grassKind.d.ts +10 -0
  226. package/dist/world/grassKind.js +22 -0
  227. package/dist/world/pathTerrain.d.ts +1 -1
  228. package/dist/world/terraform.d.ts +2 -2
  229. package/dist/world/terraform.js +46 -18
  230. package/dist/world/weather.d.ts +17 -0
  231. package/dist/world/weather.js +116 -20
  232. package/dist/world/worldSolids.d.ts +11 -0
  233. package/dist/world/worldSolids.js +112 -1
  234. package/dist/world.d.ts +5 -5
  235. package/dist/world.js +4 -4
  236. package/package.json +1 -1
  237. package/skills/ce-handoff/SKILL.md +1 -1
  238. package/skills/game-design/SKILL.md +2 -0
  239. package/skills/jgengine/SKILL.md +23 -2
  240. package/skills/jgengine/api.md +28 -17
  241. package/skills/jgengine/recipes/minimal-game.md +7 -2
  242. package/skills/jgengine-combat/SKILL.md +8 -2
  243. package/skills/jgengine-combat/api.md +22 -10
  244. package/skills/jgengine-combat/recipes/portable-runtime-state.md +60 -6
  245. package/skills/jgengine-combat/recipes/portable-weapon-plumbing.md +37 -3
  246. package/skills/jgengine-combat/reference.md +24 -2
  247. package/skills/jgengine-gameplay/SKILL.md +63 -0
  248. package/skills/jgengine-gameplay/api.md +131 -37
  249. package/skills/jgengine-gameplay/capabilities.md +68 -0
  250. package/skills/jgengine-gameplay/recipes/device-save-status.md +46 -0
  251. package/skills/jgengine-gameplay/recipes/expeditions.md +108 -0
  252. package/skills/jgengine-gameplay/recipes/narrative-authoring.md +76 -0
  253. package/skills/jgengine-gameplay/recipes/paged-item-storage.md +101 -0
  254. package/skills/jgengine-gameplay/recipes/quest-authoring.md +63 -0
  255. package/skills/jgengine-gameplay/recipes/shaped-inventory.md +50 -0
  256. package/skills/jgengine-gameplay/reference-content-validation.md +74 -0
  257. package/skills/jgengine-gameplay/reference-systems.md +119 -0
  258. package/skills/jgengine-world/SKILL.md +12 -3
  259. package/skills/jgengine-world/api.md +201 -58
  260. package/skills/jgengine-world/capabilities.md +116 -3
  261. package/skills/jgengine-world/recipes/character-animation.md +31 -8
  262. package/skills/jgengine-world/recipes/controls.md +4 -0
  263. package/skills/jgengine-world/recipes/npc-senses.md +12 -0
  264. package/skills/jgengine-world/recipes/vehicle-feel.md +3 -1
  265. package/skills/jgengine-world/reference.md +167 -5
  266. package/skills/jgengine-world/visibility.md +28 -1
@@ -127,6 +127,207 @@ export function hasValidAdjacency(grid, cells, accepts, requireConnection = fals
127
127
  return false;
128
128
  return !requireConnection || neighbors.some((neighbor) => accepts(neighbor.kind));
129
129
  }
130
+ function validId(id) { return typeof id === "string" && id.length > 0; }
131
+ function validCapacity(capacity) { return Number.isFinite(capacity) && capacity >= 0; }
132
+ function contributionCount(region) {
133
+ if (region === null || typeof region !== "object")
134
+ return null;
135
+ if (!Array.isArray(region.cells))
136
+ return null;
137
+ if (!("contributions" in region))
138
+ return 1;
139
+ return Array.isArray(region.contributions) ? region.contributions.length : null;
140
+ }
141
+ function withinRegionBudget(regions, budget) {
142
+ let cells = 0;
143
+ let contributions = 0;
144
+ for (const region of regions) {
145
+ const count = contributionCount(region);
146
+ if (count === null)
147
+ return false;
148
+ cells += region.cells.length;
149
+ contributions += count;
150
+ }
151
+ for (const [limit, count] of [[budget.maxCells, cells], [budget.maxContributions, contributions]]) {
152
+ if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 0 || count > limit))
153
+ return false;
154
+ }
155
+ return true;
156
+ }
157
+ function regionCells(cells) {
158
+ if (cells.length === 0)
159
+ return null;
160
+ const out = new Map();
161
+ for (const cell of cells) {
162
+ if (cell === null || typeof cell !== "object")
163
+ return null;
164
+ if (!Number.isSafeInteger(cell.col) || !Number.isSafeInteger(cell.row))
165
+ return null;
166
+ const key = cellKey(cell);
167
+ if (out.has(key))
168
+ return null;
169
+ out.set(key, cell);
170
+ }
171
+ return out;
172
+ }
173
+ function connectedRegion(cells) {
174
+ const first = cells.values().next().value;
175
+ if (first === undefined)
176
+ return false;
177
+ const queue = [first];
178
+ const visited = new Set([cellKey(first)]);
179
+ for (let index = 0; index < queue.length; index++) {
180
+ const cell = queue[index];
181
+ for (const offset of CARDINAL_OFFSETS) {
182
+ const key = cellKey({ col: cell.col + offset.col, row: cell.row + offset.row });
183
+ const next = cells.get(key);
184
+ if (next !== undefined && !visited.has(key)) {
185
+ visited.add(key);
186
+ queue.push(next);
187
+ }
188
+ }
189
+ }
190
+ return queue.length === cells.size;
191
+ }
192
+ function checkedRegion(region) {
193
+ if (!validId(region.id) || !validId(region.kind) || !Number.isFinite(region.tier) || !validCapacity(region.capacity))
194
+ return null;
195
+ const cells = regionCells(region.cells);
196
+ if (cells === null || !connectedRegion(cells))
197
+ return null;
198
+ const contributions = "contributions" in region ? region.contributions : [region];
199
+ if (contributions.length === 0)
200
+ return null;
201
+ const ids = new Set();
202
+ const covered = new Set();
203
+ let capacity = 0;
204
+ for (const contribution of contributions) {
205
+ if (contribution === null || typeof contribution !== "object")
206
+ return null;
207
+ if (!validId(contribution.id) || ids.has(contribution.id) || !validCapacity(contribution.capacity) || !Array.isArray(contribution.cells))
208
+ return null;
209
+ if (contribution.cells.length > cells.size - covered.size)
210
+ return null;
211
+ ids.add(contribution.id);
212
+ const partCells = regionCells(contribution.cells);
213
+ if (partCells === null || !connectedRegion(partCells))
214
+ return null;
215
+ for (const key of partCells.keys()) {
216
+ if (!cells.has(key) || covered.has(key))
217
+ return null;
218
+ covered.add(key);
219
+ }
220
+ capacity += contribution.capacity;
221
+ }
222
+ if (!Number.isFinite(capacity) || capacity !== region.capacity || covered.size !== cells.size)
223
+ return null;
224
+ return { cells, contributions, ids };
225
+ }
226
+ function mergeableRegions(a, b, budget) {
227
+ if (!withinRegionBudget([a, b], budget) || a.id === b.id || a.kind !== b.kind || a.tier !== b.tier)
228
+ return null;
229
+ const left = checkedRegion(a);
230
+ const right = checkedRegion(b);
231
+ if (left === null || right === null)
232
+ return null;
233
+ if (right.ids.has(a.id) || left.ids.has(b.id))
234
+ return null;
235
+ for (const id of left.ids)
236
+ if (right.ids.has(id))
237
+ return null;
238
+ let adjacent = false;
239
+ for (const [key, cell] of left.cells) {
240
+ if (right.cells.has(key))
241
+ return null;
242
+ for (const offset of CARDINAL_OFFSETS) {
243
+ if (right.cells.has(cellKey({ col: cell.col + offset.col, row: cell.row + offset.row })))
244
+ adjacent = true;
245
+ }
246
+ }
247
+ let capacity = 0;
248
+ for (const contribution of left.contributions)
249
+ capacity += contribution.capacity;
250
+ for (const contribution of right.contributions)
251
+ capacity += contribution.capacity;
252
+ return adjacent && Number.isFinite(capacity) ? [left, right] : null;
253
+ }
254
+ function copyRegionCells(cells) {
255
+ return cells.map(cell => ({ col: cell.col, row: cell.row }));
256
+ }
257
+ function pooledRegion(id, kind, tier, parts) {
258
+ const contributions = parts.map(part => ({ id: part.id, capacity: part.capacity, cells: copyRegionCells(part.cells) }));
259
+ return { id, kind, tier, capacity: contributions.reduce((sum, part) => sum + part.capacity, 0),
260
+ cells: contributions.flatMap(part => copyRegionCells(part.cells)), contributions };
261
+ }
262
+ /** Matching kind/tier, disjoint valid source identities/cells, and cardinal contact. Linear in input size.
263
+ * @capability mergeable-footprint-regions check compatible adjacent regions within caller work budgets
264
+ */
265
+ export function canMerge(a, b, budget = {}) {
266
+ return mergeableRegions(a, b, budget) !== null;
267
+ }
268
+ /**
269
+ * Pool compatible footprints under caller-owned `id`, retaining original capacities and identities.
270
+ * Returns null for invalid data, kind/tier mismatch, overlap, no cardinal contact or exhausted budget.
271
+ * Does not mutate inputs or grid reservations; costs, merge width and upgrade policy remain caller-owned.
272
+ * Capacity is the ordered JavaScript-number sum of original contributions, independent of cell area.
273
+ *
274
+ * @capability merge-footprint-regions pool adjacent compatible footprints with serializable capacity provenance
275
+ */
276
+ export function mergeFootprints(a, b, id, budget = {}) {
277
+ if (!validId(id))
278
+ return null;
279
+ const checked = mergeableRegions(a, b, budget);
280
+ if (checked === null)
281
+ return null;
282
+ return pooledRegion(id, a.kind, a.tier, [...checked[0].contributions, ...checked[1].contributions]);
283
+ }
284
+ /**
285
+ * Partition whole original contributions by `leftIds`; both sides must remain cardinally connected.
286
+ * Singletons restore their original id and capacity; larger sides require explicit output identities.
287
+ * Returns null for invalid provenance/budget, unknown or duplicate ids, empty/disconnected sides, or
288
+ * conflicting output identities (including identities from the opposite side's contributions).
289
+ * An output may reuse its own side's source identity. Cell cuts/capacity repricing require caller policy.
290
+ * Both sides inherit the current region kind/tier. Inputs and grid reservations remain unchanged.
291
+ *
292
+ * @capability split-footprint-regions split pooled regions along retained contributions without area-based capacity loss
293
+ */
294
+ export function splitRegion(region, leftIds, ids = {}, budget = {}) {
295
+ if (!withinRegionBudget([region], budget))
296
+ return null;
297
+ const checked = checkedRegion(region);
298
+ if (checked === null || leftIds.length === 0 || leftIds.length >= checked.contributions.length)
299
+ return null;
300
+ const selected = new Set();
301
+ for (const id of leftIds) {
302
+ if (!checked.ids.has(id) || selected.has(id))
303
+ return null;
304
+ selected.add(id);
305
+ }
306
+ const left = [];
307
+ const right = [];
308
+ for (const part of checked.contributions)
309
+ (selected.has(part.id) ? left : right).push(part);
310
+ function side(parts, id) {
311
+ if (parts.length === 1) {
312
+ const part = parts[0];
313
+ if (id !== undefined && id !== part.id)
314
+ return null;
315
+ return { id: part.id, kind: region.kind, tier: region.tier, capacity: part.capacity, cells: copyRegionCells(part.cells) };
316
+ }
317
+ if (id === undefined || !validId(id))
318
+ return null;
319
+ const pooled = pooledRegion(id, region.kind, region.tier, parts);
320
+ const cells = regionCells(pooled.cells);
321
+ return cells !== null && connectedRegion(cells) ? pooled : null;
322
+ }
323
+ const a = side(left, ids.left);
324
+ const b = side(right, ids.right);
325
+ if (a === null || b === null || a.id === b.id)
326
+ return null;
327
+ if (right.some(part => part.id === a.id) || left.some(part => part.id === b.id))
328
+ return null;
329
+ return [a, b];
330
+ }
130
331
  /** Bridges live reservations into `world/placement`'s `PlacementRules.obstacles` so `validatePlacement`/`createPlacementController` see the grid's committed footprints unchanged. */
131
332
  export function footprintObstacles(grid) {
132
333
  return grid.list().map((reservation) => {
@@ -16,6 +16,16 @@ export interface GrassRules {
16
16
  density: number;
17
17
  /** Blade height in meters. */
18
18
  bladeHeight: number;
19
+ /** Blade-width range in metres; endpoints are normalized on read. */
20
+ bladeWidthMin: number;
21
+ bladeWidthMax: number;
22
+ /** Bend range, 0..1. */
23
+ bladeBendMin: number;
24
+ bladeBendMax: number;
25
+ /** Spread of blades around each tuft root, in metres. */
26
+ tuftRadius: number;
27
+ /** Normal lift toward the sky, 0..1; controls the card lighting response. */
28
+ normalLift: number;
19
29
  /** Base (root) color, hex. */
20
30
  colorBase: string;
21
31
  /** Tip color, hex. */
@@ -15,6 +15,12 @@ export const GRASS_FIELD_KIND = "grass_field";
15
15
  export const GRASS_DEFAULTS = {
16
16
  density: 12,
17
17
  bladeHeight: 0.6,
18
+ bladeWidthMin: 0.05,
19
+ bladeWidthMax: 0.1,
20
+ bladeBendMin: 0.12,
21
+ bladeBendMax: 0.42,
22
+ tuftRadius: 0.16,
23
+ normalLift: 0.6,
18
24
  colorBase: "#3f6f37",
19
25
  colorTip: "#9ac25a",
20
26
  colorVariation: 0.35,
@@ -29,6 +35,12 @@ export const GRASS_SCHEMA = {
29
35
  fields: [
30
36
  { type: "range", key: "density", label: "density", min: 0.5, max: 160, step: 0.5, default: GRASS_DEFAULTS.density, unit: "/m²" },
31
37
  { type: "range", key: "bladeHeight", label: "blade height", min: 0.1, max: 2, step: 0.05, default: GRASS_DEFAULTS.bladeHeight, unit: "m" },
38
+ { type: "range", key: "bladeWidthMin", label: "minimum blade width", min: 0.005, max: 0.3, step: 0.005, default: GRASS_DEFAULTS.bladeWidthMin, unit: "m" },
39
+ { type: "range", key: "bladeWidthMax", label: "maximum blade width", min: 0.005, max: 0.3, step: 0.005, default: GRASS_DEFAULTS.bladeWidthMax, unit: "m" },
40
+ { type: "range", key: "bladeBendMin", label: "minimum bend", min: 0, max: 1, step: 0.01, default: GRASS_DEFAULTS.bladeBendMin },
41
+ { type: "range", key: "bladeBendMax", label: "maximum bend", min: 0, max: 1, step: 0.01, default: GRASS_DEFAULTS.bladeBendMax },
42
+ { type: "range", key: "tuftRadius", label: "tuft radius", min: 0, max: 0.6, step: 0.01, default: GRASS_DEFAULTS.tuftRadius, unit: "m" },
43
+ { type: "range", key: "normalLift", label: "normal lift", min: 0, max: 1, step: 0.01, default: GRASS_DEFAULTS.normalLift },
32
44
  { type: "color", key: "colorBase", label: "base color", default: GRASS_DEFAULTS.colorBase },
33
45
  { type: "color", key: "colorTip", label: "tip color", default: GRASS_DEFAULTS.colorTip },
34
46
  { type: "range", key: "colorVariation", label: "color variation", min: 0, max: 1, step: 0.02, default: GRASS_DEFAULTS.colorVariation },
@@ -49,9 +61,19 @@ function metaString(meta, key, fallback) {
49
61
  }
50
62
  /** Parse a volume's `meta` into fully-defaulted grass rules. @internal */
51
63
  export function readGrassRules(meta) {
64
+ const widthMin = Math.max(0.005, metaNumber(meta, "bladeWidthMin", GRASS_DEFAULTS.bladeWidthMin));
65
+ const widthMax = Math.max(0.005, metaNumber(meta, "bladeWidthMax", GRASS_DEFAULTS.bladeWidthMax));
66
+ const bendMin = Math.max(0, Math.min(1, metaNumber(meta, "bladeBendMin", GRASS_DEFAULTS.bladeBendMin)));
67
+ const bendMax = Math.max(0, Math.min(1, metaNumber(meta, "bladeBendMax", GRASS_DEFAULTS.bladeBendMax)));
52
68
  return {
53
69
  density: Math.max(0, metaNumber(meta, "density", GRASS_DEFAULTS.density)),
54
70
  bladeHeight: Math.max(0.05, metaNumber(meta, "bladeHeight", GRASS_DEFAULTS.bladeHeight)),
71
+ bladeWidthMin: Math.min(widthMin, widthMax),
72
+ bladeWidthMax: Math.max(widthMin, widthMax),
73
+ bladeBendMin: Math.min(bendMin, bendMax),
74
+ bladeBendMax: Math.max(bendMin, bendMax),
75
+ tuftRadius: Math.max(0, metaNumber(meta, "tuftRadius", GRASS_DEFAULTS.tuftRadius)),
76
+ normalLift: Math.max(0, Math.min(1, metaNumber(meta, "normalLift", GRASS_DEFAULTS.normalLift))),
55
77
  colorBase: metaString(meta, "colorBase", GRASS_DEFAULTS.colorBase),
56
78
  colorTip: metaString(meta, "colorTip", GRASS_DEFAULTS.colorTip),
57
79
  colorVariation: metaNumber(meta, "colorVariation", GRASS_DEFAULTS.colorVariation),
@@ -7,7 +7,7 @@
7
7
  * (O(points) per sample), and serializable; it composes onto any height sampler and pairs with
8
8
  * `resolveTerrainField` so the same authored path drives both the rendered mesh and player collision.
9
9
  * @capability path-terrain flatten / grade / carve a heightfield from an authored path with retaining profiles
10
- * @consumer packages/core/src/world/terrain.ts (resolveTerrainField), Games/the-robots roads
10
+ * @consumer packages/core/src/world/terrain.ts (resolveTerrainField), Games/scrap-signal roads
11
11
  */
12
12
  import type { Vec2 } from "./geometry.js";
13
13
  /**
@@ -53,7 +53,7 @@ export interface TerrainMaterialLayer {
53
53
  tiling?: number;
54
54
  /** Project the material on all three axes to hide stretching on cliffs. */
55
55
  triplanar?: boolean;
56
- /** Hex color multiplied over the surface color. */
56
+ /** Authored hex color replacing the default surface palette color in editor and runtime. */
57
57
  tint?: string;
58
58
  /** Layer master opacity 0..1 — scales this layer's blend weight everywhere. */
59
59
  opacity?: number;
@@ -148,7 +148,7 @@ export interface EditableTerrain extends TerrainField {
148
148
  revertSurfaceDelta(delta: SurfaceDelta): void;
149
149
  /** The current material layer stack (lower index paints under higher). */
150
150
  readonly layers: readonly TerrainMaterialLayer[];
151
- /** Replace the material layer stack; drops blend weights whose layer index no longer exists. */
151
+ /** Replace the material stack, retaining weights by layer id and normalizing when painted layers are removed. */
152
152
  setLayers(layers: readonly TerrainMaterialLayer[]): void;
153
153
  /**
154
154
  * Paint the active `edit.surface` layer's blend weight into the brush footprint, pushing the
@@ -123,14 +123,12 @@ export function createEditableTerrain(config) {
123
123
  const cx = gridX(edit.center[0]);
124
124
  const cz = gridZ(edit.center[1]);
125
125
  let changed = 0;
126
- for (let iz = -rCells; iz <= rCells; iz += 1) {
127
- const gz = Math.round(cz) + iz;
128
- if (gz < 0 || gz >= vertsZ)
129
- continue;
130
- for (let ix = -rCells; ix <= rCells; ix += 1) {
131
- const gx = Math.round(cx) + ix;
132
- if (gx < 0 || gx >= vertsX)
133
- continue;
126
+ const minZ = Math.max(0, Math.round(cz) - rCells);
127
+ const maxZ = Math.min(vertsZ - 1, Math.round(cz) + rCells);
128
+ const minX = Math.max(0, Math.round(cx) - rCells);
129
+ const maxX = Math.min(vertsX - 1, Math.round(cx) + rCells);
130
+ for (let gz = minZ; gz <= maxZ; gz += 1) {
131
+ for (let gx = minX; gx <= maxX; gx += 1) {
134
132
  const wx = vertexWorldX(gx);
135
133
  const wz = vertexWorldZ(gz);
136
134
  const dx = wx - edit.center[0];
@@ -230,14 +228,12 @@ export function createEditableTerrain(config) {
230
228
  const rCells = Math.ceil(edit.radius / cellSize) + 1;
231
229
  const cx = gridX(edit.center[0]);
232
230
  const cz = gridZ(edit.center[1]);
233
- for (let iz = -rCells; iz <= rCells; iz += 1) {
234
- const gz = Math.floor(cz) + iz;
235
- if (gz < 0 || gz >= rows)
236
- continue;
237
- for (let ix = -rCells; ix <= rCells; ix += 1) {
238
- const gx = Math.floor(cx) + ix;
239
- if (gx < 0 || gx >= cols)
240
- continue;
231
+ const minZ = Math.max(0, Math.floor(cz) - rCells);
232
+ const maxZ = Math.min(rows - 1, Math.floor(cz) + rCells);
233
+ const minX = Math.max(0, Math.floor(cx) - rCells);
234
+ const maxX = Math.min(cols - 1, Math.floor(cx) + rCells);
235
+ for (let gz = minZ; gz <= maxZ; gz += 1) {
236
+ for (let gx = minX; gx <= maxX; gx += 1) {
241
237
  const wx = cellWorldX(gx);
242
238
  const wz = cellWorldZ(gz);
243
239
  const dx = wx - edit.center[0];
@@ -469,9 +465,41 @@ export function createEditableTerrain(config) {
469
465
  return layers;
470
466
  },
471
467
  setLayers(next) {
468
+ const previous = layers;
469
+ const previousWeights = weights;
472
470
  layers = next.map((layer) => ({ ...layer }));
473
- // Layer count changed shape: drop the weight buffer, blends re-seed from surfaces on demand.
474
- weights = null;
471
+ if (previousWeights === null || layers.length === 0) {
472
+ weights = null;
473
+ return;
474
+ }
475
+ const previousIndices = layers.map((layer) => previous.findIndex((entry) => entry.id === layer.id));
476
+ const buffer = new Float32Array(cellCount * layers.length);
477
+ let retained = false;
478
+ for (let cell = 0; cell < cellCount; cell += 1) {
479
+ let total = 0;
480
+ for (let layer = 0; layer < layers.length; layer += 1) {
481
+ const before = previousIndices[layer];
482
+ const weight = before < 0 ? 0 : previousWeights[cell * previous.length + before];
483
+ buffer[cell * layers.length + layer] = weight;
484
+ total += weight;
485
+ }
486
+ if (total > 0) {
487
+ retained = true;
488
+ let previousTotal = 0;
489
+ for (let layer = 0; layer < previous.length; layer += 1)
490
+ previousTotal += previousWeights[cell * previous.length + layer];
491
+ if (total < previousTotal) {
492
+ for (let layer = 0; layer < layers.length; layer += 1)
493
+ buffer[cell * layers.length + layer] /= total;
494
+ }
495
+ }
496
+ else {
497
+ const fallback = layers.findIndex((layer) => layer.surface === surfaces[cell]);
498
+ if (fallback >= 0)
499
+ buffer[cell * layers.length + fallback] = 1;
500
+ }
501
+ }
502
+ weights = retained ? buffer : null;
475
503
  },
476
504
  blendRecording(edit, record) {
477
505
  return blendPaint(edit, record);
@@ -48,19 +48,34 @@ export interface FireGridConfig {
48
48
  wind?: WindVector;
49
49
  /** How strongly wind biases downwind vs upwind spread, 0..1. Default 0.6. */
50
50
  windBias?: number;
51
+ /** Hard allocation bound; defaults to 65536 cells. */
52
+ maxCells?: number;
51
53
  }
54
+ /** Live fire ignition, spread, consumption and wind policies; grid geometry stays fixed. */
55
+ export type FireGridTuning = Pick<FireGridConfig, "ignitionThreshold" | "spreadRate" | "burnRate" | "wind" | "windBias">;
52
56
  export interface FireStepOptions {
53
57
  /** Global spread multiplier this step (rain suppression from resolveWeather().spread). Default 1. */
54
58
  spread?: number;
59
+ /** Live wind in metres/second along world X,Z, overriding configured wind this step. */
60
+ wind?: WindVector;
61
+ windAt?: (x: number, z: number) => WindVector;
55
62
  /** Per-cell wetness 0..1 that resists ignition — from an environment field. */
56
63
  wetnessAt?: (col: number, row: number) => number;
64
+ /** Heat removed per second at full wetness; 0 (default) leaves extinction to game policy. */
65
+ extinguishRate?: number;
57
66
  }
58
67
  export interface FireGrid {
59
68
  readonly cols: number;
60
69
  readonly rows: number;
70
+ readonly cellSize: number;
71
+ readonly origin: readonly [number, number];
61
72
  /** Advance the cellular propagation by game-time `dt`. */
62
73
  step(dt: number, options?: FireStepOptions): void;
63
74
  igniteCell(col: number, row: number): void;
75
+ /** Cool a burning cell to unburnt without replacing its remaining fuel. */
76
+ extinguishCell(col: number, row: number): boolean;
77
+ /** Extinguish the cell under a world position; false outside the grid or when not burning. */
78
+ extinguish(x: number, z: number): boolean;
64
79
  /** Ignite the cell under a world position; no-op if outside the grid. */
65
80
  ignite(x: number, z: number): boolean;
66
81
  cell(col: number, row: number): FireCell;
@@ -73,5 +88,7 @@ export interface FireGrid {
73
88
  readonly burning: number;
74
89
  /** Flat snapshot (row-major) for rendering. */
75
90
  snapshot(): FireCell[];
91
+ restore(state: readonly FireCell[]): void;
92
+ retune(tuning: FireGridTuning): void;
76
93
  }
77
94
  export declare function createFireGrid(config: FireGridConfig): FireGrid;
@@ -29,39 +29,66 @@ const NEIGHBOURS = [
29
29
  ];
30
30
  export function createFireGrid(config) {
31
31
  const { cols, rows, cellSize } = config;
32
- if (cols <= 0 || rows <= 0)
33
- throw new Error("createFireGrid: cols/rows must be positive");
32
+ const maxCells = config.maxCells ?? 65_536;
33
+ if (!Number.isSafeInteger(maxCells) || maxCells < 1 || maxCells > 1_048_576)
34
+ throw new Error("createFireGrid: maxCells must be an integer in 1..1048576");
35
+ if (!Number.isSafeInteger(cols) || !Number.isSafeInteger(rows) || cols <= 0 || rows <= 0 || cols * rows > maxCells)
36
+ throw new Error("createFireGrid: cols/rows must be positive integers within maxCells");
37
+ if (!Number.isFinite(cellSize) || cellSize <= 0)
38
+ throw new Error("createFireGrid: cellSize must be finite and positive");
34
39
  const originX = config.origin?.[0] ?? 0;
35
40
  const originZ = config.origin?.[1] ?? 0;
36
- const ignitionThreshold = config.ignitionThreshold ?? 1;
37
- const spreadRate = config.spreadRate ?? 0.6;
38
- const burnRate = config.burnRate ?? 0.25;
39
- const windBias = Math.max(0, Math.min(1, config.windBias ?? 0.6));
40
- const wind = config.wind;
41
- const windLen = wind === undefined ? 0 : Math.hypot(wind[0], wind[1]);
42
- const windX = windLen > 0 ? wind[0] / windLen : 0;
43
- const windZ = windLen > 0 ? wind[1] / windLen : 0;
41
+ if (!Number.isFinite(originX) || !Number.isFinite(originZ))
42
+ throw new Error("createFireGrid: origin must be finite");
43
+ let ignitionThreshold = 1;
44
+ let spreadRate = 0.6;
45
+ let burnRate = 0.25;
46
+ let windBias = 0.6;
47
+ let wind;
48
+ function retune(tuning) {
49
+ for (const key of ["ignitionThreshold", "spreadRate", "burnRate", "windBias"]) {
50
+ const value = tuning[key];
51
+ if (value !== undefined && (!Number.isFinite(value) || value < 0 || (key === "windBias" && value > 1)))
52
+ throw new Error(`fire ${key} is invalid`);
53
+ }
54
+ if (tuning.wind !== undefined && (!Number.isFinite(tuning.wind[0]) || !Number.isFinite(tuning.wind[1])))
55
+ throw new Error("fire wind must be finite");
56
+ ignitionThreshold = tuning.ignitionThreshold ?? ignitionThreshold;
57
+ spreadRate = tuning.spreadRate ?? spreadRate;
58
+ burnRate = tuning.burnRate ?? burnRate;
59
+ windBias = tuning.windBias ?? windBias;
60
+ if (tuning.wind !== undefined)
61
+ wind = [tuning.wind[0], tuning.wind[1]];
62
+ }
63
+ retune(config);
44
64
  const cells = new Array(cols * rows);
45
65
  for (let row = 0; row < rows; row += 1) {
46
66
  for (let col = 0; col < cols; col += 1) {
47
67
  const fuel = config.fuelAt?.(col, row) ?? 1;
68
+ if (!Number.isFinite(fuel) || fuel < 0)
69
+ throw new Error("fire initial fuel must be finite and nonnegative");
48
70
  cells[row * cols + col] = { fuel: Math.max(0, fuel), heat: 0, state: "unburnt" };
49
71
  }
50
72
  }
51
73
  const index = (col, row) => row * cols + col;
52
- const inBounds = (col, row) => col >= 0 && col < cols && row >= 0 && row < rows;
74
+ const inBounds = (col, row) => Number.isInteger(col) && Number.isInteger(row) && col >= 0 && col < cols && row >= 0 && row < rows;
53
75
  const cellCoord = (x, z) => {
54
76
  const col = Math.round((x - originX) / cellSize);
55
77
  const row = Math.round((z - originZ) / cellSize);
56
78
  return inBounds(col, row) ? { col, row } : null;
57
79
  };
58
- const directionalWeight = (dx, dz) => {
80
+ const directionalWeight = (dx, dz, vector) => {
81
+ if (vector === undefined)
82
+ return 1;
83
+ const windLen = Math.hypot(vector[0], vector[1]);
59
84
  if (windLen === 0)
60
85
  return 1;
61
86
  const len = Math.hypot(dx, dz) || 1;
62
- const alignment = (dx / len) * windX + (dz / len) * windZ;
87
+ const alignment = (dx / len) * vector[0] / windLen + (dz / len) * vector[1] / windLen;
63
88
  return 1 + windBias * alignment;
64
89
  };
90
+ const addedHeat = new Float64Array(cols * rows);
91
+ const wetness = new Float64Array(cols * rows);
65
92
  let burning = 0;
66
93
  for (const cell of cells)
67
94
  if (cell.state === "burning")
@@ -77,20 +104,71 @@ export function createFireGrid(config) {
77
104
  cell.state = "burning";
78
105
  cell.heat = Math.max(cell.heat, ignitionThreshold);
79
106
  };
107
+ const extinguishCell = (col, row) => {
108
+ if (!inBounds(col, row))
109
+ return false;
110
+ const cell = cells[index(col, row)];
111
+ if (cell.state !== "burning")
112
+ return false;
113
+ cell.state = "unburnt";
114
+ cell.heat = 0;
115
+ burning -= 1;
116
+ return true;
117
+ };
80
118
  return {
81
119
  cols,
82
120
  rows,
121
+ cellSize,
122
+ origin: [originX, originZ],
123
+ retune,
124
+ restore(state) {
125
+ if (state.length !== cells.length)
126
+ throw new Error("fire snapshot dimensions do not match");
127
+ const next = state.map((cell) => {
128
+ if (!Number.isFinite(cell.fuel) || cell.fuel < 0 || !Number.isFinite(cell.heat) || cell.heat < 0 || !["unburnt", "burning", "burnt"].includes(cell.state) || (cell.state === "burning" && cell.fuel <= 0) || (cell.state === "burnt" && (cell.fuel !== 0 || cell.heat !== 0)))
129
+ throw new Error("invalid fire snapshot cell");
130
+ return { ...cell };
131
+ });
132
+ burning = 0;
133
+ for (let i = 0; i < cells.length; i += 1) {
134
+ cells[i] = next[i];
135
+ if (cells[i].state === "burning")
136
+ burning += 1;
137
+ }
138
+ },
83
139
  step(dt, options) {
84
- if (dt <= 0)
140
+ if (!Number.isFinite(dt) || dt < 0)
141
+ throw new Error("fire dt must be finite and nonnegative");
142
+ if (dt === 0)
85
143
  return;
86
144
  const globalSpread = options?.spread ?? 1;
145
+ if (!Number.isFinite(globalSpread) || globalSpread < 0)
146
+ throw new Error("fire spread multiplier must be finite and nonnegative");
87
147
  const wetnessAt = options?.wetnessAt;
88
- const addedHeat = new Float64Array(cols * rows);
148
+ const extinguishRate = options?.extinguishRate ?? 0;
149
+ if (!Number.isFinite(extinguishRate) || extinguishRate < 0)
150
+ throw new Error("fire extinguishRate must be finite and nonnegative");
151
+ const resolveWetness = (col, row) => {
152
+ const value = wetnessAt?.(col, row) ?? 0;
153
+ if (!Number.isFinite(value))
154
+ throw new Error("fire wetness must be finite");
155
+ return Math.max(0, Math.min(1, value));
156
+ };
157
+ addedHeat.fill(0);
158
+ wetness.fill(0);
89
159
  for (let row = 0; row < rows; row += 1) {
90
160
  for (let col = 0; col < cols; col += 1) {
91
161
  const cell = cells[index(col, row)];
92
162
  if (cell.state !== "burning")
93
163
  continue;
164
+ const vector = options?.windAt?.(originX + col * cellSize, originZ + row * cellSize) ?? options?.wind ?? wind;
165
+ if (vector !== undefined && (!Number.isFinite(vector[0]) || !Number.isFinite(vector[1])))
166
+ throw new Error("fire step wind must be finite");
167
+ const i = index(col, row);
168
+ if (extinguishRate > 0)
169
+ wetness[i] = resolveWetness(col, row);
170
+ const coolingRate = extinguishRate * wetness[i];
171
+ const burningDt = Math.min(dt, burnRate === 0 ? Infinity : cell.fuel / burnRate, coolingRate === 0 ? Infinity : cell.heat / coolingRate);
94
172
  for (const [dx, dz] of NEIGHBOURS) {
95
173
  const nc = col + dx;
96
174
  const nr = row + dz;
@@ -100,7 +178,7 @@ export function createFireGrid(config) {
100
178
  if (neighbour.state === "burnt" || neighbour.fuel <= 0)
101
179
  continue;
102
180
  addedHeat[index(nc, nr)] +=
103
- spreadRate * globalSpread * directionalWeight(dx, dz) * dt;
181
+ spreadRate * globalSpread * directionalWeight(dx, dz, vector) * burningDt;
104
182
  }
105
183
  }
106
184
  }
@@ -108,19 +186,32 @@ export function createFireGrid(config) {
108
186
  for (let col = 0; col < cols; col += 1) {
109
187
  const i = index(col, row);
110
188
  const cell = cells[i];
189
+ if (cell.state === "unburnt" && (addedHeat[i] > 0 || (extinguishRate > 0 && cell.heat > 0)))
190
+ wetness[i] = resolveWetness(col, row);
191
+ }
192
+ }
193
+ for (let row = 0; row < rows; row += 1) {
194
+ for (let col = 0; col < cols; col += 1) {
195
+ const i = index(col, row);
196
+ const cell = cells[i];
197
+ const coolingRate = extinguishRate * wetness[i];
111
198
  if (cell.state === "burning") {
112
- cell.fuel = Math.max(0, cell.fuel - burnRate * dt);
199
+ const burningDt = Math.min(dt, coolingRate === 0 ? Infinity : cell.heat / coolingRate);
200
+ cell.fuel = Math.max(0, cell.fuel - burnRate * burningDt);
201
+ cell.heat = Math.max(0, cell.heat - coolingRate * dt);
113
202
  if (cell.fuel <= 0) {
114
203
  cell.state = "burnt";
115
204
  cell.heat = 0;
116
205
  burning -= 1;
117
206
  }
207
+ else if (coolingRate > 0 && cell.heat <= 0) {
208
+ extinguishCell(col, row);
209
+ }
118
210
  continue;
119
211
  }
120
- if (cell.state !== "unburnt" || addedHeat[i] === 0)
212
+ if (cell.state !== "unburnt" || (addedHeat[i] === 0 && coolingRate === 0))
121
213
  continue;
122
- const resist = wetnessAt === undefined ? 0 : Math.max(0, Math.min(1, wetnessAt(col, row)));
123
- cell.heat += addedHeat[i] * (1 - resist);
214
+ cell.heat = Math.max(0, cell.heat + addedHeat[i] * (1 - wetness[i]) - coolingRate * dt);
124
215
  if (cell.heat >= ignitionThreshold && cell.fuel > 0) {
125
216
  cell.state = "burning";
126
217
  burning += 1;
@@ -129,6 +220,11 @@ export function createFireGrid(config) {
129
220
  }
130
221
  },
131
222
  igniteCell,
223
+ extinguishCell,
224
+ extinguish(x, z) {
225
+ const coord = cellCoord(x, z);
226
+ return coord !== null && extinguishCell(coord.col, coord.row);
227
+ },
132
228
  ignite(x, z) {
133
229
  const coord = cellCoord(x, z);
134
230
  if (coord === null)