@world-engines/spatial-authoring 0.1.0-alpha.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.
@@ -0,0 +1,1231 @@
1
+ import { generateBuildingInterior, generateCityLayout, generateParcelBuildings, validateProceduralWorld, } from "./procedural-city.js";
2
+ import { generateRoadParcels } from "./road-parcels.js";
3
+ import { roadParcelAlgorithm, validateRoadGeneration } from "./road-generation.js";
4
+ import { resolveRoadParcelTemplateSettings } from "./spatial-templates.js";
5
+ export const SPATIAL_DELTA_VERSION = 1;
6
+ export const SPATIAL_RUNTIME_CACHE_LIMITS = Object.freeze({
7
+ cityLayouts: 8,
8
+ activatedParcels: 128,
9
+ buildingInteriors: 64,
10
+ });
11
+ export const SPATIAL_DELTA_QUOTAS = Object.freeze({
12
+ maxOverrides: 2_048,
13
+ maxTombstones: 2_048,
14
+ maxUtf8Bytes: 1_048_576,
15
+ });
16
+ export const BUILDING_ENTRANCE_TOLERANCE_METERS = 1.5;
17
+ const TERRAIN_ROAD_MAX_GRID_SIDE = 257;
18
+ const TERRAIN_ROAD_MAX_GRID_CELLS = TERRAIN_ROAD_MAX_GRID_SIDE * TERRAIN_ROAD_MAX_GRID_SIDE;
19
+ const TERRAIN_ROAD_MAX_CITIES = 256;
20
+ const TERRAIN_ROAD_MAX_ROADS = 16_384;
21
+ const TERRAIN_ROAD_MAX_UNREACHABLE = 16_384;
22
+ const TERRAIN_ROAD_MAX_SEGMENTS_PER_ROAD = 2_048;
23
+ const TERRAIN_ROAD_MAX_POINTS_PER_PATH = TERRAIN_ROAD_MAX_GRID_CELLS;
24
+ export class SpatialRuntimeError extends Error {
25
+ code;
26
+ constructor(code, message) {
27
+ super(message);
28
+ this.name = "SpatialRuntimeError";
29
+ this.code = code;
30
+ }
31
+ }
32
+ const RUNTIME_OPTION_KEYS = ["world", "roadNetwork", "roadGeneration", "binding", "readControlledPose"];
33
+ const BINDING_KEYS = ["artifactDigest", "baseDigest", "worldlineId", "controlledActorId"];
34
+ const DELTA_KEYS = ["version", "binding", "overrides", "tombstones"];
35
+ const DELTA_BINDING_KEYS = ["artifactDigest", "baseDigest", "worldlineId"];
36
+ const OVERRIDE_KEYS = ["targetType", "targetId", "locator", "field", "value"];
37
+ const TOMBSTONE_KEYS = ["targetType", "targetId", "locator"];
38
+ const CITY_LOCATOR_KEYS = ["cityId"];
39
+ const PARCEL_LOCATOR_KEYS = ["cityId", "parcelId"];
40
+ const BUILDING_LOCATOR_KEYS = ["cityId", "parcelId", "buildingId"];
41
+ const OVERRIDE_FIELDS = Object.freeze(["label", "blocked", "destroyed"]);
42
+ const TARGET_TYPES = Object.freeze([
43
+ "city", "road", "parcel", "building", "room", "door",
44
+ ]);
45
+ function isRecord(value) {
46
+ return typeof value === "object" && value !== null && !Array.isArray(value);
47
+ }
48
+ function assertRecord(value, label) {
49
+ if (!isRecord(value))
50
+ throw deltaError(`${label} 必须是对象`);
51
+ }
52
+ function assertExactKeys(value, keys, label) {
53
+ const expected = new Set(keys);
54
+ const unknown = Object.keys(value).filter((key) => !expected.has(key));
55
+ if (unknown.length > 0)
56
+ throw deltaError(`${label} 包含未知字段:${unknown.join(", ")}`);
57
+ const missing = keys.filter((key) => !Object.hasOwn(value, key));
58
+ if (missing.length > 0)
59
+ throw deltaError(`${label} 缺少字段:${missing.join(", ")}`);
60
+ }
61
+ function deltaError(message) {
62
+ return new SpatialRuntimeError("DELTA_INVALID", message);
63
+ }
64
+ function nonEmptyIdentity(value, label, maximum = 256) {
65
+ if (typeof value !== "string" || value.length < 1 || value.length > maximum || /[\u0000-\u001f\u007f\s]/u.test(value)) {
66
+ throw new TypeError(`${label} 必须是 1..${maximum} 字符且不含空白/控制字符`);
67
+ }
68
+ return value;
69
+ }
70
+ function sha256Digest(value, label) {
71
+ if (typeof value !== "string" || !/^[0-9a-f]{64}$/u.test(value)) {
72
+ throw new TypeError(`${label} 必须是 64 位小写十六进制 SHA-256`);
73
+ }
74
+ return value;
75
+ }
76
+ function stableId(value, label) {
77
+ if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(value)) {
78
+ throw deltaError(`${label} 必须是稳定 identifier`);
79
+ }
80
+ return value;
81
+ }
82
+ function validateLocator(value, type, label) {
83
+ assertRecord(value, label);
84
+ if (type === "city" || type === "road" || type === "parcel") {
85
+ assertExactKeys(value, CITY_LOCATOR_KEYS, label);
86
+ return Object.freeze({ cityId: stableId(value.cityId, `${label}.cityId`) });
87
+ }
88
+ if (type === "building") {
89
+ assertExactKeys(value, PARCEL_LOCATOR_KEYS, label);
90
+ return Object.freeze({
91
+ cityId: stableId(value.cityId, `${label}.cityId`),
92
+ parcelId: stableId(value.parcelId, `${label}.parcelId`),
93
+ });
94
+ }
95
+ assertExactKeys(value, BUILDING_LOCATOR_KEYS, label);
96
+ return Object.freeze({
97
+ cityId: stableId(value.cityId, `${label}.cityId`),
98
+ parcelId: stableId(value.parcelId, `${label}.parcelId`),
99
+ buildingId: stableId(value.buildingId, `${label}.buildingId`),
100
+ });
101
+ }
102
+ function targetType(value, label) {
103
+ if (typeof value !== "string" || !TARGET_TYPES.includes(value)) {
104
+ throw deltaError(`${label} 非法`);
105
+ }
106
+ return value;
107
+ }
108
+ function overrideField(value, label) {
109
+ if (typeof value !== "string" || !OVERRIDE_FIELDS.includes(value)) {
110
+ throw deltaError(`${label} 非法;只允许 label/blocked/destroyed,禁止 geometry`);
111
+ }
112
+ return value;
113
+ }
114
+ function overrideValue(field, value, label) {
115
+ if (field === "label") {
116
+ if (typeof value !== "string" || value.trim().length < 1 || value.trim().length > 128) {
117
+ throw deltaError(`${label} 必须是 1..128 字符的非空 label`);
118
+ }
119
+ return value.trim();
120
+ }
121
+ if (typeof value !== "boolean")
122
+ throw deltaError(`${label} 必须是 boolean`);
123
+ return value;
124
+ }
125
+ function finiteMetric(value, label) {
126
+ if (typeof value !== "number" || !Number.isFinite(value))
127
+ throw new TypeError(`${label} 必须是有限数`);
128
+ return value;
129
+ }
130
+ function validateBounds(value, label) {
131
+ if (!isRecord(value))
132
+ throw new TypeError(`${label} 必须是对象`);
133
+ const x = finiteMetric(value.x, `${label}.x`);
134
+ const y = finiteMetric(value.y, `${label}.y`);
135
+ const width = finiteMetric(value.width, `${label}.width`);
136
+ const height = finiteMetric(value.height, `${label}.height`);
137
+ if (width <= 0 || height <= 0)
138
+ throw new RangeError(`${label} 尺寸必须大于 0`);
139
+ return Object.freeze({ x, y, width, height });
140
+ }
141
+ function validateRevision(value) {
142
+ if (typeof value === "string" && value.length > 0 && value.length <= 256)
143
+ return value;
144
+ if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0)
145
+ return value;
146
+ throw new SpatialRuntimeError("HOST_POSE_INVALID", "Host pose.revision 必须是非空字符串或非负安全整数");
147
+ }
148
+ function intersects(first, second) {
149
+ return first.x <= second.x + second.width
150
+ && first.x + first.width >= second.x
151
+ && first.y <= second.y + second.height
152
+ && first.y + first.height >= second.y;
153
+ }
154
+ function contains(bounds, x, y) {
155
+ return x >= bounds.x && x <= bounds.x + bounds.width
156
+ && y >= bounds.y && y <= bounds.y + bounds.height;
157
+ }
158
+ function roadBounds(road) {
159
+ if (road.points.length < 2)
160
+ throw new TypeError("road.points 至少需要两个点");
161
+ const margin = road.widthMeters / 2;
162
+ let minimumX = Number.POSITIVE_INFINITY;
163
+ let minimumY = Number.POSITIVE_INFINITY;
164
+ let maximumX = Number.NEGATIVE_INFINITY;
165
+ let maximumY = Number.NEGATIVE_INFINITY;
166
+ for (const point of road.points) {
167
+ minimumX = Math.min(minimumX, point.x);
168
+ minimumY = Math.min(minimumY, point.y);
169
+ maximumX = Math.max(maximumX, point.x);
170
+ maximumY = Math.max(maximumY, point.y);
171
+ }
172
+ return {
173
+ x: minimumX - margin,
174
+ y: minimumY - margin,
175
+ width: maximumX - minimumX + margin * 2,
176
+ height: maximumY - minimumY + margin * 2,
177
+ };
178
+ }
179
+ /** polygon 的 bounds 仅服务 viewport 索引;进入 gate 始终使用真实 polygon containment。 */
180
+ function parcelViewportBounds(parcel) {
181
+ if (parcel.polygon === undefined)
182
+ return parcel.bounds;
183
+ let minimumX = Number.POSITIVE_INFINITY;
184
+ let minimumY = Number.POSITIVE_INFINITY;
185
+ let maximumX = Number.NEGATIVE_INFINITY;
186
+ let maximumY = Number.NEGATIVE_INFINITY;
187
+ for (const point of parcel.polygon) {
188
+ minimumX = Math.min(minimumX, point.x);
189
+ minimumY = Math.min(minimumY, point.y);
190
+ maximumX = Math.max(maximumX, point.x);
191
+ maximumY = Math.max(maximumY, point.y);
192
+ }
193
+ return { x: minimumX, y: minimumY, width: maximumX - minimumX, height: maximumY - minimumY };
194
+ }
195
+ function polygonSignedArea(points) {
196
+ let twiceArea = 0;
197
+ for (let index = 0; index < points.length; index += 1) {
198
+ const current = points[index];
199
+ const next = points[(index + 1) % points.length];
200
+ if (current === undefined || next === undefined)
201
+ continue;
202
+ twiceArea += current.x * next.y - next.x * current.y;
203
+ }
204
+ return twiceArea / 2;
205
+ }
206
+ function orientation(a, b, c) {
207
+ return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
208
+ }
209
+ function properSegmentsIntersect(a, b, c, d) {
210
+ const abC = orientation(a, b, c);
211
+ const abD = orientation(a, b, d);
212
+ const cdA = orientation(c, d, a);
213
+ const cdB = orientation(c, d, b);
214
+ return ((abC > 0 && abD < 0) || (abC < 0 && abD > 0))
215
+ && ((cdA > 0 && cdB < 0) || (cdA < 0 && cdB > 0));
216
+ }
217
+ function boundaryIsSimple(points) {
218
+ for (let first = 0; first < points.length; first += 1) {
219
+ const a = points[first];
220
+ const b = points[(first + 1) % points.length];
221
+ for (let second = first + 1; second < points.length; second += 1) {
222
+ if (second === first || second === first + 1 || (first === 0 && second === points.length - 1))
223
+ continue;
224
+ const c = points[second];
225
+ const d = points[(second + 1) % points.length];
226
+ if (properSegmentsIntersect(a, b, c, d))
227
+ return false;
228
+ }
229
+ }
230
+ return true;
231
+ }
232
+ function validateTerrainCityBoundary(value, city, label) {
233
+ if (!isRecord(value) || Object.keys(value).some((key) => key !== "status" && key !== "cityId" && key !== "points")) {
234
+ throw new TypeError(`${label} 必须是严格 boundary 对象`);
235
+ }
236
+ if (value.cityId !== city.id || (value.status !== "bounded" && value.status !== "unbuildable") || !Array.isArray(value.points)) {
237
+ throw new TypeError(`${label} status 或 cityId 非法`);
238
+ }
239
+ if (value.status === "unbuildable") {
240
+ if (value.points.length !== 0)
241
+ throw new TypeError(`${label}.unbuildable.points 必须为空`);
242
+ return Object.freeze({ status: "unbuildable", cityId: city.id, points: Object.freeze([]) });
243
+ }
244
+ if (value.points.length < 3 || value.points.length > 128) {
245
+ throw new TypeError(`${label}.points 必须是 3..128 点数组`);
246
+ }
247
+ const points = value.points.map((raw, index) => {
248
+ if (!isRecord(raw) || Object.keys(raw).some((key) => key !== "x" && key !== "y")) {
249
+ throw new TypeError(`${label}[${index}] 字段非法`);
250
+ }
251
+ const point = Object.freeze({
252
+ x: finiteMetric(raw.x, `${label}[${index}].x`),
253
+ y: finiteMetric(raw.y, `${label}[${index}].y`),
254
+ });
255
+ if (Math.hypot(point.x - city.x, point.y - city.y) > city.parameters.radiusMeters + 0.000001) {
256
+ throw new RangeError(`${label}[${index}] 超出 city radiusMeters`);
257
+ }
258
+ return point;
259
+ });
260
+ for (let index = 0; index < points.length; index += 1) {
261
+ const current = points[index];
262
+ const next = points[(index + 1) % points.length];
263
+ if (current === undefined || next === undefined || (current.x === next.x && current.y === next.y)) {
264
+ throw new TypeError(`${label} 不能闭合重复或含零长度边`);
265
+ }
266
+ }
267
+ if (polygonSignedArea(points) <= 0 || !boundaryIsSimple(points)) {
268
+ throw new TypeError(`${label}.points 必须为 simple CCW 非零面积环`);
269
+ }
270
+ return Object.freeze({ status: "bounded", cityId: city.id, points: Object.freeze(points) });
271
+ }
272
+ function validateRoadParcelTerrain(value, label) {
273
+ if (!isRecord(value))
274
+ throw new TypeError(`${label} 必须是对象`);
275
+ const keys = ["width", "height", "widthMeters", "heightMeters", "originX", "originY", "elevationsMeters", "waterMask"];
276
+ if (Object.keys(value).some((key) => !keys.includes(key)) || keys.some((key) => !Object.hasOwn(value, key))) {
277
+ throw new TypeError(`${label} 字段非法`);
278
+ }
279
+ const widthRaw = value.width;
280
+ const heightRaw = value.height;
281
+ if (typeof widthRaw !== "number" || typeof heightRaw !== "number"
282
+ || !Number.isSafeInteger(widthRaw) || !Number.isSafeInteger(heightRaw)
283
+ || widthRaw < 2 || heightRaw < 2
284
+ || widthRaw > TERRAIN_ROAD_MAX_GRID_SIDE || heightRaw > TERRAIN_ROAD_MAX_GRID_SIDE
285
+ || widthRaw * heightRaw > TERRAIN_ROAD_MAX_GRID_CELLS
286
+ || !Array.isArray(value.elevationsMeters) || !Array.isArray(value.waterMask)
287
+ || value.elevationsMeters.length !== widthRaw * heightRaw || value.waterMask.length !== widthRaw * heightRaw) {
288
+ throw new TypeError(`${label} grid 非法`);
289
+ }
290
+ const width = widthRaw;
291
+ const height = heightRaw;
292
+ const widthMeters = finiteMetric(value.widthMeters, `${label}.widthMeters`);
293
+ const heightMeters = finiteMetric(value.heightMeters, `${label}.heightMeters`);
294
+ const originX = finiteMetric(value.originX, `${label}.originX`);
295
+ const originY = finiteMetric(value.originY, `${label}.originY`);
296
+ if (widthMeters <= 0 || heightMeters <= 0 || value.elevationsMeters.some((point) => typeof point !== "number" || !Number.isFinite(point))
297
+ || value.waterMask.some((water) => typeof water !== "boolean")) {
298
+ throw new TypeError(`${label} 采样值非法`);
299
+ }
300
+ return Object.freeze({
301
+ width,
302
+ height,
303
+ widthMeters,
304
+ heightMeters,
305
+ originX,
306
+ originY,
307
+ elevationsMeters: Object.freeze([...value.elevationsMeters]),
308
+ waterMask: Object.freeze([...value.waterMask]),
309
+ });
310
+ }
311
+ function containsParcel(parcel, x, y) {
312
+ if (parcel.polygon === undefined)
313
+ return contains(parcel.bounds, x, y);
314
+ let inside = false;
315
+ for (let index = 0, previous = parcel.polygon.length - 1; index < parcel.polygon.length; previous = index, index += 1) {
316
+ const from = parcel.polygon[index];
317
+ const to = parcel.polygon[previous];
318
+ if (from === undefined || to === undefined)
319
+ continue;
320
+ const cross = (to.x - from.x) * (y - from.y) - (to.y - from.y) * (x - from.x);
321
+ const onEdge = Math.abs(cross) <= 0.000001 && x >= Math.min(from.x, to.x) - 0.000001
322
+ && x <= Math.max(from.x, to.x) + 0.000001 && y >= Math.min(from.y, to.y) - 0.000001
323
+ && y <= Math.max(from.y, to.y) + 0.000001;
324
+ if (onEdge)
325
+ return true;
326
+ if ((from.y > y) !== (to.y > y) && x < (to.x - from.x) * (y - from.y) / (to.y - from.y) + from.x)
327
+ inside = !inside;
328
+ }
329
+ return inside;
330
+ }
331
+ function validateTerrainRoadNetwork(value, world) {
332
+ if (!isRecord(value) || Object.keys(value).some((key) => key !== "profile" && key !== "roads" && key !== "unreachable" && key !== "cityLayouts")) {
333
+ throw new TypeError("terrain road network 必须是严格对象");
334
+ }
335
+ if (value.profile !== "terrain-roads-v1" || !Array.isArray(value.roads)
336
+ || !Array.isArray(value.unreachable) || !Array.isArray(value.cityLayouts)) {
337
+ throw new TypeError("terrain road network profile 或集合非法");
338
+ }
339
+ if (world.cities.length > TERRAIN_ROAD_MAX_CITIES
340
+ || value.roads.length > TERRAIN_ROAD_MAX_ROADS
341
+ || value.unreachable.length > TERRAIN_ROAD_MAX_UNREACHABLE
342
+ || value.cityLayouts.length > TERRAIN_ROAD_MAX_CITIES) {
343
+ throw new RangeError("terrain road network 数量预算超限");
344
+ }
345
+ const knownCities = new Set(world.cities.map((city) => city.id));
346
+ const seenRoads = new Set();
347
+ const roads = value.roads.map((candidate, index) => {
348
+ if (!isRecord(candidate))
349
+ throw new TypeError(`terrain roads[${index}] 必须是对象`);
350
+ const keys = Object.keys(candidate);
351
+ const allowed = new Set(["id", "kind", "roadClass", "cityId", "fromCityId", "toCityId", "widthMeters", "points", "segments"]);
352
+ if (keys.some((key) => !allowed.has(key)) || !Object.hasOwn(candidate, "id")
353
+ || !Object.hasOwn(candidate, "kind") || !Object.hasOwn(candidate, "roadClass") || !Object.hasOwn(candidate, "widthMeters")
354
+ || !Object.hasOwn(candidate, "points") || !Object.hasOwn(candidate, "segments")) {
355
+ throw new TypeError(`terrain roads[${index}] 字段非法`);
356
+ }
357
+ const id = stableId(candidate.id, `terrain roads[${index}].id`);
358
+ if (seenRoads.has(id))
359
+ throw new TypeError(`terrain road id 重复:${id}`);
360
+ seenRoads.add(id);
361
+ if (candidate.kind !== "intercity" && candidate.kind !== "arterial" && candidate.kind !== "local") {
362
+ throw new TypeError(`terrain roads[${index}].kind 非法`);
363
+ }
364
+ if (candidate.roadClass !== "street" && candidate.roadClass !== "motorway") {
365
+ throw new TypeError(`terrain roads[${index}].roadClass 非法`);
366
+ }
367
+ const cityRef = (field) => {
368
+ const raw = candidate[field];
369
+ if (raw === undefined)
370
+ return undefined;
371
+ const cityId = stableId(raw, `terrain roads[${index}].${field}`);
372
+ if (!knownCities.has(cityId))
373
+ throw new TypeError(`terrain roads[${index}].${field} 未知城市`);
374
+ return cityId;
375
+ };
376
+ const cityId = cityRef("cityId");
377
+ const fromCityId = cityRef("fromCityId");
378
+ const toCityId = cityRef("toCityId");
379
+ if ((candidate.kind === "intercity") !== (fromCityId !== undefined && toCityId !== undefined)
380
+ || (candidate.kind !== "intercity" && cityId === undefined)) {
381
+ throw new TypeError(`terrain roads[${index}] 城市归属非法`);
382
+ }
383
+ const widthMeters = finiteMetric(candidate.widthMeters, `terrain roads[${index}].widthMeters`);
384
+ if (widthMeters <= 0 || widthMeters > 1_000)
385
+ throw new RangeError(`terrain roads[${index}].widthMeters 非法`);
386
+ const point = (raw, label) => {
387
+ if (!isRecord(raw) || Object.keys(raw).some((key) => key !== "x" && key !== "y" && key !== "z"))
388
+ throw new TypeError(`${label} 非法`);
389
+ return Object.freeze({ x: finiteMetric(raw.x, `${label}.x`), y: finiteMetric(raw.y, `${label}.y`), z: finiteMetric(raw.z, `${label}.z`) });
390
+ };
391
+ const points = candidate.points;
392
+ if (!Array.isArray(points) || points.length < 2 || points.length > TERRAIN_ROAD_MAX_POINTS_PER_PATH) {
393
+ throw new TypeError(`terrain roads[${index}].points 数量非法`);
394
+ }
395
+ const segments = candidate.segments;
396
+ if (!Array.isArray(segments) || segments.length === 0 || segments.length > TERRAIN_ROAD_MAX_SEGMENTS_PER_ROAD) {
397
+ throw new TypeError(`terrain roads[${index}].segments 数量非法`);
398
+ }
399
+ return Object.freeze({
400
+ id,
401
+ kind: candidate.kind,
402
+ roadClass: candidate.roadClass,
403
+ ...(cityId === undefined ? {} : { cityId }),
404
+ ...(fromCityId === undefined ? {} : { fromCityId }),
405
+ ...(toCityId === undefined ? {} : { toCityId }),
406
+ widthMeters,
407
+ points: Object.freeze(points.map((item, pointIndex) => point(item, `terrain roads[${index}].points[${pointIndex}]`))),
408
+ segments: Object.freeze(segments.map((segment, segmentIndex) => {
409
+ if (!isRecord(segment) || Object.keys(segment).some((key) => key !== "kind" && key !== "points" && key !== "lengthMeters")
410
+ || (segment.kind !== "surface" && segment.kind !== "bridge" && segment.kind !== "tunnel")
411
+ || !Array.isArray(segment.points)) {
412
+ throw new TypeError(`terrain roads[${index}].segments[${segmentIndex}] 非法`);
413
+ }
414
+ const lengthMeters = finiteMetric(segment.lengthMeters, `terrain roads[${index}].segments[${segmentIndex}].lengthMeters`);
415
+ if (lengthMeters <= 0 || segment.points.length < 2 || segment.points.length > TERRAIN_ROAD_MAX_POINTS_PER_PATH) {
416
+ throw new RangeError(`terrain roads[${index}].segments[${segmentIndex}] 非法`);
417
+ }
418
+ return Object.freeze({ kind: segment.kind, lengthMeters, points: Object.freeze(segment.points.map((item, pointIndex) => point(item, `terrain roads[${index}].segments[${segmentIndex}].points[${pointIndex}]`))) });
419
+ })),
420
+ });
421
+ });
422
+ const seenLayouts = new Set();
423
+ const cityLayouts = value.cityLayouts.map((item, index) => {
424
+ if (!isRecord(item) || Object.keys(item).some((key) => key !== "cityId" && key !== "roads" && key !== "boundary" && key !== "terrain") || !Array.isArray(item.roads)) {
425
+ throw new TypeError(`terrain cityLayouts[${index}] 非法`);
426
+ }
427
+ const cityId = stableId(item.cityId, `terrain cityLayouts[${index}].cityId`);
428
+ const city = world.cities.find((candidate) => candidate.id === cityId);
429
+ if (city === undefined || !seenLayouts.add(cityId))
430
+ throw new TypeError(`terrain cityLayouts[${index}] cityId 非法或重复`);
431
+ const boundary = validateTerrainCityBoundary(item.boundary, city, `terrain cityLayouts[${index}].boundary`);
432
+ const roadIds = item.roads.map((roadId) => stableId(roadId, `terrain cityLayouts[${index}].roads`));
433
+ if (new Set(roadIds).size !== roadIds.length || roadIds.some((roadId) => {
434
+ const road = roads.find((candidate) => candidate.id === roadId);
435
+ return road === undefined || road.cityId !== cityId;
436
+ }) || (boundary.status === "unbuildable" && roadIds.length !== 0)) {
437
+ throw new TypeError(`terrain cityLayouts[${index}] road 引用非法`);
438
+ }
439
+ return Object.freeze({
440
+ cityId,
441
+ roads: Object.freeze(roadIds),
442
+ boundary,
443
+ terrain: validateRoadParcelTerrain(item.terrain, `terrain cityLayouts[${index}].terrain`),
444
+ });
445
+ });
446
+ if (seenLayouts.size !== knownCities.size)
447
+ throw new TypeError("terrain cityLayouts 必须与 procedural cities 一一对应");
448
+ return Object.freeze({
449
+ profile: "terrain-roads-v1",
450
+ roads: Object.freeze(roads),
451
+ unreachable: Object.freeze(value.unreachable.map((item, index) => {
452
+ if (!isRecord(item))
453
+ throw new TypeError(`terrain unreachable[${index}] 必须是对象`);
454
+ return Object.freeze({ ...item });
455
+ })),
456
+ cityLayouts: Object.freeze(cityLayouts),
457
+ });
458
+ }
459
+ function cityBounds(city) {
460
+ return {
461
+ x: city.x - city.parameters.radiusMeters,
462
+ y: city.y - city.parameters.radiusMeters,
463
+ width: city.parameters.radiusMeters * 2,
464
+ height: city.parameters.radiusMeters * 2,
465
+ };
466
+ }
467
+ function targetKey(type, id) {
468
+ return `${type}\u0000${id}`;
469
+ }
470
+ function overrideKey(type, id, field) {
471
+ return `${targetKey(type, id)}\u0000${field}`;
472
+ }
473
+ function compareTarget(first, second) {
474
+ return first.targetType.localeCompare(second.targetType) || first.targetId.localeCompare(second.targetId);
475
+ }
476
+ function compareOverride(first, second) {
477
+ return compareTarget(first, second) || first.field.localeCompare(second.field);
478
+ }
479
+ function touchLru(map, key, value, maximum) {
480
+ map.delete(key);
481
+ map.set(key, value);
482
+ while (map.size > maximum) {
483
+ const oldest = map.keys().next().value;
484
+ if (oldest === undefined)
485
+ break;
486
+ map.delete(oldest);
487
+ }
488
+ return value;
489
+ }
490
+ function utf8Length(value) {
491
+ return new TextEncoder().encode(value).byteLength;
492
+ }
493
+ function decodeDeltaInput(input) {
494
+ if (typeof input === "string") {
495
+ if (utf8Length(input) > SPATIAL_DELTA_QUOTAS.maxUtf8Bytes) {
496
+ throw new SpatialRuntimeError("DELTA_QUOTA_EXCEEDED", "delta UTF-8 字节预算超限");
497
+ }
498
+ try {
499
+ return JSON.parse(input);
500
+ }
501
+ catch {
502
+ throw deltaError("delta JSON 非法");
503
+ }
504
+ }
505
+ if (input instanceof Uint8Array) {
506
+ if (input.byteLength > SPATIAL_DELTA_QUOTAS.maxUtf8Bytes) {
507
+ throw new SpatialRuntimeError("DELTA_QUOTA_EXCEEDED", "delta UTF-8 字节预算超限");
508
+ }
509
+ try {
510
+ return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(input));
511
+ }
512
+ catch {
513
+ throw deltaError("delta UTF-8/JSON 非法");
514
+ }
515
+ }
516
+ return input;
517
+ }
518
+ function viewportChunkAbortError() {
519
+ return new DOMException("空间视口 chunk 请求已取消", "AbortError");
520
+ }
521
+ function canonicalViewportNumber(value, label, positive = false) {
522
+ const number = finiteMetric(value, label);
523
+ if (positive && number <= 0)
524
+ throw new RangeError(`${label} 必须大于 0`);
525
+ // -0 不能成为另一个 cache identity。
526
+ return Object.is(number, -0) ? 0 : number;
527
+ }
528
+ function canonicalViewportRevision(value) {
529
+ if (typeof value === "string") {
530
+ if (value.length === 0 || value.length > 128)
531
+ throw new TypeError("spatialRevision 必须是非空且不超过 128 字符的字符串");
532
+ return `s:${value}`;
533
+ }
534
+ if (!Number.isSafeInteger(value) || value < 0)
535
+ throw new RangeError("spatialRevision 数字必须是非负安全整数");
536
+ return `n:${value}`;
537
+ }
538
+ function canonicalViewportChunkRequest(request) {
539
+ if (!isRecord(request))
540
+ throw new TypeError("viewport chunk request 必须是对象");
541
+ const baseDigest = sha256Digest(request.baseDigest, "viewport.baseDigest");
542
+ const revision = canonicalViewportRevision(request.spatialRevision);
543
+ if (!isRecord(request.centerMeters) || !isRecord(request.viewportSize) || !isRecord(request.chunk)) {
544
+ throw new TypeError("viewport chunk request 缺少 centerMeters、viewportSize 或 chunk");
545
+ }
546
+ canonicalViewportNumber(request.centerMeters.x, "viewport.centerMeters.x");
547
+ canonicalViewportNumber(request.centerMeters.y, "viewport.centerMeters.y");
548
+ canonicalViewportNumber(request.metersPerCssPixel, "viewport.metersPerCssPixel", true);
549
+ canonicalViewportNumber(request.viewportSize.width, "viewport.viewportSize.width", true);
550
+ canonicalViewportNumber(request.viewportSize.height, "viewport.viewportSize.height", true);
551
+ const level = request.chunk.level;
552
+ const x = request.chunk.x;
553
+ const y = request.chunk.y;
554
+ if (!Number.isSafeInteger(level) || level < 0 || level > 30
555
+ || !Number.isSafeInteger(x) || !Number.isSafeInteger(y) || x < 0 || y < 0
556
+ || x >= 2 ** level || y >= 2 ** level) {
557
+ throw new RangeError("viewport.chunk 必须是合法的有限平面 tile 坐标");
558
+ }
559
+ // 特意不把 centerMeters、metersPerCssPixel 或 viewportSize 放进 key:它们只决定
560
+ // 当前要哪些块和怎样投影,不能改变一个冻结世界坐标 chunk 的 authority。
561
+ return Object.freeze({ key: `${baseDigest}|${revision}|${level}|${x}|${y}`, baseDigest, revision });
562
+ }
563
+ /**
564
+ * scope-bound、可取消的 chunk cache。它不生成或持久化几何;调用者只能从同一个
565
+ * 已确认 base binding 请求 tile,取消或旧 revision 的响应绝不会成为新 revision cache。
566
+ */
567
+ export function createSpatialViewportChunkCache(load) {
568
+ if (typeof load !== "function")
569
+ throw new TypeError("viewport chunk loader 必须是函数");
570
+ const cache = new Map();
571
+ const active = new Set();
572
+ const abortActive = () => {
573
+ for (const controller of active)
574
+ controller.abort();
575
+ active.clear();
576
+ };
577
+ return Object.freeze({
578
+ async read(request, signal) {
579
+ const canonical = canonicalViewportChunkRequest(request);
580
+ const existing = cache.get(canonical.key);
581
+ if (existing !== undefined)
582
+ return existing;
583
+ if (signal?.aborted === true)
584
+ throw viewportChunkAbortError();
585
+ const controller = new AbortController();
586
+ active.add(controller);
587
+ const abort = () => controller.abort();
588
+ signal?.addEventListener("abort", abort, { once: true });
589
+ try {
590
+ const result = await load(request, controller.signal);
591
+ // 外部 signal 只通过 abort listener 影响本 controller;避免把调用者的
592
+ // mutable AbortSignal 当作一次 await 之后仍被 TS 静态缩窄的值。
593
+ if (controller.signal.aborted)
594
+ throw viewportChunkAbortError();
595
+ cache.set(canonical.key, result);
596
+ return result;
597
+ }
598
+ finally {
599
+ active.delete(controller);
600
+ signal?.removeEventListener("abort", abort);
601
+ }
602
+ },
603
+ clear() {
604
+ cache.clear();
605
+ // 清缓存是 scope retire,不允许一个旧 load 在 finally 之前重新写回。
606
+ abortActive();
607
+ },
608
+ retainRevision(baseDigestInput, spatialRevision) {
609
+ const baseDigest = sha256Digest(baseDigestInput, "viewport.baseDigest");
610
+ const revision = canonicalViewportRevision(spatialRevision);
611
+ for (const key of cache.keys()) {
612
+ if (!key.startsWith(`${baseDigest}|${revision}|`))
613
+ cache.delete(key);
614
+ }
615
+ // revision 切换与完整 clear 有相同的 stale-response 语义;保留的已完成
616
+ // bytes 可读,但旧 scope 的 in-flight response 不能再成为 authority。
617
+ abortActive();
618
+ },
619
+ getCacheSize() {
620
+ return cache.size;
621
+ },
622
+ });
623
+ }
624
+ class SpatialRuntimeImpl {
625
+ world;
626
+ binding;
627
+ #readControlledPose;
628
+ #terrainRoadNetwork;
629
+ #terrainRoadGeneration;
630
+ #cityLayouts = new Map();
631
+ #activatedParcels = new Map();
632
+ #interiors = new Map();
633
+ #overrides = new Map();
634
+ #tombstones = new Map();
635
+ constructor(options) {
636
+ if (!isRecord(options))
637
+ throw new TypeError("SpatialRuntimeOptions 必须是对象");
638
+ const optionRecord = options;
639
+ const unknownOptions = Object.keys(optionRecord).filter((key) => !RUNTIME_OPTION_KEYS.includes(key));
640
+ if (unknownOptions.length > 0)
641
+ throw new TypeError(`SpatialRuntimeOptions 包含未知字段:${unknownOptions.join(", ")}`);
642
+ this.world = validateProceduralWorld(options.world);
643
+ this.binding = this.#validateBinding(options.binding);
644
+ this.#terrainRoadNetwork = options.roadNetwork === undefined
645
+ ? null
646
+ : validateTerrainRoadNetwork(options.roadNetwork, this.world);
647
+ this.#terrainRoadGeneration = options.roadGeneration === undefined
648
+ ? null
649
+ : validateRoadGeneration(options.roadGeneration);
650
+ if ((this.#terrainRoadNetwork === null) !== (this.#terrainRoadGeneration === null)) {
651
+ throw new TypeError("terrain-roads-v1 必须同时提供 roadNetwork 与 roadGeneration");
652
+ }
653
+ if (typeof options.readControlledPose !== "function")
654
+ throw new TypeError("readControlledPose 必须是函数");
655
+ this.#readControlledPose = options.readControlledPose;
656
+ }
657
+ readViewport(request) {
658
+ if (!isRecord(request))
659
+ throw new TypeError("viewport request 必须是对象");
660
+ const bounds = validateBounds(request.bounds, "viewport.bounds");
661
+ const metersPerCssPixel = finiteMetric(request.metersPerCssPixel, "viewport.metersPerCssPixel");
662
+ if (metersPerCssPixel <= 0)
663
+ throw new RangeError("viewport.metersPerCssPixel 必须大于 0");
664
+ const roads = [];
665
+ const parcels = [];
666
+ if (this.#terrainRoadNetwork !== null) {
667
+ for (const road of this.#terrainRoadNetwork.roads) {
668
+ if (!intersects(roadBounds(road), bounds))
669
+ continue;
670
+ const composed = this.#compose("road", road);
671
+ if (composed !== null)
672
+ roads.push(composed);
673
+ }
674
+ for (const city of this.world.cities) {
675
+ if (!intersects(cityBounds(city), bounds))
676
+ continue;
677
+ for (const parcel of this.#layout(city).parcels) {
678
+ if (!intersects(parcelViewportBounds(parcel), bounds))
679
+ continue;
680
+ const composed = this.#compose("parcel", parcel);
681
+ if (composed !== null)
682
+ parcels.push(composed);
683
+ }
684
+ }
685
+ }
686
+ else {
687
+ for (const city of this.world.cities) {
688
+ if (!intersects(cityBounds(city), bounds))
689
+ continue;
690
+ const layout = this.#layout(city);
691
+ for (const road of layout.roads) {
692
+ if (!intersects(roadBounds(road), bounds))
693
+ continue;
694
+ const composed = this.#compose("road", road);
695
+ if (composed !== null)
696
+ roads.push(composed);
697
+ }
698
+ for (const parcel of layout.parcels) {
699
+ if (!intersects(parcelViewportBounds(parcel), bounds))
700
+ continue;
701
+ const composed = this.#compose("parcel", parcel);
702
+ if (composed !== null)
703
+ parcels.push(composed);
704
+ }
705
+ }
706
+ }
707
+ const buildings = [];
708
+ for (const activated of this.#activatedParcels.values()) {
709
+ for (const building of activated.buildings) {
710
+ if (!intersects(building.footprint, bounds))
711
+ continue;
712
+ const composed = this.#compose("building", building);
713
+ if (composed !== null)
714
+ buildings.push(composed);
715
+ }
716
+ }
717
+ roads.sort((first, second) => first.id.localeCompare(second.id));
718
+ parcels.sort((first, second) => first.id.localeCompare(second.id));
719
+ buildings.sort((first, second) => first.id.localeCompare(second.id));
720
+ return Object.freeze({
721
+ roads: Object.freeze(roads),
722
+ parcels: Object.freeze(parcels),
723
+ buildings: Object.freeze(buildings),
724
+ });
725
+ }
726
+ enterCurrentParcel() {
727
+ const pose = this.#pose();
728
+ for (const city of this.world.cities) {
729
+ if (!contains(cityBounds(city), pose.x, pose.y))
730
+ continue;
731
+ const layout = this.#layout(city);
732
+ for (const parcel of layout.parcels) {
733
+ if (!containsParcel(parcel, pose.x, pose.y))
734
+ continue;
735
+ const runtimeParcel = this.#compose("parcel", parcel);
736
+ if (runtimeParcel === null || runtimeParcel.blocked === true || runtimeParcel.destroyed === true) {
737
+ throw new SpatialRuntimeError("TARGET_UNAVAILABLE", `当前位置地块 ${parcel.id} 已不可用`);
738
+ }
739
+ let activated = this.#activatedParcels.get(parcel.id);
740
+ if (activated === undefined) {
741
+ activated = Object.freeze({
742
+ city,
743
+ parcel,
744
+ buildings: generateParcelBuildings(this.world.worldSeed, city, parcel, this.#templateOptions()),
745
+ });
746
+ }
747
+ touchLru(this.#activatedParcels, parcel.id, activated, SPATIAL_RUNTIME_CACHE_LIMITS.activatedParcels);
748
+ const buildings = activated.buildings
749
+ .map((building) => this.#compose("building", building))
750
+ .filter((building) => building !== null)
751
+ .sort((first, second) => first.id.localeCompare(second.id));
752
+ return Object.freeze({ pose, parcel: runtimeParcel, buildings: Object.freeze(buildings) });
753
+ }
754
+ }
755
+ throw new SpatialRuntimeError("POSE_OUTSIDE_PARCEL", "Host 受控角色当前位置不在任何地块内");
756
+ }
757
+ enterCurrentBuilding(buildingIdInput) {
758
+ const buildingId = stableId(buildingIdInput, "buildingId");
759
+ const pose = this.#pose();
760
+ let activated;
761
+ let building;
762
+ for (const candidate of this.#activatedParcels.values()) {
763
+ const found = candidate.buildings.find((item) => item.id === buildingId);
764
+ if (found !== undefined) {
765
+ activated = candidate;
766
+ building = found;
767
+ break;
768
+ }
769
+ }
770
+ if (activated === undefined || building === undefined) {
771
+ throw new SpatialRuntimeError("PARENT_PARCEL_NOT_ACTIVATED", `建筑 ${buildingId} 的父地块尚未由真实进入 gate 激活`);
772
+ }
773
+ if (!contains(activated.parcel.bounds, pose.x, pose.y)) {
774
+ throw new SpatialRuntimeError("POSE_OUTSIDE_BUILDING", `Host 受控角色不在建筑 ${buildingId} 的父地块内`);
775
+ }
776
+ const runtimeParcel = this.#compose("parcel", activated.parcel);
777
+ const runtimeBuilding = this.#compose("building", building);
778
+ if (runtimeParcel === null || runtimeParcel.blocked === true || runtimeParcel.destroyed === true
779
+ || runtimeBuilding === null || runtimeBuilding.blocked === true || runtimeBuilding.destroyed === true) {
780
+ throw new SpatialRuntimeError("TARGET_UNAVAILABLE", `建筑 ${buildingId} 或其父地块已不可用`);
781
+ }
782
+ const entranceDistance = Math.hypot(pose.x - building.entrance.x, pose.y - building.entrance.y);
783
+ if (!contains(building.footprint, pose.x, pose.y) && entranceDistance > BUILDING_ENTRANCE_TOLERANCE_METERS) {
784
+ throw new SpatialRuntimeError("POSE_OUTSIDE_BUILDING", `Host 受控角色不在建筑 ${buildingId} 内或入口容差内`);
785
+ }
786
+ let baseInterior = this.#interiors.get(building.id);
787
+ if (baseInterior === undefined)
788
+ baseInterior = generateBuildingInterior(this.world.worldSeed, building, this.#templateOptions());
789
+ touchLru(this.#interiors, building.id, baseInterior, SPATIAL_RUNTIME_CACHE_LIMITS.buildingInteriors);
790
+ const rooms = baseInterior.rooms
791
+ .map((room) => this.#compose("room", room))
792
+ .filter((room) => room !== null)
793
+ .sort((first, second) => first.id.localeCompare(second.id));
794
+ const doors = baseInterior.doors
795
+ .map((door) => this.#compose("door", door))
796
+ .filter((door) => door !== null)
797
+ .sort((first, second) => first.id.localeCompare(second.id));
798
+ const interior = Object.freeze({
799
+ buildingId: baseInterior.buildingId,
800
+ rooms: Object.freeze(rooms),
801
+ doors: Object.freeze(doors),
802
+ });
803
+ return Object.freeze({ pose, building: runtimeBuilding, interior });
804
+ }
805
+ clearCache() {
806
+ this.#cityLayouts.clear();
807
+ this.#activatedParcels.clear();
808
+ this.#interiors.clear();
809
+ }
810
+ getCacheStats() {
811
+ let materializedBuildings = 0;
812
+ for (const activated of this.#activatedParcels.values())
813
+ materializedBuildings += activated.buildings.length;
814
+ return Object.freeze({
815
+ cityLayouts: this.#cityLayouts.size,
816
+ activatedParcels: this.#activatedParcels.size,
817
+ materializedBuildings,
818
+ buildingInteriors: this.#interiors.size,
819
+ });
820
+ }
821
+ setOverride(targetTypeInput, targetIdInput, fieldInput, valueInput) {
822
+ const type = targetType(targetTypeInput, "targetType");
823
+ const id = stableId(targetIdInput, "targetId");
824
+ const field = overrideField(fieldInput, "field");
825
+ const value = overrideValue(field, valueInput, "value");
826
+ const resolved = this.#requireActiveTarget(type, id);
827
+ const base = resolved.base;
828
+ if (this.#tombstones.has(targetKey(type, id))) {
829
+ throw new SpatialRuntimeError("TARGET_UNAVAILABLE", `${type}:${id} 已 tombstone`);
830
+ }
831
+ const next = new Map(this.#overrides);
832
+ const key = overrideKey(type, id, field);
833
+ if ((field === "blocked" || field === "destroyed") && value === false) {
834
+ next.delete(key);
835
+ }
836
+ else if (field === "label" && "label" in base && base.label === value) {
837
+ next.delete(key);
838
+ }
839
+ else {
840
+ next.set(key, Object.freeze({ targetType: type, targetId: id, locator: resolved.locator, field, value }));
841
+ }
842
+ this.#assertCandidateQuota(next, this.#tombstones);
843
+ this.#overrides = next;
844
+ }
845
+ applyOverride(targetTypeInput, targetIdInput, patch) {
846
+ if (!isRecord(patch))
847
+ throw deltaError("override patch 必须是对象");
848
+ const keys = Object.keys(patch);
849
+ if (keys.length === 0)
850
+ throw deltaError("override patch 不能为空");
851
+ const unknown = keys.filter((key) => !OVERRIDE_FIELDS.includes(key));
852
+ if (unknown.length > 0)
853
+ throw deltaError(`override patch 包含未知字段:${unknown.join(", ")};禁止 geometry`);
854
+ // SpatialCommand 是一个 typed mutation:任何字段无效都必须让整条命令无 effect,
855
+ // 否则重开后的 delta 会带上半条编辑而无法安全 undo/retry。
856
+ const beforeOverrides = this.#overrides;
857
+ const beforeTombstones = this.#tombstones;
858
+ try {
859
+ for (const field of OVERRIDE_FIELDS) {
860
+ if (!Object.hasOwn(patch, field))
861
+ continue;
862
+ this.setOverride(targetTypeInput, targetIdInput, field, patch[field]);
863
+ }
864
+ }
865
+ catch (error) {
866
+ this.#overrides = beforeOverrides;
867
+ this.#tombstones = beforeTombstones;
868
+ throw error;
869
+ }
870
+ }
871
+ setTombstone(targetTypeInput, targetIdInput, tombstoned = true) {
872
+ const type = targetType(targetTypeInput, "targetType");
873
+ const id = stableId(targetIdInput, "targetId");
874
+ const resolved = this.#requireActiveTarget(type, id);
875
+ const nextTombstones = new Map(this.#tombstones);
876
+ const nextOverrides = new Map(this.#overrides);
877
+ const key = targetKey(type, id);
878
+ if (tombstoned) {
879
+ nextTombstones.set(key, Object.freeze({ targetType: type, targetId: id, locator: resolved.locator }));
880
+ for (const [candidateKey, operation] of nextOverrides) {
881
+ if (operation.targetType === type && operation.targetId === id)
882
+ nextOverrides.delete(candidateKey);
883
+ }
884
+ }
885
+ else {
886
+ nextTombstones.delete(key);
887
+ }
888
+ this.#assertCandidateQuota(nextOverrides, nextTombstones);
889
+ this.#overrides = nextOverrides;
890
+ this.#tombstones = nextTombstones;
891
+ }
892
+ serializeDelta() {
893
+ return this.#serializeCandidate(this.#overrides, this.#tombstones);
894
+ }
895
+ serializeDeltaUtf8() {
896
+ return new TextEncoder().encode(JSON.stringify(this.serializeDelta()));
897
+ }
898
+ restoreDelta(input) {
899
+ const parsed = this.#parseDelta(input);
900
+ this.#overrides = parsed.overrides;
901
+ this.#tombstones = parsed.tombstones;
902
+ }
903
+ applyDelta(input) {
904
+ const parsed = this.#parseDelta(input);
905
+ const nextOverrides = new Map(this.#overrides);
906
+ const nextTombstones = new Map(this.#tombstones);
907
+ for (const operation of parsed.tombstones.values()) {
908
+ nextTombstones.set(targetKey(operation.targetType, operation.targetId), operation);
909
+ for (const [key, current] of nextOverrides) {
910
+ if (current.targetType === operation.targetType && current.targetId === operation.targetId)
911
+ nextOverrides.delete(key);
912
+ }
913
+ }
914
+ for (const operation of parsed.overrides.values()) {
915
+ if (nextTombstones.has(targetKey(operation.targetType, operation.targetId)))
916
+ continue;
917
+ nextOverrides.set(overrideKey(operation.targetType, operation.targetId, operation.field), operation);
918
+ }
919
+ this.#assertCandidateQuota(nextOverrides, nextTombstones);
920
+ this.#overrides = nextOverrides;
921
+ this.#tombstones = nextTombstones;
922
+ }
923
+ #validateBinding(value) {
924
+ if (!isRecord(value))
925
+ throw new TypeError("binding 必须是对象");
926
+ const unknown = Object.keys(value).filter((key) => !BINDING_KEYS.includes(key));
927
+ const missing = BINDING_KEYS.filter((key) => !Object.hasOwn(value, key));
928
+ if (unknown.length > 0)
929
+ throw new TypeError(`binding 包含未知字段:${unknown.join(", ")}`);
930
+ if (missing.length > 0)
931
+ throw new TypeError(`binding 缺少字段:${missing.join(", ")}`);
932
+ return Object.freeze({
933
+ artifactDigest: sha256Digest(value.artifactDigest, "binding.artifactDigest"),
934
+ baseDigest: sha256Digest(value.baseDigest, "binding.baseDigest"),
935
+ worldlineId: nonEmptyIdentity(value.worldlineId, "binding.worldlineId", 128),
936
+ controlledActorId: nonEmptyIdentity(value.controlledActorId, "binding.controlledActorId", 128),
937
+ });
938
+ }
939
+ #pose() {
940
+ const raw = this.#readControlledPose();
941
+ if (!isRecord(raw)) {
942
+ throw new SpatialRuntimeError("HOST_POSE_UNAVAILABLE", "Host 未提供受控角色 pose");
943
+ }
944
+ let actorId;
945
+ try {
946
+ actorId = nonEmptyIdentity(raw.actorId, "Host pose.actorId", 128);
947
+ }
948
+ catch (error) {
949
+ throw new SpatialRuntimeError("HOST_POSE_INVALID", error instanceof Error ? error.message : "Host pose.actorId 非法");
950
+ }
951
+ if (actorId !== this.binding.controlledActorId) {
952
+ throw new SpatialRuntimeError("HOST_POSE_ACTOR_MISMATCH", "Host pose.actorId 与受控角色 binding 不匹配");
953
+ }
954
+ let x;
955
+ let y;
956
+ try {
957
+ x = finiteMetric(raw.x, "Host pose.x");
958
+ y = finiteMetric(raw.y, "Host pose.y");
959
+ }
960
+ catch (error) {
961
+ throw new SpatialRuntimeError("HOST_POSE_INVALID", error instanceof Error ? error.message : "Host pose 坐标非法");
962
+ }
963
+ return Object.freeze({ actorId, x, y, revision: validateRevision(raw.revision) });
964
+ }
965
+ #layout(city) {
966
+ const cached = this.#cityLayouts.get(city.id);
967
+ if (cached !== undefined)
968
+ return touchLru(this.#cityLayouts, city.id, cached, SPATIAL_RUNTIME_CACHE_LIMITS.cityLayouts);
969
+ if (this.#terrainRoadNetwork !== null) {
970
+ const terrain = this.#terrainRoadNetwork.cityLayouts.find((layout) => layout.cityId === city.id)?.terrain;
971
+ const boundary = this.#terrainRoadNetwork.cityLayouts.find((layout) => layout.cityId === city.id)?.boundary;
972
+ const generated = generateRoadParcels(this.world.worldSeed, city, {
973
+ // 必须带完整网络:motorway/intercity 不围 block,但仍是 forbidden corridor。
974
+ roads: this.#terrainRoadNetwork.roads,
975
+ ...(boundary === undefined ? {} : { boundary }),
976
+ ...(terrain === undefined ? {} : { terrain }),
977
+ // 由已保存 source 的 exact identity 决定,不以“当前默认版本”猜测。
978
+ algorithm: roadParcelAlgorithm(this.#terrainRoadGeneration.identity),
979
+ // 新道路的地块坡度门与发布 roadGeneration.maxGrade 相同;缺 terrain 时 helper 明确空结果。
980
+ settings: {
981
+ maxSlope: this.#terrainRoadGeneration.maxGrade,
982
+ ...(resolveRoadParcelTemplateSettings(this.world.templates) ?? {}),
983
+ },
984
+ });
985
+ return touchLru(this.#cityLayouts, city.id, Object.freeze({ roads: Object.freeze([]), parcels: generated.parcels }), SPATIAL_RUNTIME_CACHE_LIMITS.cityLayouts);
986
+ }
987
+ return touchLru(this.#cityLayouts, city.id, generateCityLayout(this.world.worldSeed, city, this.#templateOptions()), SPATIAL_RUNTIME_CACHE_LIMITS.cityLayouts);
988
+ }
989
+ #templateOptions() {
990
+ return this.world.templates === undefined ? {} : { templates: this.world.templates };
991
+ }
992
+ #compose(type, base) {
993
+ if (this.#tombstones.has(targetKey(type, base.id)))
994
+ return null;
995
+ const patch = {};
996
+ for (const field of OVERRIDE_FIELDS) {
997
+ const operation = this.#overrides.get(overrideKey(type, base.id, field));
998
+ if (operation !== undefined)
999
+ patch[field] = operation.value;
1000
+ }
1001
+ if (Object.keys(patch).length === 0)
1002
+ return base;
1003
+ return Object.freeze({ ...base, ...patch });
1004
+ }
1005
+ #requireActiveTarget(type, id) {
1006
+ if (type === "city") {
1007
+ const city = this.world.cities.find((candidate) => candidate.id === id);
1008
+ if (city !== undefined)
1009
+ return { base: city, locator: Object.freeze({ cityId: city.id }) };
1010
+ }
1011
+ if (type === "road" && this.#terrainRoadNetwork !== null) {
1012
+ const road = this.#terrainRoadNetwork.roads.find((candidate) => candidate.id === id);
1013
+ if (road !== undefined) {
1014
+ return { base: road, locator: Object.freeze({ cityId: road.cityId ?? road.fromCityId ?? road.toCityId ?? "world" }) };
1015
+ }
1016
+ }
1017
+ if (type === "parcel") {
1018
+ for (const city of this.world.cities) {
1019
+ const layout = this.#layout(city);
1020
+ const base = layout.parcels.find((candidate) => candidate.id === id);
1021
+ if (base !== undefined)
1022
+ return { base, locator: Object.freeze({ cityId: city.id }) };
1023
+ }
1024
+ }
1025
+ if (type === "road" && this.#terrainRoadNetwork === null) {
1026
+ for (const city of this.world.cities) {
1027
+ const road = this.#layout(city).roads.find((candidate) => candidate.id === id);
1028
+ if (road !== undefined)
1029
+ return { base: road, locator: Object.freeze({ cityId: city.id }) };
1030
+ }
1031
+ }
1032
+ if (type === "building") {
1033
+ for (const activated of this.#activatedParcels.values()) {
1034
+ const base = activated.buildings.find((candidate) => candidate.id === id);
1035
+ if (base !== undefined) {
1036
+ return {
1037
+ base,
1038
+ locator: Object.freeze({ cityId: activated.city.id, parcelId: activated.parcel.id }),
1039
+ };
1040
+ }
1041
+ }
1042
+ }
1043
+ if (type === "room" || type === "door") {
1044
+ for (const [buildingId, interior] of this.#interiors) {
1045
+ const base = type === "room"
1046
+ ? interior.rooms.find((candidate) => candidate.id === id)
1047
+ : interior.doors.find((candidate) => candidate.id === id);
1048
+ if (base === undefined)
1049
+ continue;
1050
+ for (const activated of this.#activatedParcels.values()) {
1051
+ if (!activated.buildings.some((candidate) => candidate.id === buildingId))
1052
+ continue;
1053
+ return {
1054
+ base,
1055
+ locator: Object.freeze({
1056
+ cityId: activated.city.id,
1057
+ parcelId: activated.parcel.id,
1058
+ buildingId,
1059
+ }),
1060
+ };
1061
+ }
1062
+ }
1063
+ }
1064
+ throw new SpatialRuntimeError("TARGET_NOT_FOUND", `delta target 未在允许层级物化:${type}:${id}`);
1065
+ }
1066
+ #resolveTargetByLocator(type, id, locator) {
1067
+ const city = this.world.cities.find((candidate) => candidate.id === locator.cityId);
1068
+ if (city === undefined && !(type === "road" && this.#terrainRoadNetwork !== null)) {
1069
+ throw new SpatialRuntimeError("TARGET_NOT_FOUND", `delta locator city 不存在:${locator.cityId}`);
1070
+ }
1071
+ if (type === "city") {
1072
+ if (city === undefined)
1073
+ throw new SpatialRuntimeError("TARGET_NOT_FOUND", `delta locator city 不存在:${locator.cityId}`);
1074
+ if (id !== city.id)
1075
+ throw new SpatialRuntimeError("TARGET_NOT_FOUND", `delta target 与 city locator 不匹配:${id}`);
1076
+ return city;
1077
+ }
1078
+ if (type === "road") {
1079
+ if (this.#terrainRoadNetwork !== null) {
1080
+ const road = this.#terrainRoadNetwork.roads.find((candidate) => candidate.id === id);
1081
+ const expectedCityId = road?.cityId ?? road?.fromCityId ?? road?.toCityId ?? "world";
1082
+ if (road === undefined || locator.cityId !== expectedCityId) {
1083
+ throw new SpatialRuntimeError("TARGET_NOT_FOUND", `delta road 不存在于 locator city:${id}`);
1084
+ }
1085
+ return road;
1086
+ }
1087
+ if (city === undefined)
1088
+ throw new SpatialRuntimeError("TARGET_NOT_FOUND", `delta locator city 不存在:${locator.cityId}`);
1089
+ const layout = generateCityLayout(this.world.worldSeed, city, this.#templateOptions());
1090
+ const road = layout.roads.find((candidate) => candidate.id === id);
1091
+ if (road === undefined)
1092
+ throw new SpatialRuntimeError("TARGET_NOT_FOUND", `delta road 不存在于 locator city:${id}`);
1093
+ return road;
1094
+ }
1095
+ if (city === undefined)
1096
+ throw new SpatialRuntimeError("TARGET_NOT_FOUND", `delta locator city 不存在:${locator.cityId}`);
1097
+ const layout = this.#terrainRoadNetwork === null
1098
+ ? generateCityLayout(this.world.worldSeed, city, this.#templateOptions())
1099
+ : this.#layout(city);
1100
+ if (type === "parcel") {
1101
+ const parcel = layout.parcels.find((candidate) => candidate.id === id);
1102
+ if (parcel === undefined)
1103
+ throw new SpatialRuntimeError("TARGET_NOT_FOUND", `delta parcel 不存在于 locator city:${id}`);
1104
+ return parcel;
1105
+ }
1106
+ if (!("parcelId" in locator))
1107
+ throw deltaError(`${type} locator 缺少 parcelId`);
1108
+ const parcel = layout.parcels.find((candidate) => candidate.id === locator.parcelId);
1109
+ if (parcel === undefined)
1110
+ throw new SpatialRuntimeError("TARGET_NOT_FOUND", `delta locator parcel 不存在:${locator.parcelId}`);
1111
+ const buildings = generateParcelBuildings(this.world.worldSeed, city, parcel, this.#templateOptions());
1112
+ if (type === "building") {
1113
+ const building = buildings.find((candidate) => candidate.id === id);
1114
+ if (building === undefined)
1115
+ throw new SpatialRuntimeError("TARGET_NOT_FOUND", `delta building 不存在于 locator parcel:${id}`);
1116
+ return building;
1117
+ }
1118
+ if (!("buildingId" in locator))
1119
+ throw deltaError(`${type} locator 缺少 buildingId`);
1120
+ const building = buildings.find((candidate) => candidate.id === locator.buildingId);
1121
+ if (building === undefined)
1122
+ throw new SpatialRuntimeError("TARGET_NOT_FOUND", `delta locator building 不存在:${locator.buildingId}`);
1123
+ const interior = generateBuildingInterior(this.world.worldSeed, building, this.#templateOptions());
1124
+ const target = type === "room"
1125
+ ? interior.rooms.find((candidate) => candidate.id === id)
1126
+ : interior.doors.find((candidate) => candidate.id === id);
1127
+ if (target === undefined)
1128
+ throw new SpatialRuntimeError("TARGET_NOT_FOUND", `delta ${type} 不存在于 locator building:${id}`);
1129
+ return target;
1130
+ }
1131
+ #parseDelta(input) {
1132
+ const decoded = decodeDeltaInput(input);
1133
+ assertRecord(decoded, "delta");
1134
+ assertExactKeys(decoded, DELTA_KEYS, "delta");
1135
+ if (decoded.version !== SPATIAL_DELTA_VERSION)
1136
+ throw deltaError(`delta.version 必须为 ${SPATIAL_DELTA_VERSION}`);
1137
+ assertRecord(decoded.binding, "delta.binding");
1138
+ assertExactKeys(decoded.binding, DELTA_BINDING_KEYS, "delta.binding");
1139
+ const binding = {
1140
+ artifactDigest: sha256Digest(decoded.binding.artifactDigest, "delta.binding.artifactDigest"),
1141
+ baseDigest: sha256Digest(decoded.binding.baseDigest, "delta.binding.baseDigest"),
1142
+ worldlineId: nonEmptyIdentity(decoded.binding.worldlineId, "delta.binding.worldlineId", 128),
1143
+ };
1144
+ if (binding.artifactDigest !== this.binding.artifactDigest
1145
+ || binding.baseDigest !== this.binding.baseDigest
1146
+ || binding.worldlineId !== this.binding.worldlineId) {
1147
+ throw new SpatialRuntimeError("DELTA_BINDING_MISMATCH", "delta binding 与当前 artifact/base/worldline 不匹配");
1148
+ }
1149
+ if (!Array.isArray(decoded.overrides) || !Array.isArray(decoded.tombstones)) {
1150
+ throw deltaError("delta.overrides/tombstones 必须是数组");
1151
+ }
1152
+ if (decoded.overrides.length > SPATIAL_DELTA_QUOTAS.maxOverrides
1153
+ || decoded.tombstones.length > SPATIAL_DELTA_QUOTAS.maxTombstones) {
1154
+ throw new SpatialRuntimeError("DELTA_QUOTA_EXCEEDED", "delta operation 数量预算超限");
1155
+ }
1156
+ let encoded;
1157
+ try {
1158
+ encoded = JSON.stringify(decoded);
1159
+ }
1160
+ catch {
1161
+ throw deltaError("delta 不是可序列化 JSON");
1162
+ }
1163
+ if (utf8Length(encoded) > SPATIAL_DELTA_QUOTAS.maxUtf8Bytes) {
1164
+ throw new SpatialRuntimeError("DELTA_QUOTA_EXCEEDED", "delta UTF-8 字节预算超限");
1165
+ }
1166
+ const overrides = new Map();
1167
+ const tombstones = new Map();
1168
+ for (let index = 0; index < decoded.overrides.length; index += 1) {
1169
+ const raw = decoded.overrides[index];
1170
+ assertRecord(raw, `delta.overrides[${index}]`);
1171
+ assertExactKeys(raw, OVERRIDE_KEYS, `delta.overrides[${index}]`);
1172
+ const type = targetType(raw.targetType, `delta.overrides[${index}].targetType`);
1173
+ const id = stableId(raw.targetId, `delta.overrides[${index}].targetId`);
1174
+ const locator = validateLocator(raw.locator, type, `delta.overrides[${index}].locator`);
1175
+ const field = overrideField(raw.field, `delta.overrides[${index}].field`);
1176
+ const value = overrideValue(field, raw.value, `delta.overrides[${index}].value`);
1177
+ this.#resolveTargetByLocator(type, id, locator);
1178
+ const operation = Object.freeze({ targetType: type, targetId: id, locator, field, value });
1179
+ overrides.set(overrideKey(type, id, field), operation);
1180
+ }
1181
+ for (let index = 0; index < decoded.tombstones.length; index += 1) {
1182
+ const raw = decoded.tombstones[index];
1183
+ assertRecord(raw, `delta.tombstones[${index}]`);
1184
+ assertExactKeys(raw, TOMBSTONE_KEYS, `delta.tombstones[${index}]`);
1185
+ const type = targetType(raw.targetType, `delta.tombstones[${index}].targetType`);
1186
+ const id = stableId(raw.targetId, `delta.tombstones[${index}].targetId`);
1187
+ const locator = validateLocator(raw.locator, type, `delta.tombstones[${index}].locator`);
1188
+ this.#resolveTargetByLocator(type, id, locator);
1189
+ const operation = Object.freeze({ targetType: type, targetId: id, locator });
1190
+ tombstones.set(targetKey(type, id), operation);
1191
+ }
1192
+ for (const operation of tombstones.values()) {
1193
+ for (const [key, override] of overrides) {
1194
+ if (override.targetType === operation.targetType && override.targetId === operation.targetId)
1195
+ overrides.delete(key);
1196
+ }
1197
+ }
1198
+ this.#assertCandidateQuota(overrides, tombstones);
1199
+ return { overrides, tombstones };
1200
+ }
1201
+ #serializeCandidate(overrides, tombstones) {
1202
+ const serializedOverrides = Array.from(overrides.values())
1203
+ .sort(compareOverride)
1204
+ .map((operation) => Object.freeze({ ...operation }));
1205
+ const serializedTombstones = Array.from(tombstones.values())
1206
+ .sort(compareTarget)
1207
+ .map((operation) => Object.freeze({ ...operation }));
1208
+ return Object.freeze({
1209
+ version: SPATIAL_DELTA_VERSION,
1210
+ binding: Object.freeze({
1211
+ artifactDigest: this.binding.artifactDigest,
1212
+ baseDigest: this.binding.baseDigest,
1213
+ worldlineId: this.binding.worldlineId,
1214
+ }),
1215
+ overrides: Object.freeze(serializedOverrides),
1216
+ tombstones: Object.freeze(serializedTombstones),
1217
+ });
1218
+ }
1219
+ #assertCandidateQuota(overrides, tombstones) {
1220
+ if (overrides.size > SPATIAL_DELTA_QUOTAS.maxOverrides || tombstones.size > SPATIAL_DELTA_QUOTAS.maxTombstones) {
1221
+ throw new SpatialRuntimeError("DELTA_QUOTA_EXCEEDED", "delta operation 数量预算超限");
1222
+ }
1223
+ const bytes = utf8Length(JSON.stringify(this.#serializeCandidate(overrides, tombstones)));
1224
+ if (bytes > SPATIAL_DELTA_QUOTAS.maxUtf8Bytes) {
1225
+ throw new SpatialRuntimeError("DELTA_QUOTA_EXCEEDED", "delta UTF-8 字节预算超限");
1226
+ }
1227
+ }
1228
+ }
1229
+ export function createSpatialRuntime(options) {
1230
+ return new SpatialRuntimeImpl(options);
1231
+ }