@homie-rocks/studio 0.46.6 → 0.47.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.
@@ -1,9 +1,22 @@
1
+ import { periodicDelta, wrapValue } from './topology.mjs';
1
2
  /** Per-connection state delivery. Pure data, shared by the relay and the view.
2
3
  * Deltas may name a periodic keyframe or the previous ordered frame. A missing
3
4
  * chained predecessor is ignored until the next keyframe. Periodic encoders
4
5
  * repair the chain; ordered transports reset it on connection or epoch change.
5
6
  * No history grows with time: one keyframe per connection, no per-frame queue.
6
7
  */
8
+ const wrapOf = snap => snap.d?.[2]?.[3]?.wrap;
9
+ const axisNames=['x','y','z'];
10
+ const distanceSq=(p,q,wrap)=>{if(!wrap){const dx=p[0]-q[0],dy=p[1]-q[1],dz=(p[2]??0)-(q[2]??0);return dx*dx+dy*dy+dz*dz;}let d=0;for(let i=0;i<3;i++)d+=periodicDelta(q[i]??0,p[i]??0,wrap?.[axisNames[i]])**2;return d;};
11
+ function periodicCells(cells,p,size,wrap) {
12
+ const shifts=axisNames.map(k=>wrap?.[k]?[0,wrap[k][1]-wrap[k][0],wrap[k][0]-wrap[k][1]]:[0]);
13
+ const found=new Set();
14
+ for(const sx of shifts[0])for(const sy of shifts[1])for(const sz of shifts[2]){
15
+ const x=Math.floor((p[0]+sx)/size),y=Math.floor((p[1]+sy)/size),z=Math.floor(((p[2]??0)+sz)/size);
16
+ for(let dx=-1;dx<=1;dx++)for(let dy=-1;dy<=1;dy++)for(let dz=-1;dz<=1;dz++)for(const e of cells.get(`${x+dx},${y+dy},${z+dz}`)??[])found.add(e);
17
+ }
18
+ return [...found];
19
+ }
7
20
  const spatial = new WeakMap(), controls = new WeakMap();
8
21
  function indexFor(snap, radius) {
9
22
  let byRadius = spatial.get(snap.d[1]);
@@ -29,10 +42,11 @@ export function interestSnapshot(snap, seat, radiusM) {
29
42
  else if (own) {
30
43
  const q=own[3], x=Math.floor(q[0]/index.size), y=Math.floor(q[1]/index.size), z=Math.floor((q[2]??0)/index.size);
31
44
  const cell = `${x},${y},${z}`;
32
- let candidates = index.candidates.get(cell);
45
+ let candidates = wrapOf(snap) ? null : index.candidates.get(cell);
33
46
  if (!candidates) {
34
47
  candidates = [];
35
- for(let dx=-1;dx<=1;dx++)for(let dy=-1;dy<=1;dy++)for(let dz=-1;dz<=1;dz++)
48
+ if(wrapOf(snap)) candidates=periodicCells(index.cells,q,index.size,wrapOf(snap));
49
+ else for(let dx=-1;dx<=1;dx++)for(let dy=-1;dy<=1;dy++)for(let dz=-1;dz<=1;dz++)
36
50
  candidates.push(...(index.cells.get(`${x+dx},${y+dy},${z+dz}`)??[]));
37
51
  // Every player in this cell shares the ordered candidates. The final
38
52
  // radius check remains exact for that player's position.
@@ -41,7 +55,7 @@ export function interestSnapshot(snap, seat, radiusM) {
41
55
  }
42
56
  for (const e of candidates) {
43
57
  const p=e[3];
44
- if(e===own||(p[0]-q[0])**2+(p[1]-q[1])**2+((p[2]??0)-(q[2]??0))**2<=radiusM**2)visible.push(e);
58
+ if(e===own||distanceSq(p,q,wrapOf(snap))<=radiusM**2)visible.push(e);
45
59
  }
46
60
  }
47
61
  }
@@ -97,9 +111,9 @@ export function snapshotText(snap) {
97
111
 
98
112
  /** Whether a body that left `sent` at tick `sentTick` and kept its velocity is within `tolerance` of `entity` now. */
99
113
  const POSITION = 1 << 3, VELOCITY = 1 << 4, HEADING = 1 << 5;
100
- function coasts(entity, sent, ticks, hz, tolerance) {
114
+ function coasts(entity, sent, ticks, hz, tolerance, wrap) {
101
115
  const p = entity[3], q = sent[3], v = sent[4], t = ticks / hz;
102
- for (let i = 0; i < p.length; i++) if (Math.abs(p[i] - (q[i] + v[i] * t)) > tolerance) return false;
116
+ for (let i = 0; i < p.length; i++) if (Math.abs(periodicDelta(q[i] + v[i] * t,p[i],wrap?.[axisNames[i]])) > tolerance) return false;
103
117
  // A heading is a unit vector: the same tolerance, as a turn of that many metres at one metre's reach.
104
118
  const h = entity[5], g = sent[5];
105
119
  if (Array.isArray(h) && Array.isArray(g)) for (let i = 0; i < h.length; i++) if (Math.abs(h[i] - g[i]) > COAST_HEADING) return false;
@@ -136,7 +150,7 @@ export function snapshotEncoder(keyframeTicks, chained = false, coast = null) {
136
150
  if (!before) { changed.push(entity); continue; }
137
151
  const change = changedRow(entity, before);
138
152
  // Only where it is, how fast and which way it faces: anything else a body changed is always sent.
139
- if (change && coasting && !(change[1] & ~(POSITION | VELOCITY | HEADING)) && coasts(entity, before, snap.k - held.k, coasting.hz, coasting.toleranceM)) continue;
153
+ if (change && coasting && !(change[1] & ~(POSITION | VELOCITY | HEADING)) && coasts(entity, before, snap.k - held.k, coasting.hz, coasting.toleranceM, wrapOf(snap))) continue;
140
154
  if (held && chained) { held.entity = entity; held.k = snap.k; }
141
155
  if (change) changed.push(change);
142
156
  }
@@ -154,13 +168,13 @@ export function snapshotDecoder() {
154
168
  // The tick each row was last SENT at: a frame that says `coast` leaves a body out while it keeps its course,
155
169
  // and this end moves it along its last velocity. `rows` keeps what was sent; what is returned is where it is now.
156
170
  let sentAt = new Map();
157
- const coasted = (list, snap) => {
171
+ const coasted = (list, snap, collision) => {
158
172
  const hz = snap.coast;
159
173
  if (!(hz > 0)) return list;
160
174
  return list.map(row => {
161
175
  const ticks = snap.k - (sentAt.get(row[0]) ?? snap.k), v = row[4];
162
176
  if (ticks <= 0 || !Array.isArray(v) || !v.some(n => n !== 0)) return row;
163
- const moved = row.slice(); moved[3] = row[3].map((n, i) => n + v[i] * ticks / hz);
177
+ const moved = row.slice(); moved[3] = row[3].map((n, i) => wrapValue(n + v[i] * ticks / hz, collision?.[3]?.wrap?.[axisNames[i]]));
164
178
  return moved;
165
179
  });
166
180
  };
@@ -212,7 +226,7 @@ export function snapshotDecoder() {
212
226
  for (const id of d.removed) { next.delete(id); if (d.chain) sentAt.delete(id); }
213
227
  for (const entity of updates) { next.set(entity[0], entity); if (d.chain) sentAt.set(entity[0], snap.k); }
214
228
  for (const e of d.own ?? []) { next.set(e[0], e); if (d.chain) sentAt.set(e[0], snap.k); }
215
- const decoded = { ...snap, d: [d.round, coasted([...next.values()], snap), ...(d.collision === undefined ? base.d.slice(2) : [d.collision])] };
229
+ const decoded = { ...snap, d: [d.round, coasted([...next.values()], snap, d.collision ?? base.d[2]), ...(d.collision === undefined ? base.d.slice(2) : [d.collision])] };
216
230
  if (d.chain) { base = decoded; rows = next; }
217
231
  return decoded;
218
232
  },
@@ -238,7 +252,7 @@ export function scheduledView({ radiusM = null, precisionM = 0, nearM = null, fa
238
252
  let row = entity, sampledTick = snap.k;
239
253
  if (entity !== own) {
240
254
  const p = entity[3], q = own?.[3];
241
- const distanceSquared = q ? (p[0]-q[0])**2 + (p[1]-q[1])**2 + ((p[2]??0)-(q[2]??0))**2 : 0;
255
+ const distanceSquared = q ? distanceSq(p,q,wrapOf(snap)) : 0;
242
256
  const old = held?.row;
243
257
  const interval = farHz && nearM !== null && distanceSquared > nearM**2 ? Math.max(1, Math.round(tickHz / farHz)) : 1;
244
258
  // Spread far-view refreshes across players instead of sending the
@@ -355,10 +369,11 @@ export function crowdWorlds({ radiusM = null, precisionM = 0 } = {}) {
355
369
  near(p) {
356
370
  if (radiusM === null) return this.everything();
357
371
  const x = Math.floor(p[0] / size), y = Math.floor(p[1] / size), z = Math.floor((p[2] ?? 0) / size), key = `${x},${y},${z}`;
358
- let list = around.get(key);
372
+ let list = wrapOf(snap) ? null : around.get(key);
359
373
  if (!list) {
360
374
  list = [];
361
- for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) for (let dz = -1; dz <= 1; dz++) { const cell = cells.get(`${x + dx},${y + dy},${z + dz}`); if (cell) for (const i of cell) list.push(i); }
375
+ if(wrapOf(snap)) list=periodicCells(cells,p,size,wrapOf(snap));
376
+ else for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) for (let dz = -1; dz <= 1; dz++) { const cell = cells.get(`${x + dx},${y + dy},${z + dz}`); if (cell) for (const i of cell) list.push(i); }
362
377
  list.sort((a, b) => a - b); around.set(key, list);
363
378
  }
364
379
  return list;
@@ -390,7 +405,7 @@ export function crowdView(worlds, { nearM = null, farHz = null } = {}, tickHz =
390
405
  let far = false;
391
406
  if (e !== mine && q) {
392
407
  const p = e[3]; let d = 0;
393
- for (let a = 0; a < p.length; a++) d += (p[a] - q[a]) ** 2;
408
+ for (let a = 0; a < p.length; a++) d += periodicDelta(q[a],p[a],wrapOf(snap)?.[axisNames[a]]) ** 2;
394
409
  if (d > reach) continue;
395
410
  far = d > nearSq;
396
411
  }
@@ -407,7 +422,7 @@ export function crowdView(worlds, { nearM = null, farHz = null } = {}, tickHz =
407
422
  let tolerance = precisionM;
408
423
  if (far) { let speed = 0; for (let a = 0; a < v.length; a++) speed += v[a] * v[a]; tolerance = Math.max(precisionM, Math.sqrt(speed) * farShare); }
409
424
  let there = true;
410
- for (let a = 0; a < p.length; a++) if (Math.abs(p[a] - (b[a] + v[a] * t)) > tolerance) { there = false; break; }
425
+ for (let a = 0; a < p.length; a++) if (Math.abs(periodicDelta(b[a]+v[a]*t,p[a],wrapOf(snap)?.[axisNames[a]])) > tolerance) { there = false; break; }
411
426
  if (there) {
412
427
  const h = row[5], g = before[5];
413
428
  if (Array.isArray(h) && Array.isArray(g)) for (let a = 0; a < h.length; a++) if (Math.abs(h[a] - g[a]) > COAST_HEADING) { there = false; break; }
package/rules/live.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import {wrapPoint} from './topology.mjs';
1
2
  import { rayQuery, raySnapshot, type QueryTarget } from './query.ts';
2
3
  import { overlapsTerrain } from './terrain.ts';
3
4
  import { nearbyMap } from './map-index.ts';
@@ -10,7 +11,7 @@ import { castMap3, solidAt, type BodyShape } from './collision.ts';
10
11
  import { sweepMap, rayBox, rayCircle } from './math.ts';
11
12
 
12
13
  export type ColliderRow = [string, string, number, number, number, number, number, number, {kind: string; query?: QueryDef; fields: Record<string, number | string | boolean>}?];
13
- export type CollisionRevision = [number, number, ColliderRow[]];
14
+ export type CollisionRevision = [number, number, ColliderRow[], {wrap?: import('./rules.ts').MapWrap; map?: import('./map-state.ts').MapRevision | null; previousMap?: import('./map-state.ts').MapRevision | null}?];
14
15
  export function colliderRow(id: string, at: Vec3, body: BodyShape, def: ColliderDef, fields: Record<string, unknown>, kind?: string, query?: QueryDef): ColliderRow | null {
15
16
  const opts = def === true ? {} : def;
16
17
  if (opts.enabled && fields[opts.enabled] !== true) return null;
@@ -44,7 +45,7 @@ export function collisionMap(map: MapShapes, rows: readonly ColliderRow[]): MapS
44
45
  else if (shape === 'sphere') spheres.push({ at: { x, y, z: z + w / 2 }, r: w / 2, id } as any);
45
46
  else capsules.push({ at, r: w / 2, height: h, id } as any);
46
47
  }
47
- const combined = { bounds: map.bounds, staticMap: map, boxes, circles, spheres, capsules };
48
+ const combined = { ...(map.wrap?{wrap:map.wrap}:{}), bounds: map.bounds, staticMap: map, boxes, circles, spheres, capsules };
48
49
  rowsOf.set(combined, rows);
49
50
  return combined;
50
51
  }
@@ -71,7 +72,7 @@ export function collisionQueries(map: () => MapShapes, shape: () => BodyShape, d
71
72
  const d = typeof distance === 'number' && Number.isFinite(distance) ? Math.max(0, Math.min(10000, distance)) : 0;
72
73
  if (dims === 2) return Object.freeze({ at: Object.freeze(p), normal: Object.freeze({ x: 0, y: 0, z: 1 }), dist: 0 });
73
74
  const h = castMap3(m, p, { x: 0, y: 0, z: -d }, shape());
74
- return h && h.nz > 0.5 ? Object.freeze({ at: Object.freeze({ ...p, z: p.z - d * h.t }), normal: Object.freeze({ x: h.nx, y: h.ny, z: h.nz }), dist: d * h.t, ...(h.id ? { entity: h.id } : {}) }) : undefined;
75
+ return h && h.nz > 0.5 ? Object.freeze({ at: Object.freeze(wrapPoint({ ...p, z: p.z - d * h.t },m.wrap)), normal: Object.freeze({ x: h.nx, y: h.ny, z: h.nz }), dist: d * h.t, ...(h.id ? { entity: h.id } : {}) }) : undefined;
75
76
  },
76
77
  overlaps: (body: unknown) => {
77
78
  const p = position(body), s = shape(), a = solidAt(p, s), m = nearbyMap(map(), p, {x:0,y:0,z:0}, s.radius, s.height || 2*s.radius, dims);
@@ -1,3 +1,4 @@
1
+ import { imageOffsets } from './topology.mjs';
1
2
  /** Immutable build-time BVH. Queries charge nodes visited and narrow-phase work,
2
3
  * never the number of shapes in the level. Live geometry is a separate overlay. */
3
4
  import { charge } from './guard.ts';
@@ -31,6 +32,29 @@ export function indexMap(map: MapShapes): void {
31
32
  }
32
33
  /** The segment of a body's reference point against bounds expanded by its extents. */
33
34
  export function nearbyMap(map: MapShapes, p:Vec3, d:Vec3, radius:number, height:number, dims=3):MapShapes {
35
+ if (!map.wrap) return nearbyFlatMap(map,p,d,radius,height,dims);
36
+ const bounds={min:{...map.bounds.min},max:{...map.bounds.max}};
37
+ for(const k of axes)if(map.wrap[k]){bounds.min[k]=-Infinity;bounds.max[k]=Infinity;}
38
+ const out:any={bounds,boxes:[],circles:[],spheres:[],capsules:[],heightTiles:[]};
39
+ const extent={min:{...map.bounds.min},max:{...map.bounds.max}};
40
+ const root=indexes.get(map.staticMap??map);
41
+ if(root) for(const k of axes){if(Number.isFinite(root.min[k]))extent.min[k]=Math.min(extent.min[k],root.min[k]);if(Number.isFinite(root.max[k]))extent.max[k]=Math.max(extent.max[k],root.max[k]);}
42
+ if(map.staticMap)for(const key of ['boxes','circles','spheres','capsules'] as const)for(const raw of map[key]??[]){
43
+ charge(12);const v:any=raw;
44
+ for(const k of axes){const low=key==='boxes'?v.min[k]:v.at[k]-v.r,high=key==='boxes'?v.max[k]:v.at[k]+(k==='z'&&key==='capsules'?v.height:v.r);extent.min[k]=Math.min(extent.min[k],low);extent.max[k]=Math.max(extent.max[k],high);}
45
+ }
46
+ for(const offset of imageOffsets(extent,map.wrap,p,d,radius,height,dims)){
47
+ charge(48);
48
+ const found=nearbyFlatMap(map,{x:p.x-offset.x,y:p.y-offset.y,z:p.z-offset.z},d,radius,height,dims);
49
+ const shift=(v:Vec3)=>({x:v.x+offset.x,y:v.y+offset.y,z:v.z+offset.z});
50
+ for(const key of ['boxes','circles','spheres','capsules','heightTiles'] as const)for(const value of found[key]??[]){
51
+ charge(24);const v:any=value;
52
+ out[key].push(key==='boxes'?{...v,min:shift(v.min),max:shift(v.max)}:{...v,at:shift(v.at)});
53
+ }
54
+ }
55
+ return out;
56
+ }
57
+ function nearbyFlatMap(map: MapShapes, p:Vec3, d:Vec3, radius:number, height:number, dims=3):MapShapes {
34
58
  const parent=map.staticMap;
35
59
  const root=indexes.get(parent??map);
36
60
  if(!root)return map;
@@ -0,0 +1,54 @@
1
+ /** Copy and compile a room map before publishing it. No mutation survives a failed bake. */
2
+ import { charge, own } from './guard.ts';
3
+ import { compileMap, type GameMap } from './rules.ts';
4
+ export type MapRevision = readonly [
5
+ number,
6
+ unknown
7
+ ];
8
+ function copy(value: unknown, depth = 0): unknown {
9
+ charge(6);
10
+ if (typeof value === 'string') {
11
+ charge(value.length);
12
+ return value;
13
+ }
14
+ if (value === null || typeof value === 'boolean')
15
+ return value;
16
+ if (typeof value === 'number' && Number.isFinite(value))
17
+ return value;
18
+ if (!value || typeof value !== 'object' || depth > 8)
19
+ throw new Error('map.replace needs finite plain map data');
20
+ if (Array.isArray(value)) {
21
+ const out = [];
22
+ for (let i = 0; i < value.length; i++)
23
+ out.push(copy(own(value, i), depth + 1));
24
+ return Object.freeze(out);
25
+ }
26
+ const out: Record<string, unknown> = Object.create(null);
27
+ for (const key of Object.keys(value)) {
28
+ charge(key.length);
29
+ out[key] = copy(own(value, key), depth + 1);
30
+ }
31
+ return Object.freeze(out);
32
+ }
33
+ export function prepareMapReplacement(value: unknown, base: GameMap): {
34
+ raw: unknown;
35
+ map: GameMap;
36
+ cells: number;
37
+ } {
38
+ const raw = copy(value) as any;
39
+ // Index construction sorts at each depth. Charge its upper bound before compilation.
40
+ let runs = 0;
41
+ const tiles = raw?.tiles;
42
+ if (tiles && Array.isArray(tiles.data)) {
43
+ charge(tiles.data.length * 8);
44
+ for (let i = 0; i < tiles.data.length; i++)
45
+ if (tiles.data[i] && (i % tiles.width === 0 || !tiles.data[i - 1]))
46
+ runs++;
47
+ }
48
+ const count = (raw?.boxes?.length ?? 0) + (raw?.circles?.length ?? 0) + (raw?.spheres?.length ?? 0) + (raw?.capsules?.length ?? 0) + (raw?.heightTiles?.length ?? 0) + runs;
49
+ charge(64 + count * (16 + Math.ceil(Math.log2(count + 1)) ** 2));
50
+ const map = compileMap(raw, base.name);
51
+ if (JSON.stringify(map.bounds) !== JSON.stringify(base.bounds) || (['x', 'y', 'z'] as const).some(k => Boolean(map.wrap?.[k]) !== Boolean(base.wrap?.[k])))
52
+ throw new Error('map.replace preserves bounds and wrap; replace level geometry, tiles and spots');
53
+ return { raw, map, cells: JSON.stringify(raw).length };
54
+ }
package/rules/math.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { wrapPoint } from './topology.mjs';
2
+ import { tileApi } from './tiles.ts';
1
3
  import { nearbyMap } from './map-index.ts';
2
4
  import { collisionQueries } from './live.ts';
3
5
  /*
@@ -184,7 +186,7 @@ export const exact = Object.freeze({ sin, cos, atan, atan2, exp, log, pow });
184
186
  /* ------------------------------------------------------------------ the ground plane: a point or a circle against the map */
185
187
 
186
188
  /** The shapes of a map the sweep reads (rules.ts `GameMap` has them). */
187
- export interface MapShapes { staticMap?: MapShapes; bounds: { min: Vec3; max: Vec3 }; boxes: readonly { min: Vec3; max: Vec3 }[]; circles: readonly { at: Vec3; r: number }[]; spheres?: readonly { at: Vec3; r: number }[]; capsules?: readonly { at: Vec3; r: number; height: number }[]; heightTiles?: readonly import('./rules.ts').MapHeightTile[] }
189
+ export interface MapShapes { wrap?: import('./rules.ts').MapWrap; staticMap?: MapShapes; bounds: { min: Vec3; max: Vec3 }; boxes: readonly { min: Vec3; max: Vec3 }[]; circles: readonly { at: Vec3; r: number }[]; spheres?: readonly { at: Vec3; r: number }[]; capsules?: readonly { at: Vec3; r: number; height: number }[]; heightTiles?: readonly import('./rules.ts').MapHeightTile[] }
188
190
  /** How close a swept body stops to what it hit, in metres. */
189
191
  export const SKIN = 0.001;
190
192
 
@@ -278,16 +280,16 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
278
280
  const h = castMap3(map, p, d, shape);
279
281
  const length = Math.sqrt(dx * dx + dy * dy + d.z * d.z);
280
282
  const t = h ? Math.max(0, h.t - (length > 0 ? SKIN / length : 0)) : 1;
281
- const at = v(fr(px + dx * t), fr(py + dy * t), fr(p.z + d.z * t));
283
+ const at = wrapPoint(v(fr(px + dx * t), fr(py + dy * t), fr(p.z + d.z * t)), map.wrap);
282
284
  put(body, 'pos', at); if (own(options, 'ground') !== false) put(body, 'grounded', restsOnMap(map, at, shape));
283
285
  return h ? Object.freeze({ at, normal: v(h.nx, h.ny, h.nz), ...(h.id ? { entity: h.id } : {}) }) : undefined;
284
286
  }
285
287
  const { hit: h } = castMap(map, px, py, dx, dy, radius);
286
288
  put(body, 'grounded', true);
287
- if (!h) { put(body, 'pos', v(fr(px + dx), fr(py + dy), 0)); return undefined; }
289
+ if (!h) { put(body, 'pos', wrapPoint(v(fr(px + dx), fr(py + dy), 0), map.wrap)); return undefined; }
288
290
  const l = Math.sqrt(dx * dx + dy * dy);
289
291
  const t = l > 0 ? Math.max(0, h.t - SKIN / l) : 0;
290
- const at = v(fr(px + dx * t), fr(py + dy * t), 0);
292
+ const at = wrapPoint(v(fr(px + dx * t), fr(py + dy * t), 0), map.wrap);
291
293
  put(body, 'pos', at);
292
294
  return Object.freeze({ at, normal: v(h.nx, h.ny, 0), ...(h.id ? { entity: h.id } : {}) });
293
295
  }
@@ -299,12 +301,13 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
299
301
  */
300
302
  export function moveContext(o: { tick: () => number; tickHz: number; tune: unknown; map: MapShapes; name: string; spots: Readonly<Record<string, readonly unknown[]>>; radius: () => number; shape?: () => BodyShape; dims: number; geometry?: () => MapShapes; self?: () => string | undefined }): unknown {
301
303
  const none = Object.freeze([]);
304
+ const currentMap = () => (o.geometry?.()?.staticMap ?? o.geometry?.() ?? o.map) as import('./rules.ts').GameMap;
302
305
  const queries = collisionQueries(() => o.geometry?.() ?? o.map, () => o.shape?.() ?? {shape: 'sphere', radius: o.radius(), height: 2 * o.radius()}, o.dims, o.self);
303
306
  return brand(Object.freeze({
304
307
  get tick() { return o.tick(); }, dt: 1 / o.tickHz, tune: o.tune, math,
305
308
  // As the server's `ctx.ticks` reads it: a plain number, or nothing.
306
309
  ticks: (seconds: unknown): number => { const s = num(seconds); const n = Math.round(s * o.tickHz); return s > 0 && Number.isFinite(n) ? Math.max(1, n) : 0; },
307
310
  world: brand(Object.freeze(queries)),
308
- map: brand(Object.freeze({ name: o.name, spot: (name: string) => (own(o.spots, name) as readonly unknown[] | undefined)?.[0], spots: (name: string) => own(o.spots, name) ?? none, ...queries })),
311
+ map: brand(Object.freeze({ ...tileApi(currentMap), name: o.name, spot: (name: string) => (own(currentMap().spots ?? o.spots, name) as readonly unknown[] | undefined)?.[0], spots: (name: string) => own(currentMap().spots ?? o.spots, name) ?? none, ...queries })),
309
312
  }));
310
313
  }
@@ -0,0 +1,53 @@
1
+ // Generated by scripts/studio-nav.mjs from packages/nav/src/Flow.ts.
2
+ /** Cardinal multi-source flow bake. Stable left/up/right/down ties.
3
+ * Callers can debit deterministic work before allocation and for each visited cell. */
4
+ export interface FlowGrid {
5
+ width: number;
6
+ height: number;
7
+ data: readonly number[];
8
+ wrapX?: boolean;
9
+ wrapY?: boolean;
10
+ }
11
+
12
+ export function bakeGridFlow(
13
+ grid: FlowGrid,
14
+ goals: readonly number[],
15
+ charge: (units: number) => void = () => {},
16
+ ): { distance: Int32Array; next: Int32Array } {
17
+ const { width, height, data } = grid;
18
+ const count = width * height;
19
+ if (!Number.isSafeInteger(count) || width < 1 || height < 1 ||
20
+ !Number.isInteger(width) || !Number.isInteger(height) || data.length !== count) {
21
+ throw new Error('nav flow: invalid grid');
22
+ }
23
+ charge(count * 4);
24
+ const distance = new Int32Array(count).fill(-1);
25
+ const next = new Int32Array(count).fill(-1);
26
+ const queue = new Int32Array(count);
27
+ let tail = 0;
28
+ for (const goal of goals) {
29
+ charge(2);
30
+ if (!Number.isInteger(goal) || goal < 0 || goal >= count) throw new Error('nav flow: goal outside grid');
31
+ if (!data[goal] && distance[goal]! < 0) {
32
+ distance[goal] = 0;
33
+ next[goal] = goal;
34
+ queue[tail++] = goal;
35
+ }
36
+ }
37
+ for (let head = 0; head < tail; head++) {
38
+ charge(16);
39
+ const cell = queue[head]!, x = cell % width, y = Math.floor(cell / width);
40
+ for (const [dx, dy] of [[-1, 0], [0, -1], [1, 0], [0, 1]]) {
41
+ let nx = x + dx!, ny = y + dy!;
42
+ if (grid.wrapX) nx = (nx + width) % width;
43
+ if (grid.wrapY) ny = (ny + height) % height;
44
+ if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue;
45
+ const id = ny * width + nx;
46
+ if (data[id] || distance[id] !== -1) continue;
47
+ distance[id] = distance[cell]! + 1;
48
+ next[id] = cell;
49
+ queue[tail++] = id;
50
+ }
51
+ }
52
+ return { distance, next };
53
+ }
package/rules/query.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { imageOffsets, wrapPoint } from './topology.mjs';
1
2
  /** Bounded, data-only ray policy. Shared by rules and predicted geometry queries. */
2
3
  import { charge, brand, plainData, put } from './guard.ts';
3
4
  import { own, dir, vec3 } from './pack.ts';
@@ -37,14 +38,15 @@ export function rayQuery(map: MapShapes, targets: readonly QueryTarget[], dims:
37
38
  const take=(h: {t:number;nx:number;ny:number;nz?:number}|null,id?:string,part?:string):void=>{
38
39
  if(!h)return; charge(32);
39
40
  if(h.t===0 && h.nx===0 && h.ny===0 && !h.nz)h={...h,nx:-d.x,ny:-d.y,nz:-d.z};
40
- hits.push(Object.freeze({at:vec3({x:p.x+delta.x*h.t,y:p.y+delta.y*h.t,z:p.z+delta.z*h.t},dims),normal:vec3({x:h.nx,y:h.ny,z:h.nz??0},dims),dist:max*h.t,...(id?{entity:id}:{}),...(part?{part}:{})}));
41
+ hits.push(Object.freeze({at:vec3(wrapPoint({x:p.x+delta.x*h.t,y:p.y+delta.y*h.t,z:p.z+delta.z*h.t},map.wrap),dims),normal:vec3({x:h.nx,y:h.ny,z:h.nz??0},dims),dist:max*h.t,...(id?{entity:id}:{}),...(part?{part}:{})}));
41
42
  };
42
43
  if(own(options,'entitiesOnly')!==true && (layer===undefined || layer==='geometry')) {
43
44
  if(dims===3) {const found: import('./collision.ts').Hit3[]=[];const h=castMap3(map,start,delta,shape,all?found:undefined,includeInside);if(all)for(const h of found)take(h);else take(h);}
44
45
  else {const found: import('./math.ts').Hit[]=[];const h=castMap(map,p.x,p.y,delta.x,delta.y,radius,all?found:undefined,includeInside,shape.shape==='box').hit;if(all)for(const h of found)take(h);else take(h);}
45
46
  }
46
47
  const point=solidAt(start,shape);
47
- for(const target of targets) {
48
+ const candidates=map.wrap?periodicTargets(targets,map,start,delta,radius,height,dims,profile as string | undefined):targets;
49
+ for(const target of candidates) {
48
50
  if(excluded.has(target.id) || own(options,'geometryOnly')===true&&!target.geometry || layer!==undefined && layer!==(target.query?.layer??(target.geometry?'geometry':'body')) || kind!==undefined && kind!==target.kind || tag!==undefined && !target.query?.tags?.includes(tag as string)) continue;
49
51
  const declared = target.at ? queryParts(target.at,target.query,profile as string | undefined) : target.parts;
50
52
  const parts=(own(options,'geometryOnly')!==true && declared?.length?declared:[{name:'',solid:target.solid}]).filter(part=>{
@@ -96,8 +98,21 @@ export function raySnapshot(map:MapShapes,targets:readonly QueryTarget[],dims:nu
96
98
  const ray=(from:unknown,direction:unknown,max:unknown)=>rayQuery(map,scene,dims,from,direction,max,policy,self)[0];
97
99
  const radius=typeof own(policy,'radius')==='number'?own(policy,'radius') as number:0,height=own(policy,'shape')==='box'||own(policy,'shape')==='capsule'?Number(own(policy,'height')??radius*2):radius*2;
98
100
  const position=(body:unknown)=>{const at=own(body,'pos');if(!at||typeof at!=='object')throw new Error('scene sweep/support takes a scratch body with a plain pos');return vec3(at,dims);};
99
- const support=(body:unknown,distance:unknown=.002)=>{const p=position(body);if(dims===2)return Object.freeze({at:p,normal:Object.freeze({x:0,y:0,z:1}),dist:0});if(typeof distance!=='number'||!Number.isFinite(distance)||distance<0)throw new Error('support distance is finite and nonnegative');const h=rayQuery(map,scene,dims,{x:p.x,y:p.y,z:p.z+height/2},{x:0,y:0,z:-1},distance,policy,self,false,false)[0];return h&&h.normal.z>.5?Object.freeze({...h,at:vec3({x:h.at.x,y:h.at.y,z:(h.at.z??0)-height/2},dims)}):undefined;};
101
+ const support=(body:unknown,distance:unknown=.002)=>{const p=position(body);if(dims===2)return Object.freeze({at:p,normal:Object.freeze({x:0,y:0,z:1}),dist:0});if(typeof distance!=='number'||!Number.isFinite(distance)||distance<0)throw new Error('support distance is finite and nonnegative');const h=rayQuery(map,scene,dims,{x:p.x,y:p.y,z:p.z+height/2},{x:0,y:0,z:-1},distance,policy,self,false,false)[0];return h&&h.normal.z>.5?Object.freeze({...h,at:vec3(wrapPoint({x:h.at.x,y:h.at.y,z:(h.at.z??0)-height/2},map.wrap),dims)}):undefined;};
100
102
  const sweep=(body:unknown,delta:unknown,options?:unknown)=>{const p=position(body),d=vec3(delta,dims),length=Math.sqrt(d.x*d.x+d.y*d.y+d.z*d.z),h=length?rayQuery(map,scene,dims,{x:p.x,y:p.y,z:p.z+height/2},d,length,policy,self,false,false)[0]:undefined;
101
- const fraction=h?Math.max(0,(h.dist-.001)/length):1;put(body as object,'pos',vec3({x:p.x+d.x*fraction,y:p.y+d.y*fraction,z:p.z+d.z*fraction},dims));if(own(options,'ground')!==false)put(body as object,'grounded',dims===2||Boolean(support(body)));return h?Object.freeze({...h,at:vec3(own(body,'pos'),dims)}):undefined;};
103
+ const fraction=h?Math.max(0,(h.dist-.001)/length):1;put(body as object,'pos',vec3(wrapPoint({x:p.x+d.x*fraction,y:p.y+d.y*fraction,z:p.z+d.z*fraction},map.wrap),dims));if(own(options,'ground')!==false)put(body as object,'grounded',dims===2||Boolean(support(body)));return h?Object.freeze({...h,at:vec3(own(body,'pos'),dims)}):undefined;};
102
104
  return brand(Object.freeze({ray,rayAll:(from:unknown,direction:unknown,max:unknown)=>rayQuery(map,scene,dims,from,direction,max,policy,self,true),sweep,support}));
103
105
  }
106
+
107
+ /** Default queries retain their original target loop; only wrapped worlds construct images. */
108
+ function* periodicTargets(targets:readonly QueryTarget[],map:MapShapes,start:Vec3,delta:Vec3,radius:number,height:number,dims:number,profile?:string):Generator<QueryTarget> {
109
+ for(const source of targets) {
110
+ const ext={min:{x:source.solid.min.x-source.solid.r,y:source.solid.min.y-source.solid.r,z:source.solid.min.z-source.solid.r},max:{x:source.solid.max.x+source.solid.r,y:source.solid.max.y+source.solid.r,z:source.solid.max.z+source.solid.r}};
111
+ for(const part of (source.at ? queryParts(source.at,source.query,profile) : source.parts) ?? []) for(const k of ['x','y','z'] as const){ext.min[k]=Math.min(ext.min[k],part.solid.min[k]-part.solid.r);ext.max[k]=Math.max(ext.max[k],part.solid.max[k]+part.solid.r);}
112
+ for(const offset of imageOffsets(ext,map.wrap,start,delta,radius,height,dims)) {
113
+ charge(48);
114
+ const shift=(v:Vec3)=>({x:v.x+offset.x,y:v.y+offset.y,z:v.z+offset.z});
115
+ yield {...source,at:source.at?shift(source.at):undefined,solid:{...source.solid,min:shift(source.solid.min),max:shift(source.solid.max)},parts:source.parts?.map(part=>({...part,solid:{...part.solid,min:shift(part.solid.min),max:shift(part.solid.max)}}))};
116
+ }
117
+ }
118
+ }
package/rules/rules.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { compileTiles, type Tiles } from './tiles.ts';
1
2
  import {prepareGraph,type RouteNode} from './navigation.ts';
2
3
  import { prepareTile as prepareExactTile } from './terrain.ts';
3
4
  import { prepareTile as prepareLegacyTile } from './terrain-legacy.ts';
@@ -289,7 +290,8 @@ export function roomSettings(raw: unknown): { settings: RoomSettings; problems:
289
290
  export interface MapBox { readonly min: Vec3; readonly max: Vec3 }
290
291
  export interface MapCircle { readonly at: Vec3; readonly r: number }
291
292
  export interface MapHeightTile { readonly base?: number; readonly diagonal?: '00-11' | '10-01'; readonly at: Vec3; readonly size: Readonly<{ x: number; y: number }>; readonly heights: readonly [number, number, number, number] }
292
- export interface GameMap { readonly name: string; readonly heightTiles: readonly MapHeightTile[]; readonly bounds: MapBox; readonly boxes: readonly MapBox[]; readonly circles: readonly MapCircle[]; readonly spheres: readonly MapCircle[]; readonly capsules: readonly (MapCircle & { height: number })[]; readonly spots: Readonly<Record<string, readonly Vec3[]>> }
293
+ export type MapWrap = Readonly<Partial<Record<'x' | 'y' | 'z', readonly [number, number]>>>;
294
+ export interface GameMap { readonly tiles?: Tiles; readonly wrap?: MapWrap; readonly name: string; readonly heightTiles: readonly MapHeightTile[]; readonly bounds: MapBox; readonly boxes: readonly MapBox[]; readonly circles: readonly MapCircle[]; readonly spheres: readonly MapCircle[]; readonly capsules: readonly (MapCircle & { height: number })[]; readonly spots: Readonly<Record<string, readonly Vec3[]>> }
293
295
 
294
296
  const vecOf = (a: unknown, what: string): Vec3 => {
295
297
  const p = Array.isArray(a) ? { x: a[0], y: a[1], z: a[2] ?? 0 } : a as { x?: unknown; y?: unknown; z?: unknown } | null;
@@ -323,8 +325,24 @@ export function compileMap(raw: unknown, name = 'main'): GameMap {
323
325
  };
324
326
  if ([m.boxes, m.circles, m.spheres, m.capsules, m.heightTiles].some(list => list !== undefined && !Array.isArray(list))) throw new Error(`${where}: shapes are lists`);
325
327
  if ([m.boxes, m.circles, m.spheres, m.capsules, m.heightTiles].reduce((n, list) => n + (list?.length ?? 0), 0) > 100000) throw new Error(`${where}: at most 100000 static shapes`);
328
+ const bounds = box(m.bounds, `${where} bounds`);
329
+ let wrap: MapWrap | undefined;
330
+ if (m.wrap !== undefined) {
331
+ if (!m.wrap || typeof m.wrap !== 'object' || Array.isArray(m.wrap)) throw new Error(`${where}: wrap names axes x, y, z`);
332
+ const axes: Partial<Record<'x'|'y'|'z', readonly [number,number]>> = {};
333
+ for (const key of Object.keys(m.wrap)) {
334
+ if (!['x','y','z'].includes(key) || m.wrap[key] !== true) throw new Error(`${where}: wrap axes are true (period comes from bounds)`);
335
+ const k = key as 'x'|'y'|'z';
336
+ if (!(bounds.max[k] > bounds.min[k]) || !Number.isFinite(bounds.max[k]-bounds.min[k])) throw new Error(`${where}: wrapped axis needs finite positive extent`);
337
+ axes[k] = Object.freeze([bounds.min[k], bounds.max[k]]);
338
+ }
339
+ if(Object.keys(axes).length) wrap = Object.freeze(axes);
340
+ }
341
+ const tiled=compileTiles(m.tiles,wrap);
342
+ if(tiled) for(const k of ['x','y'] as const) if(wrap?.[k] && (tiled.tiles.at[k]!==bounds.min[k] || tiled.tiles.at[k]+tiled.tiles.size*(k==='x'?tiled.tiles.width:tiled.tiles.height)!==bounds.max[k])) throw new Error(`${where}: tiles must span each wrapped axis`);
326
343
  const result = Object.freeze({
327
- name,
344
+ ...(tiled?{tiles:tiled.tiles}:{}),
345
+ name, ...(wrap ? {wrap} : {}),
328
346
  heightTiles: Object.freeze((m.heightTiles ?? []).map((t: any, i: number) => {
329
347
  const what = `${where} heightTiles[${i}]`;
330
348
  const at = vecOf(t?.at, `${what}.at`);
@@ -342,8 +360,8 @@ export function compileMap(raw: unknown, name = 'main'): GameMap {
342
360
  if (!Number.isFinite(c.height) || c.height < 2 * shape.r) throw new Error(`${where} capsules[${i}]: height is at least twice the radius`);
343
361
  return Object.freeze({ ...shape, height: c.height });
344
362
  })),
345
- bounds: box(m.bounds, `${where} bounds`),
346
- boxes: Object.freeze((Array.isArray(m.boxes) ? m.boxes : []).map((b: unknown, i: number) => box(b, `${where} boxes[${i}]`))),
363
+ bounds,
364
+ boxes: Object.freeze([...(tiled?.boxes??[]),...(Array.isArray(m.boxes) ? m.boxes : [])].map((b: unknown, i: number) => box(b, `${where} boxes[${i}]`))),
347
365
  circles: Object.freeze((Array.isArray(m.circles) ? m.circles : []).map((c: any, i: number) => {
348
366
  const r = Number(c?.r);
349
367
  if (!Number.isFinite(r) || !(r > 0)) throw new Error(`${where} circles[${i}] needs "r", its radius in metres`);
package/rules/tiles.ts ADDED
@@ -0,0 +1,58 @@
1
+ import { bakeGridFlow } from './nav/Flow.ts';
2
+ import { charge } from './guard.ts';
3
+ import type { GameMap, MapBox, MapWrap, Vec3 } from './rules.ts';
4
+ export interface Tiles {
5
+ readonly width: number;
6
+ readonly height: number;
7
+ readonly size: number;
8
+ readonly at: Vec3;
9
+ readonly solidHeight: number;
10
+ readonly data: readonly number[];
11
+ readonly flows: Readonly<Record<string, readonly number[]>>;
12
+ }
13
+ const fields = new WeakMap<object, Map<string, ReturnType<typeof bakeGridFlow>>>();
14
+ export function compileTiles(raw: any, wrap?: MapWrap): {
15
+ tiles: Tiles;
16
+ boxes: MapBox[];
17
+ } | undefined {
18
+ if (raw === undefined)
19
+ return;
20
+ const { width, height, size = 1, solidHeight = 1 } = raw, n = width * height, at = raw.at ?? [0, 0, 0];
21
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1 || !Number.isSafeInteger(n) || !Number.isFinite(size) || size <= 0 || !Number.isFinite(solidHeight) || solidHeight <= 0 || !Array.isArray(at) || at.length < 2 || at.length > 3 || at.some((x: unknown) => typeof x !== 'number' || !Number.isFinite(x)) || !Array.isArray(raw.data) || raw.data.length !== n)
22
+ throw new Error('map tiles: positive width, height, size, solidHeight, at and one data value per cell');
23
+ if (!Number.isFinite(at[0] + width * size) || !Number.isFinite(at[1] + height * size) || !Number.isFinite((at[2] ?? 0) + solidHeight))
24
+ throw new Error('map tiles: finite extent required');
25
+ const data = raw.data.map((v: unknown) => { if (typeof v !== 'number' || !Number.isInteger(v) || v < 0 || v > 65535)
26
+ throw new Error('map tile values are integers 0..65535'); return v; });
27
+ const origin = { x: at[0], y: at[1], z: at[2] ?? 0 }, boxes: MapBox[] = [];
28
+ for (let y = 0; y < height; y++)
29
+ for (let x = 0; x < width; x++)
30
+ if (data[y * width + x]) {
31
+ const start = x;
32
+ while (x + 1 < width && data[y * width + x + 1])
33
+ x++;
34
+ boxes.push(Object.freeze({ min: Object.freeze({ x: origin.x + start * size, y: origin.y + y * size, z: origin.z }), max: Object.freeze({ x: origin.x + (x + 1) * size, y: origin.y + (y + 1) * size, z: origin.z + solidHeight }) }));
35
+ }
36
+ const flows: Record<string, readonly number[]> = {}, baked = new Map<string, ReturnType<typeof bakeGridFlow>>();
37
+ for (const [name, goals] of Object.entries(raw.flows ?? {})) {
38
+ if (!/^[a-z][A-Za-z0-9_]{0,31}$/.test(name) || !Array.isArray(goals))
39
+ throw new Error('tile flows name lists of goal cell indices');
40
+ flows[name] = Object.freeze([...goals]);
41
+ baked.set(name, bakeGridFlow({ width, height, data, wrapX: Boolean(wrap?.x), wrapY: Boolean(wrap?.y) }, goals, charge));
42
+ }
43
+ const tiles = Object.freeze({ width, height, size, solidHeight, at: Object.freeze(origin), data: Object.freeze(data), flows: Object.freeze(flows) });
44
+ fields.set(tiles, baked);
45
+ return { tiles, boxes };
46
+ }
47
+ export function tileApi(get: () => GameMap) {
48
+ const cell = (x: unknown, y: unknown) => { const t = get().tiles; if (!t || typeof x !== 'number' || typeof y !== 'number' || !Number.isInteger(x) || !Number.isInteger(y))
49
+ return -1; const w = get().wrap; if (w?.x)
50
+ x = ((x % t.width) + t.width) % t.width; if (w?.y)
51
+ y = ((y % t.height) + t.height) % t.height; return x < 0 || y < 0 || x >= t.width || y >= t.height ? -1 : y * t.width + x; };
52
+ return {
53
+ tile: (x: unknown, y: unknown) => { charge(4); const id = cell(x, y); return id < 0 ? 1 : get().tiles!.data[id]; },
54
+ distance: (name: unknown, x: unknown, y: unknown) => { charge(4); const id = cell(x, y), f = typeof name === 'string' && get().tiles ? fields.get(get().tiles!)?.get(name) : null; return id < 0 || !f ? -1 : f.distance[id]; },
55
+ flow: (name: unknown, x: unknown, y: unknown) => { charge(8); const id = cell(x, y), t = get().tiles, f = typeof name === 'string' && t ? fields.get(t)?.get(name) : null; if (!t || id < 0 || !f || f.next[id] < 0)
56
+ return undefined; const next = f.next[id], dx = next % t.width - id % t.width, dy = Math.floor(next / t.width) - Math.floor(id / t.width); return Object.freeze({ x: Math.abs(dx) > 1 ? -Math.sign(dx) : dx, y: Math.abs(dy) > 1 ? -Math.sign(dy) : dy, z: 0 }); },
57
+ };
58
+ }
@@ -0,0 +1,22 @@
1
+ /** Periodic coordinates. Half-period ties consistently choose the negative way. */
2
+ export const wrapValue = (value, range) => range ? range[0] + ((value - range[0]) % (range[1] - range[0]) + range[1] - range[0]) % (range[1] - range[0]) : value;
3
+ export const periodicDelta = (a, b, range) => range ? wrapValue(b - a, [-(range[1] - range[0]) / 2, (range[1] - range[0]) / 2]) : b - a;
4
+ export const wrapPoint = (p, wrap) => wrap ? {x: wrapValue(p.x, wrap.x), y: wrapValue(p.y, wrap.y), z: wrapValue(p.z ?? 0, wrap.z)} : p;
5
+ export const relativePoint = (p, at, wrap) => wrap ? {x:at.x+periodicDelta(at.x,p.x,wrap.x),y:at.y+periodicDelta(at.y,p.y,wrap.y),z:(at.z??0)+periodicDelta(at.z??0,p.z??0,wrap.z)} : p;
6
+ export const blendPosition = (a,b,t,wrap) => ({x:wrapValue(a.x+periodicDelta(a.x,b.x,wrap?.x)*t,wrap?.x),y:wrapValue(a.y+periodicDelta(a.y,b.y,wrap?.y)*t,wrap?.y),z:wrapValue(a.z+periodicDelta(a.z,b.z,wrap?.z)*t,wrap?.z)});
7
+ export function boundPosition(p, map, radius, height, dims) {
8
+ const b=map.bounds,w=map.wrap;
9
+ return {x:w?.x?wrapValue(p.x,w.x):Math.max(b.min.x+radius,Math.min(b.max.x-radius,p.x)),y:w?.y?wrapValue(p.y,w.y):Math.max(b.min.y+radius,Math.min(b.max.y-radius,p.y)),z:dims===3?(w?.z?wrapValue(p.z,w.z):Math.max(b.min.z,Math.min(b.max.z-height,p.z))):0};
10
+ }
11
+ /** Stream periodic images intersecting a query's swept bounds; callers meter each image. */
12
+ export function* imageOffsets(bounds, wrap, p, d, radius, height, dims=3) {
13
+ const ranges=['x','y','z'].map(k=>{
14
+ if(!wrap?.[k] || k==='z'&&dims===2)return [0,0,0];
15
+ const period=wrap[k][1]-wrap[k][0];
16
+ const lo=Math.min(p[k],p[k]+d[k])-(k==='z'?0:radius),hi=Math.max(p[k],p[k]+d[k])+(k==='z'?height:radius);
17
+ const first=Math.ceil((lo-bounds.max[k])/period),last=Math.floor((hi-bounds.min[k])/period);
18
+ if(!Number.isSafeInteger(first)||!Number.isSafeInteger(last))throw new RangeError('periodic query exceeds exact integer coordinates');
19
+ return [first,last,period];
20
+ });
21
+ for(let x=ranges[0][0];x<=ranges[0][1];x++)for(let y=ranges[1][0];y<=ranges[1][1];y++)for(let z=ranges[2][0];z<=ranges[2][1];z++)yield {x:x*ranges[0][2],y:y*ranges[1][2],z:z*ranges[2][2]};
22
+ }
package/rules/types.ts CHANGED
@@ -24,11 +24,11 @@ export interface RaySnapshot {sweep(body:{pos:Point;grounded?:boolean},delta:Poi
24
24
  export interface RayQueries { rays(options?:RayOptions):RaySnapshot; ray(from: Point, direction: Point, max: number, options?: RayOptions): RayHit | undefined; rayAll(from: Point, direction: Point, max: number, options?: RayOptions): readonly RayHit[] }
25
25
  export interface EffectAudience { readonly to?: string | readonly string[]; readonly within?: number }
26
26
  export type Area = { sphere: { at: Point; r: number } } | { box: { min: Point; max: Point } } | { cone: { at: Point; dir: Point; r: number; angle: number } };
27
- export interface MapView { readonly name: string; spot(name: string): Vec3 | undefined; spots(name: string): readonly Vec3[] }
27
+ export interface MapView { tile(x:number,y:number):number; distance(flow:string,x:number,y:number):number; flow(name:string,x:number,y:number):Vec3|undefined; readonly name: string; spot(name: string): Vec3 | undefined; spots(name: string): readonly Vec3[] }
28
28
  export interface Clock { readonly tick: number; readonly dt: number; ticks(seconds: number): number; readonly math: typeof math }
29
29
  export interface Round { readonly n: number; readonly phase: 'live' | 'over'; readonly endsAt: number }
30
30
  export type World<E, F, S, V, C, A, T, Room extends boolean = false> = Clock & {
31
- readonly tune: ReadonlyState<T>; readonly map: MapView; readonly stage: string; readonly level: number; readonly levelMax: number; readonly guideLevel: number; readonly guideSeats: readonly number[]; readonly kids: boolean; readonly levelSet: boolean;
31
+ readonly tune: ReadonlyState<T>; readonly map: MapView & (Room extends true ? {replace(data:Record<string,unknown>):void} : {}); readonly stage: string; readonly level: number; readonly levelMax: number; readonly guideLevel: number; readonly guideSeats: readonly number[]; readonly kids: boolean; readonly levelSet: boolean;
32
32
  readonly shared: Room extends true ? S : ReadonlyState<S>;
33
33
  readonly round: Round & (Room extends true ? { end(): void; restart(): void } : {});
34
34
  /** Budget units the running handler may still spend (0.46.3). */