@jgengine/core 0.14.0 → 0.15.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 (214) hide show
  1. package/CHANGELOG.md +500 -1
  2. package/dist/ai/difficulty.d.ts +154 -0
  3. package/dist/ai/difficulty.js +194 -0
  4. package/dist/ai/driver.d.ts +107 -0
  5. package/dist/ai/driver.js +175 -0
  6. package/dist/ai/flock.js +13 -23
  7. package/dist/ai/laneSelect.js +2 -1
  8. package/dist/ai/mobBrain.js +2 -1
  9. package/dist/ai/waveRunner.d.ts +81 -0
  10. package/dist/ai/waveRunner.js +104 -0
  11. package/dist/audio/musicState.d.ts +47 -0
  12. package/dist/audio/musicState.js +38 -0
  13. package/dist/audio/stepCadence.d.ts +47 -0
  14. package/dist/audio/stepCadence.js +29 -0
  15. package/dist/audio/synth.d.ts +12 -0
  16. package/dist/authoring.d.ts +6 -4
  17. package/dist/authoring.js +6 -4
  18. package/dist/combat/castRunner.d.ts +13 -1
  19. package/dist/combat/castRunner.js +5 -1
  20. package/dist/combat/comboMeter.d.ts +140 -0
  21. package/dist/combat/comboMeter.js +215 -0
  22. package/dist/combat/comboPoints.d.ts +11 -0
  23. package/dist/combat/comboPoints.js +11 -2
  24. package/dist/combat/damageResolution.d.ts +1 -1
  25. package/dist/combat/defensiveWindow.d.ts +5 -0
  26. package/dist/combat/effects.d.ts +24 -0
  27. package/dist/combat/effects.js +6 -1
  28. package/dist/combat/projectiles.d.ts +6 -0
  29. package/dist/combat/projectiles.js +2 -2
  30. package/dist/combat/regenShield.d.ts +40 -2
  31. package/dist/combat/regenShield.js +66 -18
  32. package/dist/combat/resourcePool.d.ts +19 -0
  33. package/dist/combat/resourcePool.js +20 -2
  34. package/dist/combat/shotOrigin.d.ts +5 -0
  35. package/dist/combat/statusApplication.d.ts +1 -1
  36. package/dist/combat/statusApplication.js +2 -1
  37. package/dist/combat/statusEffectView.d.ts +56 -0
  38. package/dist/combat/statusEffectView.js +52 -0
  39. package/dist/combat.d.ts +4 -2
  40. package/dist/combat.js +2 -0
  41. package/dist/devtools/fallbackSeams.d.ts +3 -2
  42. package/dist/devtools/fallbackSeams.js +1 -1
  43. package/dist/devtools/textureErrors.d.ts +40 -0
  44. package/dist/devtools/textureErrors.js +68 -0
  45. package/dist/economy/shopStock.d.ts +110 -0
  46. package/dist/economy/shopStock.js +157 -0
  47. package/dist/economy/wallet.d.ts +6 -0
  48. package/dist/economy/wallet.js +1 -0
  49. package/dist/game/achievements.d.ts +80 -0
  50. package/dist/game/achievements.js +124 -0
  51. package/dist/game/cameraConfig.d.ts +7 -0
  52. package/dist/game/codex.d.ts +60 -0
  53. package/dist/game/codex.js +89 -0
  54. package/dist/game/credits.d.ts +36 -0
  55. package/dist/game/credits.js +0 -0
  56. package/dist/game/defineGame.d.ts +28 -3
  57. package/dist/game/defineGame.js +5 -0
  58. package/dist/game/dialogueGraph.d.ts +116 -0
  59. package/dist/game/dialogueGraph.js +103 -0
  60. package/dist/game/eventTicker.d.ts +95 -0
  61. package/dist/game/eventTicker.js +91 -0
  62. package/dist/game/events.d.ts +24 -5
  63. package/dist/game/events.js +18 -0
  64. package/dist/game/gamePhase.d.ts +1 -1
  65. package/dist/game/gamePhase.js +1 -1
  66. package/dist/game/leaderboardRank.d.ts +71 -0
  67. package/dist/game/leaderboardRank.js +76 -0
  68. package/dist/game/lootTable.d.ts +10 -1
  69. package/dist/game/lootTable.js +4 -2
  70. package/dist/game/notifications.d.ts +57 -0
  71. package/dist/game/notifications.js +107 -0
  72. package/dist/game/playableGame.d.ts +39 -2
  73. package/dist/game/quest.d.ts +26 -0
  74. package/dist/game/quest.js +31 -0
  75. package/dist/game/runDraft.js +2 -1
  76. package/dist/game/saveSlots.d.ts +102 -0
  77. package/dist/game/saveSlots.js +136 -0
  78. package/dist/game/talentTreeView.d.ts +106 -0
  79. package/dist/game/talentTreeView.js +114 -0
  80. package/dist/game/worldItem.js +2 -1
  81. package/dist/gameplay.d.ts +15 -4
  82. package/dist/gameplay.js +13 -2
  83. package/dist/i18n/i18n.d.ts +51 -0
  84. package/dist/i18n/i18n.js +82 -0
  85. package/dist/input/axisInput.d.ts +1 -1
  86. package/dist/input/axisInput.js +18 -2
  87. package/dist/input/rebindSession.d.ts +141 -0
  88. package/dist/input/rebindSession.js +235 -0
  89. package/dist/input/touchControlsMode.d.ts +16 -0
  90. package/dist/input/touchControlsMode.js +23 -0
  91. package/dist/input/touchScheme.d.ts +30 -1
  92. package/dist/input/touchScheme.js +31 -1
  93. package/dist/interaction/promptRegistry.d.ts +78 -0
  94. package/dist/interaction/promptRegistry.js +130 -0
  95. package/dist/inventory/storageTier.js +3 -2
  96. package/dist/meta/changelog.d.ts +1 -1
  97. package/dist/meta/changelog.js +145 -1
  98. package/dist/movement/movementModel.d.ts +26 -3
  99. package/dist/movement/movementModel.js +121 -15
  100. package/dist/movement/playerMovement.js +161 -25
  101. package/dist/physics/drivableVehicle.js +3 -1
  102. package/dist/physics/kinematicVehicle.d.ts +56 -1
  103. package/dist/physics/kinematicVehicle.js +99 -10
  104. package/dist/physics/vehicleObstacles.js +19 -0
  105. package/dist/random/resolveRng.d.ts +14 -0
  106. package/dist/random/resolveRng.js +31 -0
  107. package/dist/render/postProcessing.d.ts +12 -1
  108. package/dist/runtime/cameraDirector.d.ts +2 -2
  109. package/dist/runtime/context/combat.js +8 -0
  110. package/dist/runtime/gameContext.js +6 -1
  111. package/dist/runtime/gameContextTypes.d.ts +18 -0
  112. package/dist/runtime/inputSnapshot.d.ts +10 -1
  113. package/dist/runtime/inputSnapshot.js +13 -1
  114. package/dist/runtime/runtimeSave.d.ts +3 -3
  115. package/dist/runtime/runtimeSave.js +11 -2
  116. package/dist/scene/assetDiagnostics.js +2 -2
  117. package/dist/scene/autoTarget.js +2 -1
  118. package/dist/scene/captureCheck.js +3 -2
  119. package/dist/scene/entityStore.d.ts +4 -1
  120. package/dist/scene/entityStore.js +3 -0
  121. package/dist/scene/objectStore.d.ts +10 -0
  122. package/dist/scene/objectStore.js +29 -6
  123. package/dist/scene/sceneKinds.d.ts +18 -0
  124. package/dist/scene/sceneKinds.js +19 -0
  125. package/dist/scene/sequenceDirector.d.ts +110 -0
  126. package/dist/scene/sequenceDirector.js +146 -0
  127. package/dist/session/roles.js +3 -2
  128. package/dist/settings/settingsModel.d.ts +1 -0
  129. package/dist/settings/settingsModel.js +1 -0
  130. package/dist/stats/rollCheck.js +4 -2
  131. package/dist/time/dayNightCycle.d.ts +114 -0
  132. package/dist/time/dayNightCycle.js +208 -0
  133. package/dist/time/timerSet.d.ts +132 -0
  134. package/dist/time/timerSet.js +243 -0
  135. package/dist/ui/accessibility.d.ts +51 -0
  136. package/dist/ui/accessibility.js +83 -0
  137. package/dist/ui/coachMarks.d.ts +99 -0
  138. package/dist/ui/coachMarks.js +126 -0
  139. package/dist/ui/floatingText.d.ts +114 -0
  140. package/dist/ui/floatingText.js +203 -0
  141. package/dist/ui/modalStack.d.ts +143 -0
  142. package/dist/ui/modalStack.js +160 -0
  143. package/dist/ui/objectiveBanner.d.ts +123 -0
  144. package/dist/ui/objectiveBanner.js +162 -0
  145. package/dist/ui/photoMode.d.ts +32 -0
  146. package/dist/ui/photoMode.js +51 -0
  147. package/dist/ui/radialMenu.d.ts +47 -0
  148. package/dist/ui/radialMenu.js +74 -0
  149. package/dist/ui/screenMarkers.d.ts +128 -0
  150. package/dist/ui/screenMarkers.js +118 -0
  151. package/dist/ui.d.ts +8 -0
  152. package/dist/ui.js +8 -0
  153. package/dist/vfx/cameraShake.d.ts +97 -0
  154. package/dist/vfx/cameraShake.js +136 -0
  155. package/dist/vfx/damageDirection.d.ts +102 -0
  156. package/dist/vfx/damageDirection.js +188 -0
  157. package/dist/vfx/particles.d.ts +103 -0
  158. package/dist/vfx/particles.js +256 -0
  159. package/dist/vfx/screenEffects.d.ts +149 -0
  160. package/dist/vfx/screenEffects.js +191 -0
  161. package/dist/world/authoredObjects.d.ts +18 -0
  162. package/dist/world/authoredObjects.js +21 -0
  163. package/dist/world/authoredSpawn.js +19 -0
  164. package/dist/world/buildingLots.d.ts +54 -11
  165. package/dist/world/buildingLots.js +116 -30
  166. package/dist/world/cityBlocks.d.ts +45 -0
  167. package/dist/world/cityBlocks.js +159 -0
  168. package/dist/world/cityContent.d.ts +46 -2
  169. package/dist/world/cityContent.js +160 -2
  170. package/dist/world/cityGenerator.d.ts +261 -23
  171. package/dist/world/cityGenerator.js +643 -33
  172. package/dist/world/cityGeometry.d.ts +20 -0
  173. package/dist/world/cityGeometry.js +91 -0
  174. package/dist/world/cityKind.js +1 -44
  175. package/dist/world/fastTravel.d.ts +64 -0
  176. package/dist/world/fastTravel.js +108 -0
  177. package/dist/world/features.d.ts +16 -0
  178. package/dist/world/features.js +2 -0
  179. package/dist/world/grounded.d.ts +11 -0
  180. package/dist/world/grounded.js +12 -0
  181. package/dist/world/mapAnnotations.d.ts +75 -0
  182. package/dist/world/mapAnnotations.js +139 -0
  183. package/dist/world/markers.js +1 -0
  184. package/dist/world/resourceNode.d.ts +1 -1
  185. package/dist/world/resourceNode.js +2 -1
  186. package/dist/world/roads.d.ts +294 -5
  187. package/dist/world/roads.js +1262 -21
  188. package/dist/world/sharedAnnotations.d.ts +84 -0
  189. package/dist/world/sharedAnnotations.js +76 -0
  190. package/dist/world/spawnOverride.d.ts +37 -0
  191. package/dist/world/spawnOverride.js +50 -0
  192. package/dist/world/streetGenerator.d.ts +60 -11
  193. package/dist/world/streetGenerator.js +1786 -129
  194. package/dist/world/terrain.d.ts +1 -0
  195. package/dist/world/terrain.js +2 -0
  196. package/dist/world/waypoints.d.ts +63 -0
  197. package/dist/world/waypoints.js +137 -0
  198. package/dist/world.d.ts +17 -2
  199. package/dist/world.js +17 -2
  200. package/package.json +1 -1
  201. package/skills/ce-handoff/SKILL.md +149 -0
  202. package/skills/game-design/SKILL.md +2 -0
  203. package/skills/jgengine/SKILL.md +3 -1
  204. package/skills/jgengine/api.md +17 -7
  205. package/skills/jgengine/capabilities.md +12 -0
  206. package/skills/jgengine/recipes/minimal-game.md +7 -4
  207. package/skills/jgengine-combat/api.md +40 -4
  208. package/skills/jgengine-combat/capabilities.md +12 -0
  209. package/skills/jgengine-combat/reference.md +2 -0
  210. package/skills/jgengine-gameplay/api.md +212 -11
  211. package/skills/jgengine-gameplay/capabilities.md +66 -1
  212. package/skills/jgengine-world/api.md +397 -11
  213. package/skills/jgengine-world/capabilities.md +123 -2
  214. package/skills/jgengine-world/reference.md +15 -1
@@ -1,4 +1,39 @@
1
+ /**
2
+ * Street-aware building placement: derive building LOTS along ROAD FRONTAGE. Given road segments
3
+ * (centerline + width) and a district area, walk each road by arc length and emit lots on both
4
+ * sides at a consistent setback — a curb + sidewalk strip — with each lot carrying the yaw that
5
+ * makes the building FRONT face its road. Unlike `cityBlocks` (which needs a CLOSED street network
6
+ * to extract planar block faces), this works on a sparse set of segments — the common authoring
7
+ * case — so buildings line the streets instead of scattering on plinths around a center point.
8
+ *
9
+ * Pure seeded math over `cityGeometry` `Vec2` primitives: no rendering, deterministic from the
10
+ * inputs, bounded by `maxLots`.
11
+ *
12
+ * @capability world-environment lots aligned to and facing road frontage, with a setback sidewalk strip
13
+ */
14
+ import { rectClearsPolyline, rectsSeparated } from "./cityGeometry.js";
15
+ import { seededStreams } from "../random/rng.js";
1
16
  const DEFAULT_FOOTPRINT = { w: 12, d: 10 };
17
+ /**
18
+ * Frontage-fill reference point: at this dial value the compaction is a no-op (legacy spacing/width),
19
+ * so callers can leave `blockFill` unset for the classic look and this value reproduces it exactly.
20
+ */
21
+ export const FRONTAGE_FILL_REFERENCE = 0.45;
22
+ /** Modest extra frontage-width growth applied at full fill so consecutive lots touch (edge-to-edge). */
23
+ const FRONTAGE_WIDTH_OVERFILL = 0.1;
24
+ /**
25
+ * Map a `blockFill` dial to frontage compaction factors. `undefined` ⇒ identity (legacy path stays
26
+ * byte-identical). At {@link FRONTAGE_FILL_REFERENCE} the factors are exactly `{1, 1}`; toward 1 the
27
+ * gap collapses and lots widen, toward 0 the gap grows (sparser than default). @internal
28
+ */
29
+ export function frontageCompaction(blockFill) {
30
+ if (blockFill === undefined)
31
+ return { spacingScale: 1, widthScale: 1 };
32
+ const f = Math.max(0, Math.min(1, blockFill));
33
+ const spacingScale = (1 - f) / (1 - FRONTAGE_FILL_REFERENCE);
34
+ const widthScale = 1 + (Math.max(0, f - FRONTAGE_FILL_REFERENCE) / (1 - FRONTAGE_FILL_REFERENCE)) * FRONTAGE_WIDTH_OVERFILL;
35
+ return { spacingScale, widthScale };
36
+ }
2
37
  /** Cumulative arc length of a polyline. */
3
38
  function polylineLength(path) {
4
39
  let sum = 0;
@@ -45,27 +80,59 @@ export function facingRotation(outwardNormal) {
45
80
  return Math.atan2(-outwardNormal[0], -outwardNormal[1]);
46
81
  }
47
82
  /**
48
- * Derive street-aligned building lots from road frontage. Lots are stepped along each road at
49
- * `footprint.w + spacing`, offset to each side by `width / 2 + setback + footprint.d / 2`, and
50
- * turned to face the road. Cross-road overlaps are rejected by a bounded nearest-neighbour test, so
51
- * two roads meeting at a corner don't stack buildings. Deterministic: identical inputs (including
52
- * seed) always yield the identical list.
83
+ * Derive street-aligned building lots (PLOTS) from road frontage. Each side of each road walks the
84
+ * frontage placing plots at `plot.w + spacing` pitch — rolling the next plot size from the weighted
85
+ * `footprint` variants when several are given — offset outward by `width / 2 + setback + plot.d / 2`
86
+ * and turned to face the road. The PLOT CONTRACT is enforced here, exactly: no two plots ever overlap
87
+ * (true oriented-rect separation with the spacing gap, so corners and curved frontage can't stack
88
+ * buildings), and no plot footprint ever touches any road corridor in `roads` or `avoid` — not
89
+ * just its own frontage road. Deterministic: identical inputs (including seed) always yield the
90
+ * identical list.
53
91
  * @capability building-lots derive street-facing building lots from road frontage
54
92
  */
55
93
  export function deriveBuildingLots(options) {
56
- const footprint = options.footprint ?? DEFAULT_FOOTPRINT;
57
- const spacing = Math.max(0, options.spacing ?? 2);
94
+ const raw = options.footprint ?? DEFAULT_FOOTPRINT;
95
+ // `blockFill` compaction: widen each lot's frontage and shrink the along-road gap toward a
96
+ // Manhattan streetwall as the dial rises. Undefined ⇒ identity, so the classic path is untouched.
97
+ // Compaction composes with plot variants — every variant's frontage widens by the same factor.
98
+ const { spacingScale, widthScale } = frontageCompaction(options.blockFill);
99
+ const variants = (Array.isArray(raw) ? raw : [raw])
100
+ .filter((v) => v.w > 0.5 && v.d > 0.5)
101
+ .map((v) => ({ w: v.w * widthScale, d: v.d, weight: Math.max(0, v.weight ?? 1) }));
102
+ if (variants.length === 0)
103
+ variants.push({ ...DEFAULT_FOOTPRINT, weight: 1 });
104
+ const totalWeight = variants.reduce((sum, v) => sum + v.weight, 0) || 1;
105
+ const pickVariant = (roll) => {
106
+ let cursor = roll * totalWeight;
107
+ for (const v of variants) {
108
+ cursor -= v.weight;
109
+ if (cursor <= 0)
110
+ return v;
111
+ }
112
+ return variants[0];
113
+ };
114
+ const spacing = Math.max(0, options.spacing ?? 2) * spacingScale;
58
115
  const setback = Math.max(0, options.setback ?? 3);
59
116
  const bothSides = options.bothSides ?? true;
60
117
  const maxLots = Math.max(0, Math.floor(options.maxLots ?? 400));
61
- const step = Math.max(1, footprint.w + spacing);
118
+ const streams = seededStreams(`plots:${options.seed ?? ""}`);
62
119
  const sides = bothSides ? [1, -1] : [1];
63
- // Minimum center separation for cross-road de-dup: half the footprint diagonal, a touch under
64
- // the along-road step so same-road neighbours are never rejected.
65
- const minSep = Math.min(step * 0.95, Math.hypot(footprint.w, footprint.d) * 0.5);
66
- const minSep2 = minSep * minSep;
120
+ const corridors = [];
121
+ for (const road of options.roads)
122
+ corridors.push({ path: road.path, half: road.width / 2 });
123
+ for (const road of options.avoid ?? [])
124
+ corridors.push({ path: road.path, half: road.width / 2 });
67
125
  const lots = [];
68
126
  const accepted = [];
127
+ // Gap grown onto the candidate for the plot-vs-plot separation test. Just under half the
128
+ // spacing keeps same-road neighbours (spaced exactly `spacing` apart) accepted while anything
129
+ // closer — corner stacking, inside-of-curve pinches — is rejected. At `spacing` 0 the gap goes
130
+ // slightly NEGATIVE (a shrink), so exactly-touching plots (terraces, rowhouse walls) are legal:
131
+ // touching is a valid look, overlapping never is.
132
+ const gap = spacing * 0.45 - 0.05;
133
+ // Plots may touch the asphalt edge but never intrude into it; the tiny epsilon keeps an exact
134
+ // setback-0 front face (touching the corridor boundary) legal.
135
+ const roadClearanceSlack = 0.02;
69
136
  for (let r = 0; r < options.roads.length && lots.length < maxLots; r += 1) {
70
137
  const road = options.roads[r];
71
138
  if (road.path.length < 2)
@@ -74,44 +141,63 @@ export function deriveBuildingLots(options) {
74
141
  if (total < 1e-6)
75
142
  continue;
76
143
  const front = road.width / 2 + setback;
77
- const offset = front + footprint.d / 2;
78
- // Center the run so lots sit symmetrically and endpoints keep a half-step margin.
79
- const count = Math.max(1, Math.floor(total / step));
80
- const startS = (total - (count - 1) * step) / 2;
81
- for (let i = 0; i < count && lots.length < maxLots; i += 1) {
82
- const s = startS + i * step;
83
- const station = stationAt(road.path, s);
84
- const [tx, tz] = station.tangent;
85
- // Left normal of the tangent (CCW): (-tz, tx). Right side negates it.
86
- for (const side of sides) {
87
- if (lots.length >= maxLots)
144
+ // Each side walks its own seeded cursor so variant runs differ across the street — terraces
145
+ // can face detached houses. With a single variant the walk degenerates to the fixed pitch.
146
+ for (const side of sides) {
147
+ const roll = variants.length > 1 ? streams(`road:${r}:side:${side}`) : null;
148
+ let cursor = spacing / 2 + 0.5;
149
+ while (cursor < total && lots.length < maxLots) {
150
+ const plot = roll === null ? variants[0] : pickVariant(roll());
151
+ if (cursor + plot.w > total - 0.5)
88
152
  break;
153
+ const s = cursor + plot.w / 2;
154
+ cursor += plot.w + spacing;
155
+ const station = stationAt(road.path, s);
156
+ const [tx, tz] = station.tangent;
157
+ // Left normal of the tangent (CCW): (-tz, tx). Right side negates it.
89
158
  const nx = -tz * side;
90
159
  const nz = tx * side;
160
+ const offset = front + plot.d / 2;
91
161
  const cx = station.p[0] + nx * offset;
92
162
  const cz = station.p[1] + nz * offset;
93
163
  if (!inArea(options.area, cx, cz))
94
164
  continue;
165
+ const rotationY = facingRotation([nx, nz]);
166
+ const hw = plot.w / 2;
167
+ const hd = plot.d / 2;
168
+ const rect = { x: cx, z: cz, hw, hd, angle: rotationY };
169
+ // Plot-vs-plot: reject any candidate whose rect (grown by the spacing gap) overlaps an
170
+ // accepted plot — regardless of which road placed it.
171
+ const grown = { ...rect, hw: hw + gap, hd: hd + gap };
95
172
  let clash = false;
96
- for (const [ax, az] of accepted) {
97
- const dx = ax - cx;
98
- const dz = az - cz;
99
- if (dx * dx + dz * dz < minSep2) {
173
+ for (const other of accepted) {
174
+ if (!rectsSeparated(grown, other)) {
100
175
  clash = true;
101
176
  break;
102
177
  }
103
178
  }
104
179
  if (clash)
105
180
  continue;
181
+ // Plot-vs-road: the full footprint stays off every corridor (frontage roads AND avoid
182
+ // corridors), so a plot can never straddle a crossing street.
183
+ let onRoad = false;
184
+ for (const corridor of corridors) {
185
+ if (!rectClearsPolyline(rect, corridor.path, corridor.half - roadClearanceSlack)) {
186
+ onRoad = true;
187
+ break;
188
+ }
189
+ }
190
+ if (onRoad)
191
+ continue;
106
192
  lots.push({
107
193
  center: [cx, cz],
108
- rotationY: facingRotation([nx, nz]),
109
- footprint: { w: footprint.w, d: footprint.d },
194
+ rotationY,
195
+ footprint: { w: plot.w, d: plot.d },
110
196
  road: r,
111
197
  side,
112
198
  frontDistance: front,
113
199
  });
114
- accepted.push([cx, cz]);
200
+ accepted.push(rect);
115
201
  }
116
202
  }
117
203
  }
@@ -94,6 +94,51 @@ export declare function extractBlocks(streets: readonly FabricStreet[], hx: numb
94
94
  width: number;
95
95
  }[];
96
96
  };
97
+ /** Minimal node/edge shapes {@link extractGraphBlocks} needs (matches `StreetNetwork.nodes`/`edges`). */
98
+ export interface GraphBlockNode {
99
+ x: number;
100
+ z: number;
101
+ }
102
+ /** One street-graph edge {@link extractGraphBlocks} traces block faces through. */
103
+ export interface GraphBlockEdge {
104
+ /** Endpoint node indices. `points[0]` sits exactly on node `a`, the last on node `b`. */
105
+ a: number;
106
+ b: number;
107
+ points: readonly (readonly [number, number])[];
108
+ width: number;
109
+ level: FabricStreet["level"];
110
+ /** Owning street index, carried through to pruned dead-end corridors so callers can line them. */
111
+ street?: number;
112
+ }
113
+ /** One block from {@link extractGraphBlocks}: the face polygon and its inset land ring. */
114
+ export interface GraphBlockRings {
115
+ /** Face polygon on road CENTERLINES (as traced, interior faces CCW). */
116
+ face: Vec2[];
117
+ /** Land ring: face inset by each bordering edge's half width + curb margin + sidewalk band. */
118
+ land: Vec2[];
119
+ }
120
+ /**
121
+ * Extract closed blocks from an EXACT street graph — the `nodes`/`edges` a street network already
122
+ * carries — instead of re-deriving topology from welded polylines. Half-edges are ordered by true
123
+ * departure angle at each node, dangling chains are pruned first (returned as `deadEnds` for
124
+ * corridor carving), interior faces are traced through the rotation system, and each face is inset
125
+ * to its land ring by the bordering edges' half-widths (+ curb + sidewalk). Robust against wandered
126
+ * and arc-filleted centerlines, which defeat proximity welding. Pure bounded geometry.
127
+ *
128
+ * @capability city-district extract block polygons directly from a street network's exact graph
129
+ */
130
+ export declare function extractGraphBlocks(nodes: readonly GraphBlockNode[], edges: readonly GraphBlockEdge[], params: {
131
+ sidewalkBase: number;
132
+ curbMargin: number;
133
+ }): {
134
+ blocks: GraphBlockRings[];
135
+ deadEnds: {
136
+ pts: Vec2[];
137
+ width: number;
138
+ level: FabricStreet["level"];
139
+ street?: number;
140
+ }[];
141
+ };
97
142
  /** Arc-length station lookup along a closed ring. */
98
143
  export declare class RingWalker {
99
144
  readonly ring: readonly Vec2[];
@@ -11,8 +11,20 @@
11
11
  * @capability city-district road-derived block polygons, parcel subdivision, buildable footprints
12
12
  */
13
13
  import { clipHalfPlane, dedupeRing, distanceToRing, ensureCcw, extractSimpleLoop, insetRing, pointInPolygon, polygonArea, polygonMeanWidth, polygonSignedArea, } from "./cityGeometry.js";
14
+ import { isProductionEnvironment } from "../devtools/saveEndpoint.js";
14
15
  const MAX_BLOCKS = 420;
15
16
  const WELD_RADIUS = 1.4;
17
+ // Proximity welding (WELD_RADIUS) assumes near-collinear / aligned centerlines. Wandered and
18
+ // arc-filleted streets weld into a degenerate face set: a real case went 38 streets → 2 faces
19
+ // with no signal. These constants gate a dev-mode collapse warning. A connected road grid has
20
+ // bounded faces on the order of its street count (Euler: bounded faces = edges − nodes + 1), so
21
+ // faces far below the street count means welding ate the topology, not that the district is sparse.
22
+ // COLLAPSE_MIN_STREETS avoids false positives on genuinely tiny inputs (a handful of crossings
23
+ // legitimately yields few faces); COLLAPSE_RATIO_DIVISOR flags only a gross collapse — with 8
24
+ // streets it fires under 2 faces, with 38 streets under 9 (the 38 → 2 case triggers, a healthy
25
+ // grid where faces ≥ street count never does).
26
+ const COLLAPSE_MIN_STREETS = 8;
27
+ const COLLAPSE_RATIO_DIVISOR = 4;
16
28
  const RIM_SNAP = 6;
17
29
  /** Sidewalk band width by street hierarchy (base = the district's street-level width). */
18
30
  export function sidewalkWidthFor(level, base) {
@@ -346,6 +358,7 @@ function extractFaces(nodes, halves) {
346
358
  export function extractBlocks(streets, hx, hz, params) {
347
359
  const { nodes, halves, deadEnds } = buildGraph(streets, hx, hz);
348
360
  const faces = extractFaces(nodes, halves);
361
+ warnOnImplausibleCollapse(streets.length, faces.length);
349
362
  faces.sort((a, b) => polygonArea(b.polygon) - polygonArea(a.polygon));
350
363
  const blocks = [];
351
364
  for (const face of faces) {
@@ -391,6 +404,26 @@ export function extractBlocks(streets, hx, hz, params) {
391
404
  }
392
405
  return { blocks, deadEnds };
393
406
  }
407
+ /**
408
+ * Dev-mode guard: warn when {@link extractBlocks} extracts implausibly few faces for the street
409
+ * count — the silent-collapse failure mode where proximity welding folds wandered / arc-filleted
410
+ * centerlines into a degenerate face set (see issue #1502: 38 streets → 2 faces). No-ops in a
411
+ * production build. Points at {@link extractGraphBlocks}, which traces faces from the exact street
412
+ * graph and is robust against the centerlines that defeat welding.
413
+ */
414
+ function warnOnImplausibleCollapse(streetCount, faceCount) {
415
+ if (isProductionEnvironment() || typeof console === "undefined")
416
+ return;
417
+ if (streetCount < COLLAPSE_MIN_STREETS)
418
+ return;
419
+ if (faceCount >= streetCount / COLLAPSE_RATIO_DIVISOR)
420
+ return;
421
+ console.warn(`[jgengine:extractBlocks] ${streetCount} streets extracted only ${faceCount} block face(s) — ` +
422
+ "implausibly few, likely a silent proximity-welding collapse on wandered / arc-filleted " +
423
+ "centerlines (the WELD_RADIUS heuristic assumes near-aligned streets). The fabric will look " +
424
+ "empty rather than error. Trace blocks from the exact street graph with extractGraphBlocks() " +
425
+ "instead, which is robust against centerlines that defeat welding.");
426
+ }
394
427
  /** Reverse per-edge street labels when a ring got rewound to CCW. */
395
428
  function remapReversedEdgeStreets(streetsPerEdge) {
396
429
  const n = streetsPerEdge.length;
@@ -400,6 +433,132 @@ function remapReversedEdgeStreets(streetsPerEdge) {
400
433
  out[i] = streetsPerEdge[(2 * n - 2 - i) % n];
401
434
  return out;
402
435
  }
436
+ /**
437
+ * Extract closed blocks from an EXACT street graph — the `nodes`/`edges` a street network already
438
+ * carries — instead of re-deriving topology from welded polylines. Half-edges are ordered by true
439
+ * departure angle at each node, dangling chains are pruned first (returned as `deadEnds` for
440
+ * corridor carving), interior faces are traced through the rotation system, and each face is inset
441
+ * to its land ring by the bordering edges' half-widths (+ curb + sidewalk). Robust against wandered
442
+ * and arc-filleted centerlines, which defeat proximity welding. Pure bounded geometry.
443
+ *
444
+ * @capability city-district extract block polygons directly from a street network's exact graph
445
+ */
446
+ export function extractGraphBlocks(nodes, edges, params) {
447
+ // --- prune dangling chains: an edge touching a degree-1 node can't border a closed face ---
448
+ const degree = new Array(nodes.length).fill(0);
449
+ const alive = edges.map((e) => e.a !== e.b && e.points.length >= 2);
450
+ edges.forEach((e, i) => {
451
+ if (!alive[i])
452
+ return;
453
+ degree[e.a] += 1;
454
+ degree[e.b] += 1;
455
+ });
456
+ const deadEnds = [];
457
+ let pruned = true;
458
+ while (pruned) {
459
+ pruned = false;
460
+ for (let i = 0; i < edges.length; i += 1) {
461
+ if (!alive[i])
462
+ continue;
463
+ const e = edges[i];
464
+ if (degree[e.a] === 1 || degree[e.b] === 1) {
465
+ alive[i] = false;
466
+ deadEnds.push({ pts: e.points.map((p) => [p[0], p[1]]), width: e.width, level: e.level, street: e.street });
467
+ degree[e.a] -= 1;
468
+ degree[e.b] -= 1;
469
+ pruned = true;
470
+ }
471
+ }
472
+ }
473
+ const halves = [];
474
+ const out = nodes.map(() => []);
475
+ const departAngle = (pts) => {
476
+ // First non-degenerate segment sets the true departure direction.
477
+ for (let i = 1; i < pts.length; i += 1) {
478
+ const dx = pts[i][0] - pts[0][0];
479
+ const dz = pts[i][1] - pts[0][1];
480
+ if (dx * dx + dz * dz > 1e-12)
481
+ return Math.atan2(dz, dx);
482
+ }
483
+ return 0;
484
+ };
485
+ const angles = [];
486
+ edges.forEach((e, ei) => {
487
+ if (!alive[ei])
488
+ return;
489
+ const fwd = e.points.map((p) => [p[0], p[1]]);
490
+ const rev = [...fwd].reverse();
491
+ const h0 = halves.length;
492
+ halves.push({ to: e.b, pts: fwd, twin: h0 + 1, edge: ei, visited: false });
493
+ halves.push({ to: e.a, pts: rev, twin: h0, edge: ei, visited: false });
494
+ angles.push(departAngle(fwd), departAngle(rev));
495
+ out[e.a].push(h0);
496
+ out[e.b].push(h0 + 1);
497
+ });
498
+ for (const list of out)
499
+ list.sort((a, b) => angles[a] - angles[b]);
500
+ // --- trace interior faces: at each destination, rotate clockwise from the twin ---
501
+ const blocks = [];
502
+ for (let start = 0; start < halves.length && blocks.length < MAX_BLOCKS; start += 1) {
503
+ if (halves[start].visited)
504
+ continue;
505
+ const polygon = [];
506
+ const segEdge = [];
507
+ let current = start;
508
+ let guard = 0;
509
+ let closed = false;
510
+ while (guard < halves.length + 4) {
511
+ guard += 1;
512
+ const half = halves[current];
513
+ if (half.visited)
514
+ break;
515
+ half.visited = true;
516
+ for (let i = 0; i + 1 < half.pts.length; i += 1) {
517
+ polygon.push(half.pts[i]);
518
+ segEdge.push(half.edge);
519
+ }
520
+ const list = out[half.to];
521
+ const idx = list.indexOf(half.twin);
522
+ if (idx < 0)
523
+ break;
524
+ current = list[(idx - 1 + list.length) % list.length];
525
+ if (current === start) {
526
+ closed = true;
527
+ break;
528
+ }
529
+ }
530
+ if (!closed || polygon.length < 3)
531
+ continue;
532
+ if (polygonSignedArea(polygon) <= 1)
533
+ continue; // outer face (CW) or degenerate sliver
534
+ // Dedupe near-coincident vertices while keeping each surviving segment's source edge.
535
+ const ring = [];
536
+ const ringEdge = [];
537
+ for (let i = 0; i < polygon.length; i += 1) {
538
+ const p = polygon[i];
539
+ const prev = ring[ring.length - 1];
540
+ if (prev !== undefined && Math.hypot(prev[0] - p[0], prev[1] - p[1]) < 0.02)
541
+ continue;
542
+ ring.push(p);
543
+ ringEdge.push(segEdge[i]);
544
+ }
545
+ while (ring.length > 1 && Math.hypot(ring[0][0] - ring[ring.length - 1][0], ring[0][1] - ring[ring.length - 1][1]) < 0.02) {
546
+ ring.pop();
547
+ ringEdge.pop();
548
+ }
549
+ if (ring.length < 3)
550
+ continue;
551
+ const dist = ring.map((_, i) => {
552
+ const e = edges[ringEdge[i]];
553
+ const walk = e.level === "lane" ? 0 : sidewalkWidthFor(e.level, params.sidewalkBase);
554
+ return e.width / 2 + params.curbMargin + walk;
555
+ });
556
+ const land = insetRing(ring, dist);
557
+ blocks.push({ face: ring, land });
558
+ }
559
+ blocks.sort((a, b) => polygonArea(b.land) - polygonArea(a.land));
560
+ return { blocks, deadEnds };
561
+ }
403
562
  /** Arc-length station lookup along a closed ring. */
404
563
  export class RingWalker {
405
564
  ring;
@@ -14,6 +14,15 @@ import type { WeightedParamEntry } from "../scene/sceneKinds.js";
14
14
  export type CityLotClass = "tower" | "slab" | "shop" | "rowhouse" | "house" | "mansion" | "farmhouse" | "barn" | "silo";
15
15
  /** All classes, for schema hints and validation. */
16
16
  export declare const CITY_LOT_CLASSES: readonly CityLotClass[];
17
+ /**
18
+ * Filler classes used ONLY by the block-interior fill pass (`resolveCityLotContent` at a high
19
+ * `blockFill`). They are deliberately kept OUT of {@link CITY_LOT_CLASSES} so a zone mix can never
20
+ * weight them onto street frontage — they exist to pack the backs of Manhattan-style blocks with
21
+ * parking structures and warehouses/depots, mixed in with ordinary zone classes.
22
+ */
23
+ export type CityFillerClass = "garage" | "depot";
24
+ /** All filler classes, for validation and interior-pass allow-lists. */
25
+ export declare const CITY_FILLER_CLASSES: readonly CityFillerClass[];
17
26
  /** Zone band a lot falls in: dense core, middle ring, or the district edge. */
18
27
  export type CityZoneBand = "core" | "mid" | "edge";
19
28
  /** How the radial zone metric maps to bands. */
@@ -37,6 +46,11 @@ export interface CityLotPiece {
37
46
  /** Banded walls receive the window-strip treatment in the renderer. */
38
47
  banded: boolean;
39
48
  }
49
+ /**
50
+ * Natural frontage-width range (meters) of a class's massing — what the plot-size class bias reads
51
+ * so towers land on wide plots and rowhouses on narrow ones. @internal
52
+ */
53
+ export declare function classWidthRange(cls: string): readonly [number, number] | undefined;
40
54
  /** Placement numbers a resolved lot carries out of the class profile. @internal */
41
55
  export interface ClassPlacement {
42
56
  width: number;
@@ -52,7 +66,16 @@ export interface ClassPlacement {
52
66
  * aligned building wall; spacing likewise, so neighbors form rhythmic rows instead of scatter.
53
67
  * @internal
54
68
  */
55
- export declare function rollClassPlacement(cls: CityLotClass, rng: () => number, lotScale: number, floorsMin: number, floorsMax: number, setbackBase: number, spacingBase: number): ClassPlacement;
69
+ export declare function rollClassPlacement(cls: CityLotClass | CityFillerClass, rng: () => number, lotScale: number, floorsMin: number, floorsMax: number, setbackBase: number, spacingBase: number, widthStretch?: number): ClassPlacement;
70
+ /**
71
+ * Weight multiplier favouring classes whose natural frontage fits a plot of width `plotW`: a class
72
+ * far wider than the plot (a tower profile on a 6 m slice) is heavily downweighted, a class that
73
+ * would rattle around a much wider plot is mildly downweighted, and anything whose width range
74
+ * genuinely fits keeps weight 1. Pure data over {@link CLASS_PROFILES} — this is what turns a
75
+ * mixed-plot frontage into terraces on the slices and detached houses on the wide parcels.
76
+ * @internal
77
+ */
78
+ export declare function classPlotFit(cls: CityLotClass, plotW: number): number;
56
79
  /**
57
80
  * Normalized zone metric for a point in district-local space: 0 at the center line, 1 at the rim,
58
81
  * shaped to the district's rectangle (Chebyshev) so bands follow the volume's footprint.
@@ -68,7 +91,28 @@ export declare function pickClass(mix: readonly WeightedParamEntry[], roll: numb
68
91
  * floors, and stream always yield the identical silhouette. Sizes are in meters, lot-local.
69
92
  * @internal
70
93
  */
71
- export declare function buildLotPieces(cls: CityLotClass, width: number, depth: number, floors: number, floorHeight: number, rng: () => number): CityLotPiece[];
94
+ export declare function buildLotPieces(cls: CityLotClass | CityFillerClass, width: number, depth: number, floors: number, floorHeight: number, rng: () => number): CityLotPiece[];
95
+ /**
96
+ * A block-scale landmark class. Unlike {@link CityLotClass} these are never weighted into a zone
97
+ * mix — the landmark pass in `resolveCityLotContent` picks clusters of adjacent lots and stamps one
98
+ * of these over them. Each expresses with the same four piece shapes (box/gable/cylinder/dome) at a
99
+ * much larger footprint (~40–90 m).
100
+ */
101
+ export type CityLandmarkClass = "hall" | "arena" | "market" | "campus";
102
+ /** All landmark classes, for schema hints, validation, and default allow-lists. */
103
+ export declare const CITY_LANDMARK_CLASSES: readonly CityLandmarkClass[];
104
+ /** Minimum depth-into-block factor for a landmark class (the pass grows shallow clusters to this). @internal */
105
+ export declare function landmarkMinDepthFactor(cls: CityLandmarkClass): number;
106
+ /** Roll a landmark's floor count from its profile range. @internal */
107
+ export declare function landmarkFloors(cls: CityLandmarkClass, rng: () => number): number;
108
+ /**
109
+ * Compose the massing pieces for one landmark parcel, filling the given `width`×`depth` footprint
110
+ * (lot-local, x along frontage, z into the block). Deterministic per `rng`. Every piece is kept
111
+ * inside the `[-width/2, width/2] × [-depth/2, depth/2]` box so the reported footprint matches the
112
+ * merged cluster's AABB. Sizes are in meters.
113
+ * @internal
114
+ */
115
+ export declare function buildLandmarkPieces(cls: CityLandmarkClass, width: number, depth: number, floors: number, floorHeight: number, rng: () => number): CityLotPiece[];
72
116
  /** Tree species a district's tree mix can weight — the renderer keeps one canopy mesh per species. */
73
117
  export type CityTreeSpecies = "broadleaf" | "conifer" | "palm" | "cypress";
74
118
  /** All species, for schema hints and validation. */