@homie-rocks/studio 0.45.1 → 0.45.3

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.
package/rules/math.ts CHANGED
@@ -14,7 +14,7 @@ import { collisionQueries } from './live.ts';
14
14
  * Every call is charged 4 units to the running handler's budget (guard.ts), beside what any guarded call costs.
15
15
  * =============================================================================
16
16
  */
17
- import { castMap3, restsOnMap, type BodyShape } from './collision.ts';
17
+ import { castMap3, castSolid, restsOnMap, type BodyShape } from './collision.ts';
18
18
  import { brand, charge, own, put } from './guard.ts';
19
19
  import { num } from './pack.ts';
20
20
 
@@ -237,12 +237,24 @@ function rayBounds(px: number, py: number, dx: number, dy: number, b: { min: Vec
237
237
  return best;
238
238
  }
239
239
  /** A circle (or a point, r 0) moved along d against the static map: the first thing in the way, and how many shapes were tested. */
240
- export function castMap(map: MapShapes, px: number, py: number, dx: number, dy: number, r: number): { hit: Hit | null; tested: number } {
240
+ export function castMap(map: MapShapes, px: number, py: number, dx: number, dy: number, r: number, hits?: Hit[], includeInside = false, square = false): { hit: Hit | null; tested: number } {
241
241
  map = nearbyMap(map, {x:px,y:py,z:0}, {x:dx,y:dy,z:0}, r, 0, 2);
242
242
  charge(16 + 4 * (map.boxes.length + map.circles.length));
243
243
  let best: Hit | null = rayBounds(Math.max(map.bounds.min.x + r, Math.min(map.bounds.max.x - r, px)), Math.max(map.bounds.min.y + r, Math.min(map.bounds.max.y - r, py)), dx, dy, map.bounds, r);
244
- for (const b of map.boxes) { const h = rayBox(px, py, dx, dy, b.min, b.max, r); if (h && (!best || h.t < best.t)) best = { ...h, id: (b as any).id }; }
245
- for (const c of map.circles) { const h = rayCircle(px, py, dx, dy, c.at.x, c.at.y, c.r + r); if (h && (!best || h.t < best.t)) best = { ...h, id: (c as any).id }; }
244
+ if (hits && best) hits.push(best);
245
+ const take=(h: Hit | null,id?:string): void => {if(!h)return;const hit={...h,...(id?{id}:{})};if(hits)hits.push(hit);if(!best||h.t<best.t)best=hit;};
246
+ const length=Math.sqrt(dx*dx+dy*dy)||1;
247
+ const inside: Hit={t:0,nx:-dx/length,ny:-dy/length};
248
+ for (const b of map.boxes) {
249
+ const min=square?{x:b.min.x-r,y:b.min.y-r,z:0}:b.min,max=square?{x:b.max.x+r,y:b.max.y+r,z:0}:b.max;
250
+ const embedded=includeInside&&px>min.x&&px<max.x&&py>min.y&&py<max.y;
251
+ take(embedded?inside:rayBox(px,py,dx,dy,min,max,square?0:r),(b as {id?:string}).id);
252
+ }
253
+ for (const c of map.circles) {
254
+ const embedded=includeInside&&(px-c.at.x)*(px-c.at.x)+(py-c.at.y)*(py-c.at.y)<(c.r+r)*(c.r+r);
255
+ const h=square?castSolid({min:{x:px-r,y:py-r,z:0},max:{x:px+r,y:py+r,z:1},r:0},{x:dx,y:dy,z:0},{min:{x:c.at.x,y:c.at.y,z:-1},max:{x:c.at.x,y:c.at.y,z:2},r:c.r},includeInside):embedded?inside:rayCircle(px,py,dx,dy,c.at.x,c.at.y,c.r+r);
256
+ take(h,(c as {id?:string}).id);
257
+ }
246
258
  return { hit: best, tested: 4 + map.boxes.length + map.circles.length };
247
259
  }
248
260
 
@@ -251,7 +263,7 @@ export function castMap(map: MapShapes, px: number, py: number, dx: number, dy:
251
263
  * and a browser both move a body with this, so they agree to the last bit. Returns nothing, or `{ at, normal }`.
252
264
  * The 2D path charges 20 units and 4 per shape; 3D also charges each convex cast and terrain tile. With `dims` 2 a body always rests on the ground.
253
265
  */
254
- export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius: number, dims: number, shape: BodyShape = { shape: 'sphere', radius, height: 2 * radius }): unknown {
266
+ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius: number, dims: number, shape: BodyShape = { shape: 'sphere', radius, height: 2 * radius }, options?: unknown): unknown {
255
267
  // Charged before the cast, for every shape of the map it may test.
256
268
  charge(20);
257
269
  const fr = Math.fround;
@@ -267,7 +279,7 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
267
279
  const length = Math.sqrt(dx * dx + dy * dy + d.z * d.z);
268
280
  const t = h ? Math.max(0, h.t - (length > 0 ? SKIN / length : 0)) : 1;
269
281
  const at = v(fr(px + dx * t), fr(py + dy * t), fr(p.z + d.z * t));
270
- put(body, 'pos', at); put(body, 'grounded', restsOnMap(map, at, shape));
282
+ put(body, 'pos', at); if (own(options, 'ground') !== false) put(body, 'grounded', restsOnMap(map, at, shape));
271
283
  return h ? Object.freeze({ at, normal: v(h.nx, h.ny, h.nz), ...(h.id ? { entity: h.id } : {}) }) : undefined;
272
284
  }
273
285
  const { hit: h } = castMap(map, px, py, dx, dy, radius);
@@ -285,9 +297,9 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
285
297
  * map. One function, so that the browser (view.ts) and the person the build check plays hold the same one. The
286
298
  * server's own (core.ts `moveCtx`) is made beside the rest of its world, and charges as that does.
287
299
  */
288
- 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 }): unknown {
300
+ 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 {
289
301
  const none = Object.freeze([]);
290
- const queries = collisionQueries(() => o.geometry?.() ?? o.map, () => o.shape?.() ?? {shape: 'sphere', radius: o.radius(), height: 2 * o.radius()}, o.dims);
302
+ const queries = collisionQueries(() => o.geometry?.() ?? o.map, () => o.shape?.() ?? {shape: 'sphere', radius: o.radius(), height: 2 * o.radius()}, o.dims, o.self);
291
303
  return brand(Object.freeze({
292
304
  get tick() { return o.tick(); }, dt: 1 / o.tickHz, tune: o.tune, math,
293
305
  // As the server's `ctx.ticks` reads it: a plain number, or nothing.
@@ -0,0 +1,69 @@
1
+ /** Deterministic routing over an authored, baked walking graph. Full searches
2
+ * either reach the goal or return no path; never silently truncate a route. */
3
+ import {own,charge} from './guard.ts';
4
+ import type {Solid} from './collision.ts';
5
+ import {castSolid} from './collision.ts';
6
+ export type RouteNode = readonly [number,number,number,readonly number[]];
7
+ const graphs = new WeakMap<object,readonly RouteNode[]>();
8
+ const incoming = new WeakMap<object,readonly (readonly number[])[]>();
9
+ function graphOf(value: unknown, billing=true): readonly RouteNode[] {
10
+ if(!Array.isArray(value)||value.length>8192)throw new Error('route graph has at most 8192 nodes');
11
+ if(billing)charge(value.length*12);const cached=graphs.get(value);if(cached)return cached;
12
+ const nodes:RouteNode[]=[];
13
+ for(let i=0;i<value.length;i++){
14
+ const n=own(value,i),links=own(n,3),x=own(n,0),y=own(n,1),z=own(n,2);
15
+ if(![x,y,z].every(v=>typeof v==='number'&&Number.isFinite(v))||!Array.isArray(links)||links.length>16)throw new Error('route nodes are [x,y,z,links], at most 16 neighbours');
16
+ const adjacent:number[]=[];for(let j=0;j<links.length;j++){const id=own(links,j);if(typeof id!=='number'||!Number.isInteger(id)||id<0||id>=value.length)throw new Error('route links name graph nodes');adjacent.push(id);}
17
+ nodes.push(Object.freeze([x as number,y as number,z as number,Object.freeze(adjacent)]));
18
+ }
19
+ const result=Object.freeze(nodes);const reverse:number[][]=nodes.map(()=>[]);nodes.forEach((n,i)=>{for(const next of n[3])reverse[next].push(i);});incoming.set(result,reverse);if(Object.isFrozen(value)&&value.every(n=>Object.isFrozen(n)&&Object.isFrozen(own(n,3))))graphs.set(value,result);return result;
20
+ }
21
+ export const prepareGraph=(value:unknown):readonly RouteNode[]=>graphOf(value,false);
22
+ export function routeGraph(value: unknown, origin: unknown, destination: unknown, solids: readonly Solid[], radius: number, height: number, prepared=false): readonly number[] {
23
+ const nodes=prepared?value as readonly RouteNode[]:graphOf(value);if(prepared)charge(16);
24
+ if(!Number.isInteger(origin)||!Number.isInteger(destination)||Number(origin)<0||Number(destination)<0||Number(origin)>=nodes.length||Number(destination)>=nodes.length)throw new Error('route endpoints name graph nodes');
25
+ let start=Number(origin),goal=Number(destination),end=nodes[goal];
26
+ const buckets=new Map<string,Solid[]>();
27
+ for(const solid of solids){const minX=Math.floor((solid.min.x-solid.r-radius)/4),maxX=Math.floor((solid.max.x+solid.r+radius)/4),minY=Math.floor((solid.min.y-solid.r-radius)/4),maxY=Math.floor((solid.max.y+solid.r+radius)/4);
28
+ if((maxX-minX+1)*(maxY-minY+1)>65536)throw new Error('route obstacle spans too many graph cells');
29
+ for(let x=minX;x<=maxX;x++)for(let y=minY;y<=maxY;y++){charge(2);const key=x+','+y;const rows=buckets.get(key)??[];rows.push(solid);buckets.set(key,rows);}
30
+ }
31
+ const blocked=(a:RouteNode,b:RouteNode)=>{
32
+ const from={min:{x:a[0]-radius,y:a[1]-radius,z:a[2]+.05},max:{x:a[0]+radius,y:a[1]+radius,z:a[2]+height},r:0},delta={x:b[0]-a[0],y:b[1]-a[1],z:b[2]-a[2]};
33
+ const candidates=new Set<Solid>();
34
+ for(let x=Math.floor(Math.min(a[0],b[0])/4);x<=Math.floor(Math.max(a[0],b[0])/4);x++)for(let y=Math.floor(Math.min(a[1],b[1])/4);y<=Math.floor(Math.max(a[1],b[1])/4);y++){charge(1);for(const solid of buckets.get(x+','+y)??[])candidates.add(solid);}
35
+ for(const s of candidates){charge(2);if(Math.max(a[0],b[0])+radius<s.min.x-s.r||Math.min(a[0],b[0])-radius>s.max.x+s.r||Math.max(a[1],b[1])+radius<s.min.y-s.r||Math.min(a[1],b[1])-radius>s.max.y+s.r||Math.max(a[2],b[2])+height<s.min.z-s.r||Math.min(a[2],b[2])+.05>s.max.z+s.r)continue;if(s.r===0){
36
+ charge(24);let near=0,far=1,hit=true;
37
+ for(const axis of ['x','y','z'] as const){const low=s.min[axis]-from.max[axis],high=s.max[axis]-from.min[axis],speed=delta[axis];if(!speed){if(low>=0||high<=0){hit=false;break;}}else{let a=low/speed,b=high/speed;if(a>b)[a,b]=[b,a];near=Math.max(near,a);far=Math.min(far,b);if(near>far){hit=false;break;}}}
38
+ if(hit&&far>=0)return true;
39
+ }else if(castSolid(from,delta,s,true))return true;}
40
+ return false;
41
+ };
42
+ const endpoint=(id:number,limit:number)=>{
43
+ if(!blocked(nodes[id],nodes[id]))return id;
44
+ const p=nodes[id];let best=-1,score=Infinity;
45
+ for(let i=0;i<nodes.length;i++){charge(4);const q=nodes[i],distance=Math.hypot(q[0]-p[0],q[1]-p[1]);const cost=distance+Math.abs(q[2]-p[2])*2;if(distance>limit||cost>=score||!q[3].length||blocked(q,q))continue;best=i;score=cost;}
46
+ return best;
47
+ };
48
+ start=endpoint(start,4);goal=endpoint(goal,Infinity);if(start<0||goal<0)return Object.freeze([]);end=nodes[goal];
49
+ type Entry={id:number;f:number;order:number};let sequence=0;
50
+ const before=(a:Entry,b:Entry)=>a.f<b.f||a.f===b.f&&a.order<b.order;
51
+ const push=(heap:Entry[],id:number,f:number)=>{const item={id,f,order:sequence++};let i=heap.length;heap.push(item);while(i){const p=(i-1)>>1;if(!before(item,heap[p]))break;heap[i]=heap[p];i=p;}heap[i]=item;};
52
+ const pop=(heap:Entry[])=>{const first=heap[0],last=heap.pop()!;if(heap.length){let i=0;while(i*2+1<heap.length){let j=i*2+1;if(j+1<heap.length&&before(heap[j+1],heap[j]))j++;if(!before(heap[j],last))break;heap[i]=heap[j];i=j;}heap[i]=last;}return first.id;};
53
+ const originPoint=nodes[start],potential=(id:number)=>(Math.hypot(nodes[id][0]-end[0],nodes[id][1]-end[1])-Math.hypot(nodes[id][0]-originPoint[0],nodes[id][1]-originPoint[1]))/2;
54
+ const heaps:Entry[][]=[[],[]],costs=[new Map([[start,0]]),new Map([[goal,0]])],parents=[new Map<number,number>(),new Map<number,number>()],closed=[new Set<number>(),new Set<number>()],reverse=incoming.get(nodes)!;
55
+ push(heaps[0],start,potential(start));push(heaps[1],goal,-potential(goal));let best=Infinity,meet=-1;
56
+ // Balanced bidirectional A*: either frontier can prove disconnection. No
57
+ // expansion limit, and every returned route connects the two endpoints.
58
+ while(heaps[0].length&&heaps[1].length){
59
+ if(heaps[0][0].f+heaps[1][0].f>=best)break;
60
+ const side=heaps[0][0].f<=heaps[1][0].f?0:1,other=1-side,current=pop(heaps[side]);charge(12);if(closed[side].has(current))continue;closed[side].add(current);
61
+ const cost=costs[side].get(current)!,opposite=costs[other].get(current);if(opposite!==undefined&&cost+opposite<best){best=cost+opposite;meet=current;}
62
+ for(const next of side?reverse[current]:nodes[current][3]){charge(4);if(closed[side].has(next))continue;const a=nodes[side?next:current],b=nodes[side?current:next];if(blocked(a,b))continue;
63
+ const score=cost+Math.hypot(b[0]-a[0],b[1]-a[1])+Math.max(0,b[2]-a[2])*.3;if(score>=(costs[side].get(next)??Infinity))continue;
64
+ costs[side].set(next,score);parents[side].set(next,current);push(heaps[side],next,score+(side?-potential(next):potential(next)));
65
+ const otherCost=costs[other].get(next);if(otherCost!==undefined&&score+otherCost<best){best=score+otherCost;meet=next;}
66
+ }
67
+ }
68
+ if(meet<0)return Object.freeze([]);const path=[meet];while(path[0]!==start)path.unshift(parents[0].get(path[0])!);while(path[path.length-1]!==goal)path.push(parents[1].get(path[path.length-1])!);charge(path.length);return Object.freeze(path);
69
+ }
package/rules/pack.ts CHANGED
@@ -21,7 +21,7 @@ import { cellsOf } from './rules.ts';
21
21
  import type { Field, FieldList, Vec3 } from './rules.ts';
22
22
  import { G, charge, keyCount, own, refusedName } from './guard.ts';
23
23
 
24
- const RANGE: Record<string, [number, number]> = { u8: [0, 255], u16: [0, 65535], u32: [0, 4294967295], i8: [-128, 127], i16: [-32768, 32767], i32: [-2147483648, 2147483647], tick: [0, 4294967295], ticks: [0, 4294967295] };
24
+ const RANGE: Record<string, [number, number]> = { pulse: [0, 255], u8: [0, 255], u16: [0, 65535], u32: [0, 4294967295], i8: [-128, 127], i16: [-32768, 32767], i32: [-2147483648, 2147483647], tick: [0, 4294967295], ticks: [0, 4294967295] };
25
25
  /** `f.fix`: steps of 1/4096, to plus or minus 2^31 (a tick count with a fraction fits for the life of any room). */
26
26
  export const FIX_STEP = 4096;
27
27
  const FIX_MAX = 2147483648;
@@ -262,7 +262,7 @@ export interface MoveBody { pos: Vec3; vel: Vec3; heading: Vec3; grounded: boole
262
262
  * `used` is the units it took. What it throws goes to `failed` (the budget's own stop too) and the body is still read.
263
263
  */
264
264
  export function stepMove(fn: (body: MoveBody, input: unknown, ctx: unknown) => void, body: MoveBody, input: Readonly<Record<string, unknown>>, ctx: unknown, quota: number, motion: FieldList, dims: number, failed: (error: unknown) => void): MoveBody & { used: number } {
265
- G.left = quota;
265
+ G.queryEpoch++; G.left = quota;
266
266
  const bag = coerceFields(motion, body.motion, dims);
267
267
  const target: Record<string, unknown> = {};
268
268
  const work: Record<string, unknown> = {};
package/rules/query.ts ADDED
@@ -0,0 +1,103 @@
1
+ /** Bounded, data-only ray policy. Shared by rules and predicted geometry queries. */
2
+ import { charge, brand, plainData, put } from './guard.ts';
3
+ import { own, dir, vec3 } from './pack.ts';
4
+ import { castMap } from './math.ts';
5
+ import type { MapShapes } from './math.ts';
6
+ import { castMap3, castSolid, solidAt, type Solid } from './collision.ts';
7
+ import { RAY_REACH_M, type Vec3, type QueryDef } from './rules.ts';
8
+ import type { RayHit } from './types.ts';
9
+ export interface QueryTarget { at?: Vec3; id: string; kind: string; query?: QueryDef; fields: Record<string, unknown>; geometry: boolean; solid: Solid; parts?: readonly {name: string; solid: Solid}[] }
10
+ export function rayQuery(map: MapShapes, targets: readonly QueryTarget[], dims: number, from: unknown, direction: unknown, max: unknown, options?: unknown, self?: string, all = false, includeInside=true): readonly RayHit[] {
11
+ charge(32 + 8 * targets.length);
12
+ if (typeof max !== 'number' || !Number.isFinite(max) || max < 0 || max > RAY_REACH_M) throw new Error(`ray reaches 0 to ${RAY_REACH_M} metres`);
13
+ const p = vec3(from, dims), d = dir(direction,dims), delta = {x:d.x*max,y:d.y*max,z:d.z*max};
14
+ const radiusOption=own(options,'radius'), shapeOption=own(options,'shape');
15
+ const radius=radiusOption===undefined?0:radiusOption;
16
+ if(typeof radius!=='number'||!Number.isFinite(radius)||radius<0||radius>100)throw new Error('ray.radius is 0 to 100 metres');
17
+ if(shapeOption!==undefined && !['sphere','box','capsule'].includes(shapeOption as string))throw new Error('ray.shape is sphere, box or capsule');
18
+ const heightOption=own(options,'height'),height=heightOption===undefined?2*radius:heightOption;
19
+ if(typeof height!=='number'||!Number.isFinite(height)||height<2*radius||height>200)throw new Error('ray.height is at least twice its radius and at most 200 metres');
20
+ const terrainOption=own(options,'terrain');
21
+ if(terrainOption!==undefined && terrainOption!=='exact')throw new Error("ray.terrain is 'exact' or omitted");
22
+ const shape={terrain:terrainOption as 'exact' | undefined,shape:shapeOption==='box'?'box':shapeOption==='capsule'?'capsule':'sphere',radius,height:shapeOption==='sphere'||shapeOption===undefined?2*radius:height};
23
+ const start={x:p.x,y:p.y,z:p.z-shape.height/2};
24
+ const ignore = own(options,'ignore');
25
+ if (ignore !== undefined && (!Array.isArray(ignore) || ignore.length > 16)) throw new Error('ray.ignore holds at most 16 entity refs');
26
+ const excluded = new Set<string>();
27
+ if (Array.isArray(ignore)) for (let i=0;i<ignore.length;i++) { const id=own(ignore,i); if(typeof id!=='string') throw new Error('ray.ignore needs entity refs'); excluded.add(id); }
28
+ if (self && own(options,'ignoreSelf') !== false) excluded.add(self);
29
+ const profile=own(options,'profile');
30
+ const layer=own(options,'layer'), kind=own(options,'kind'), tag=own(options,'tag');
31
+ for(const v of [layer,kind,tag,profile]) if(v!==undefined && (typeof v!=='string' || v.length>32)) throw new Error('ray filters are short names');
32
+ const where=own(options,'where');
33
+ if(where!==undefined && (!where || typeof where!=='object' || Array.isArray(where))) throw new Error('ray.where is a field map');
34
+ const keys=where ? Object.keys(where) : [];
35
+ if(keys.length>16) throw new Error('ray.where holds at most 16 field tests');
36
+ const hits: RayHit[]=[];
37
+ const take=(h: {t:number;nx:number;ny:number;nz?:number}|null,id?:string,part?:string):void=>{
38
+ if(!h)return; charge(32);
39
+ 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
+ };
42
+ if(own(options,'entitiesOnly')!==true && (layer===undefined || layer==='geometry')) {
43
+ 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
+ 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
+ const point=solidAt(start,shape);
47
+ for(const target of targets) {
48
+ 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
+ const declared = target.at ? queryParts(target.at,target.query,profile as string | undefined) : target.parts;
50
+ const parts=(own(options,'geometryOnly')!==true && declared?.length?declared:[{name:'',solid:target.solid}]).filter(part=>{
51
+ charge(12);let near=0,far=1;const radius=point.r+part.solid.r;
52
+ for(const k of dims===3?['x','y','z'] as const:['x','y'] as const){const lo=part.solid.min[k]-point.max[k]-radius,hi=part.solid.max[k]-point.min[k]+radius,v=delta[k];if(v===0){if(lo>1e-9||hi< -1e-9)return false;}else{let a=lo/v,b=hi/v;if(a>b)[a,b]=[b,a];near=Math.max(near,a);far=Math.min(far,b);if(near>far+1e-9)return false;}}
53
+ return true;
54
+ });
55
+ if(!parts.length)continue;
56
+ let match=true;
57
+ for(const key of keys) {
58
+ charge(32); const actual=own(target.fields,key), condition=own(where,key);
59
+ if(condition!==null && typeof condition==='object') {
60
+ const operators=Object.keys(condition); if(operators.length>5)throw new Error('ray comparison holds at most five operators');
61
+ for(const op of operators) {
62
+ if(!['eq','gt','gte','lt','lte'].includes(op)) throw new Error('ray.where supports eq, gt, gte, lt, lte');
63
+ const n=own(condition,op);
64
+ if(op==='eq') {if(actual!==n)match=false;}
65
+ else {if(typeof n!=='number'||!Number.isFinite(n))throw new Error('ray comparison needs a finite number');if(typeof actual!=='number'|| !(op==='gt'?actual>n:op==='gte'?actual>=n:op==='lt'?actual<n:actual<=n))match=false;}
66
+ }
67
+ } else if(actual!==condition)match=false;
68
+ }
69
+ if(!match)continue;
70
+ for(const part of parts) {
71
+ const s=part.solid;
72
+ const h=dims===3?castSolid(point,delta,s,includeInside):castSolid(
73
+ {...point,min:{...point.min,z:0},max:{...point.max,z:1}}, {...delta,z:0},
74
+ {...s,min:{...s.min,z:-1},max:{...s.max,z:2}},includeInside);
75
+
76
+ take(h,target.id,part.name||undefined);
77
+ }
78
+ }
79
+ charge(hits.length * (8 + Math.ceil(Math.log2(hits.length+1))));
80
+ // Stable ties retain static-map order, then entity insertion order and declaration part order.
81
+ hits.sort((a,b)=>a.dist-b.dist);
82
+ return Object.freeze(all?hits:hits.slice(0,1));
83
+ }
84
+ export function queryParts(at: Vec3, query?: QueryDef, profile?: string): QueryTarget['parts'] {
85
+ const parts=profile && query?.profiles?.[profile] ? query.profiles[profile] : query?.parts;
86
+ if(!parts)return undefined;
87
+ charge(24*Object.keys(parts).length);
88
+ return Object.entries(parts).map(([name,p])=>({name,solid:solidAt({x:at.x+(p.offset?.x??0),y:at.y+(p.offset?.y??0),z:at.z+(p.offset?.z??0)},{shape:p.shape,radius:p.radius,height:p.height??2*p.radius})}));
89
+ }
90
+
91
+ /** A handler-local immutable query scene. Target construction is paid once;
92
+ * every individual ray still pays for its candidates and geometry traversal. */
93
+ export function raySnapshot(map:MapShapes,targets:readonly QueryTarget[],dims:number,options:unknown,self?:string){
94
+ const policy=plainData(options??{}, {n:1024}, 6);
95
+ const scene=targets.map(t=>{charge(24+4*Object.keys(t.fields).length);return {...t,at:t.at?{...t.at}:undefined,fields:Object.fromEntries(Object.entries(t.fields).filter(([,v])=>typeof v==='number'||typeof v==='string'||typeof v==='boolean'))};});
96
+ const ray=(from:unknown,direction:unknown,max:unknown)=>rayQuery(map,scene,dims,from,direction,max,policy,self)[0];
97
+ 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
+ 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;};
100
+ 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;};
102
+ 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
+ }
package/rules/rules.ts CHANGED
@@ -1,4 +1,7 @@
1
- import { prepareTile } from './terrain.ts';
1
+ import {prepareGraph,type RouteNode} from './navigation.ts';
2
+ import { prepareTile as prepareExactTile } from './terrain.ts';
3
+ import { prepareTile as prepareLegacyTile } from './terrain-legacy.ts';
4
+ const prepareTile = (tile: MapHeightTile) => { prepareExactTile(tile); prepareLegacyTile(tile); };
2
5
  import { indexMap } from './map-index.ts';
3
6
  /*
4
7
  * rules.ts — the rules contract, version 2: what a rules module declares, and how the runtime reads it.
@@ -37,6 +40,8 @@ export const RULES_CONTRACT = 2;
37
40
  export const SAVE_REVISION = 2;
38
41
  /** How far a query, a ray or an area event reaches, in metres. */
39
42
  export const REACH_M = 64;
43
+ /** Rays visit indexed geometry, rather than a radius-sized entity neighbourhood. */
44
+ export const RAY_REACH_M = 10000;
40
45
  /** The most entities one room holds. A spawn past it throws in the handler that asked. */
41
46
  export const ENTITY_MAX = 2048;
42
47
  /** The current bot-fill ceiling; public admission lives in worker/seats.mjs. */
@@ -49,13 +54,14 @@ export const QUEUE_MAX = 16_384;
49
54
 
50
55
  /* ------------------------------------------------------------------ field types */
51
56
 
52
- export type FieldKind = 'u8' | 'u16' | 'u32' | 'i8' | 'i16' | 'i32' | 'bit' | 'fix' | 'vec3' | 'dir' | 'tick' | 'ticks' | 'ref' | 'text' | 'list' | 'map' | 'struct' | 'press';
57
+ export type FieldKind = 'u8' | 'u16' | 'u32' | 'i8' | 'i16' | 'i32' | 'bit' | 'fix' | 'vec3' | 'dir' | 'tick' | 'ticks' | 'ref' | 'text' | 'list' | 'map' | 'struct' | 'press' | 'pulse';
53
58
  export interface Field {
54
59
  readonly t: FieldKind;
55
60
  /** The value a new entity starts with. */
56
61
  readonly init?: unknown;
57
62
  /** On one integer field of a player's body: this is the score a round's results rank by. */
58
63
  readonly score?: true;
64
+ readonly visibility?: 'owner' | 'server' | 'results';
59
65
  /** `text`: the most characters. `list` and `map`: the most entries. */
60
66
  readonly max?: number;
61
67
  /** `list` and `map`: what each entry is. */
@@ -63,11 +69,11 @@ export interface Field {
63
69
  /** `struct`: its fields, in order. */
64
70
  readonly fields?: Readonly<Record<string, Field>>;
65
71
  }
66
- export interface FieldOptions { init?: unknown; score?: boolean }
72
+ export interface FieldOptions { init?: unknown; score?: boolean; visibility?: 'owner' | 'server' | 'results' }
67
73
  export type Fields = Readonly<Record<string, Field>>;
68
74
 
69
75
  const made = (t: FieldKind, o: FieldOptions | undefined, extra: Partial<Field> = {}): Field =>
70
- Object.freeze({ t, ...extra, ...(o && o.init !== undefined ? { init: o.init } : {}), ...(o && o.score === true ? { score: true as const } : {}) });
76
+ Object.freeze({ t, ...extra, ...(o?.visibility ? {visibility:o.visibility} : {}), ...(o && o.init !== undefined ? { init: o.init } : {}), ...(o && o.score === true ? { score: true as const } : {}) });
71
77
  const plain = (t: FieldKind) => (o?: FieldOptions): Field => made(t, o);
72
78
 
73
79
  /**
@@ -85,6 +91,7 @@ export const f = brand(Object.freeze({
85
91
  map: (of: Field, max: number, o?: FieldOptions): Field => made('map', o, { of, max }),
86
92
  struct: (fields: Fields, o?: FieldOptions): Field => made('struct', o, { fields: Object.freeze({ ...fields }) }),
87
93
  press: (): Field => made('press', undefined),
94
+ pulse: (): Field => made('pulse', undefined),
88
95
  }));
89
96
 
90
97
  /**
@@ -125,16 +132,20 @@ export type Handler = (world: RuntimeWorld, self: RuntimeSelf, e?: any) => void;
125
132
  export type RoomHandler = (world: RuntimeWorld, e?: any) => void;
126
133
  export type MoveFn = (body: any, input: any, ctx: any) => void;
127
134
 
128
- export type ColliderDef = true | { size?: string; enabled?: string };
129
- export interface BodyDef { shape: 'circle' | 'sphere' | 'capsule' | 'box'; radius: number; height?: number; maxSpeed: number; sweep?: boolean; move?: 'owner' }
135
+ export type ColliderDef = true | { size?: string; enabled?: string; fields?: readonly string[] };
136
+ export interface HitRegion { shape: 'sphere' | 'capsule' | 'box'; radius: number; height?: number; offset?: Vec3 }
137
+ export interface QueryDef { layer?: string; tags?: readonly string[]; parts?: Record<string, HitRegion>; profiles?: Record<string, Record<string, HitRegion>> }
138
+ export interface BodyDef { shape: 'circle' | 'sphere' | 'capsule' | 'box'; radius: number; height?: number; maxSpeed: number; sweep?: boolean; move?: 'owner'; terrain?: 'exact' }
130
139
  export interface GuideDef { view: (world: RuntimeWorld, self: RuntimeSelf) => unknown; floor?: (world: RuntimeWorld, self: RuntimeSelf, view: any) => unknown }
140
+ export interface RoundRecords {key:string; identity:string; eligible?:string; fields:Readonly<Record<string,string>>}
131
141
  export interface EntityDef {
132
- player?: true | { away?: 'neutral' | 'think'; leave?: 'despawn' | 'bot' };
142
+ player?: true | { away?: 'neutral' | 'think'; leave?: 'despawn' | 'bot'; control?: string; takeover?: string };
133
143
  fields?: Fields;
134
144
  motion?: Fields;
135
145
  input?: Fields;
136
146
  body?: BodyDef;
137
147
  collider?: ColliderDef;
148
+ query?: QueryDef;
138
149
  guide?: GuideDef;
139
150
  tick?: Handler;
140
151
  think?: (world: RuntimeWorld, self: RuntimeSelf) => Record<string, unknown>;
@@ -153,11 +164,14 @@ export interface RulesDef {
153
164
  room?: {
154
165
  rounds?: { seconds: number; breakSeconds: number };
155
166
  bots?: { keep: number };
167
+ records?: RoundRecords;
168
+ historySeconds?: number;
156
169
  start?: RoomHandler;
157
170
  join?: (ctx: any, player: { seat: number; driver: 'person' | 'bot' | 'ai'; owner: string }) => { kind: string; at: Vec3; heading?: Vec3; fields?: Record<string, unknown>; motion?: Record<string, unknown> };
158
171
  on?: Record<string, RoomHandler>;
159
172
  };
160
173
  asks?: Record<string, AskDef>;
174
+ navigation?: Record<string,readonly RouteNode[]>;
161
175
  map?: string;
162
176
  }
163
177
 
@@ -184,7 +198,7 @@ export interface RoomSettings {
184
198
  view: { radiusM: number | null; precisionM?: number; nearM?: number | null; farHz?: number | null };
185
199
  durability: { movementSeconds: number };
186
200
  budget: { tick: number };
187
- predict: { catchM: number | null; catchUp: number; snapM: number | null; blendMs: number; interpMs: number | null };
201
+ predict: { idleHold?: boolean; catchM: number | null; catchUp: number; snapM: number | null; blendMs: number; interpMs: number | null };
188
202
  }
189
203
  /**
190
204
  * The default `budget.tick`: 500,000 units at 20 ticks a second or fewer, and less at a faster rate, so a second of
@@ -238,7 +252,7 @@ export function roomSettings(raw: unknown): { settings: RoomSettings; problems:
238
252
  view: { radiusM: r.view?.radiusM === null ? null : num('view.radiusM', r.view?.radiusM, null), ...(r.view?.precisionM !== undefined ? { precisionM: num('view.precisionM', r.view.precisionM, 0) as number } : {}), ...(r.view?.nearM !== undefined ? { nearM: num('view.nearM', r.view.nearM, null) } : {}), ...(r.view?.farHz !== undefined ? { farHz: whole('view.farHz', r.view.farHz, 1, tickHz, tickHz) } : {}) },
239
253
  durability: { movementSeconds: whole('durability.movementSeconds', r.durability?.movementSeconds, 1, 60, 1) },
240
254
  budget: { tick: Math.max(1, Math.floor(num('budget.tick', r.budget?.tick, budgetFor(tickHz)) as number)) },
241
- predict: { catchM: num('predict.catchM', p.catchM, null), catchUp: num('predict.catchUp', p.catchUp, 1.25) as number, snapM: num('predict.snapM', p.snapM, null), blendMs: num('predict.blendMs', p.blendMs, 100) as number, interpMs: num('predict.interpMs', p.interpMs, null) },
255
+ predict: { ...(p.idleHold === true ? {idleHold:true} : {}), catchM: num('predict.catchM', p.catchM, null), catchUp: num('predict.catchUp', p.catchUp, 1.25) as number, snapM: num('predict.snapM', p.snapM, null), blendMs: num('predict.blendMs', p.blendMs, 100) as number, interpMs: num('predict.interpMs', p.interpMs, null) },
242
256
  },
243
257
  problems,
244
258
  };
@@ -321,12 +335,13 @@ export type FieldList = readonly (readonly [string, Field])[];
321
335
  export interface KindTable {
322
336
  name: string;
323
337
  index: number;
324
- player: null | { away: 'neutral' | 'think'; leave: 'despawn' | 'bot' };
338
+ player: null | { away: 'neutral' | 'think'; leave: 'despawn' | 'bot'; control?: string; takeover?: string };
325
339
  fields: FieldList;
326
340
  motion: FieldList;
327
341
  input: FieldList;
328
342
  collider?: ColliderDef;
329
- body: null | { shape: string; radius: number; height: number; maxSpeed: number; sweep: boolean; owner: boolean };
343
+ query?: QueryDef;
344
+ body: null | { shape: string; radius: number; height: number; maxSpeed: number; sweep: boolean; owner: boolean; terrain?: 'exact' };
330
345
  score: string | null;
331
346
  tick: Handler | null;
332
347
  think: EntityDef['think'] | null;
@@ -337,6 +352,7 @@ export interface KindTable {
337
352
  move: MoveFn | null;
338
353
  }
339
354
  export interface Compiled {
355
+ navigation: Readonly<Record<string,readonly RouteNode[]>>;
340
356
  contract: 2;
341
357
  /** `SAVE_REVISION`, so that what hashes these declarations hashes it too. */
342
358
  save: number;
@@ -349,6 +365,8 @@ export interface Compiled {
349
365
  effectNames: string[];
350
366
  shared: FieldList;
351
367
  view: FieldList;
368
+ records?: RoundRecords;
369
+ historySeconds?: number;
352
370
  rounds: { seconds: number; breakSeconds: number } | null;
353
371
  bots: number;
354
372
  start: RoomHandler | null;
@@ -371,7 +389,7 @@ export const BUILT_IN_FIELDS = Object.freeze(['id', 'kind', 'pos', 'vel', 'headi
371
389
 
372
390
  const NAME = /^[a-z][A-Za-z0-9_]{0,31}$/;
373
391
  const INTS = new Set(['u8', 'u16', 'u32', 'i8', 'i16', 'i32']);
374
- const TYPES = new Set(['u8', 'u16', 'u32', 'i8', 'i16', 'i32', 'bit', 'fix', 'vec3', 'dir', 'tick', 'ticks', 'ref', 'text', 'list', 'map', 'struct', 'press']);
392
+ const TYPES = new Set(['u8', 'u16', 'u32', 'i8', 'i16', 'i32', 'bit', 'fix', 'vec3', 'dir', 'tick', 'ticks', 'ref', 'text', 'list', 'map', 'struct', 'press', 'pulse']);
375
393
 
376
394
  /**
377
395
  * One declared field, checked, and made again as the runtime's own: a frozen record of its type, its sizes and its
@@ -385,6 +403,7 @@ function cleanField(fd: unknown, where: string, inInput: boolean): Field {
385
403
  const init = own(x, 'init');
386
404
  return Object.freeze({
387
405
  t: x.t,
406
+ ...(x.visibility ? {visibility:x.visibility} : {}),
388
407
  ...(x.t === 'text' || x.t === 'list' || x.t === 'map' ? { max: x.max } : {}),
389
408
  ...(x.t === 'list' || x.t === 'map' ? { of: cleanField(x.of, `${where} (its entries)`, false) } : {}),
390
409
  ...(x.t === 'struct' ? { fields: Object.freeze(Object.fromEntries(Object.entries(x.fields ?? {}).map(([key, sub]) => [key, cleanField(sub, `${where}.${key}`, false)]))) } : {}),
@@ -395,8 +414,10 @@ function cleanField(fd: unknown, where: string, inInput: boolean): Field {
395
414
  function checkField(fd: unknown, where: string, inInput: boolean): void {
396
415
  const x = fd as Field;
397
416
  if (!x || typeof x !== 'object' || typeof x.t !== 'string' || !TYPES.has(x.t)) throw new Error(`${where} is not a field type: declare it with f (f.u16(), f.vec3(), f.list(f.ref(), 8)…)`);
398
- if (x.t === 'press' && !inInput) throw new Error(`${where}: f.press() is for input only (a button that fires once)`);
399
- if (inInput && !(INTS.has(x.t) || x.t === 'bit' || x.t === 'press' || x.t === 'fix')) throw new Error(`${where}: an input field is a whole number, f.fix(), f.bit() or f.press()`);
417
+ if ((x.t === 'press' || x.t === 'pulse') && !inInput) throw new Error(`${where}: f.press() is for input only (a button that fires once)`);
418
+ if (inInput && !(INTS.has(x.t) || x.t === 'bit' || x.t === 'press' || x.t === 'pulse' || x.t === 'fix')) throw new Error(`${where}: an input field is a whole number, f.fix(), f.bit() or f.press()`);
419
+ if (x.visibility !== undefined && !/^entities\.[^.]+\.fields\.[^.]+$/.test(where)) throw new Error(`${where}: visibility belongs on a top-level entity field`);
420
+ if (x.visibility !== undefined && (!['owner','server','results'].includes(x.visibility) || inInput)) throw new Error(`${where}: visibility is owner, server or results on state fields only`);
400
421
  if (x.t === 'text' && !(Number.isInteger(x.max) && (x.max as number) >= 1 && (x.max as number) <= 4096)) throw new Error(`${where}: f.text(max) needs its largest length, 1 to 4096 characters`);
401
422
  if (x.t === 'list' || x.t === 'map') {
402
423
  if (!(Number.isInteger(x.max) && (x.max as number) >= 1 && (x.max as number) <= 1024)) throw new Error(`${where}: a list or a map declares its largest size, 1 to 1024 entries`);
@@ -508,7 +529,10 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
508
529
  if (!p || typeof p !== 'object') throw new Error(`${at}.player is true, or { away, leave }`);
509
530
  if (p.away !== undefined && p.away !== 'neutral' && p.away !== 'think') throw new Error(`${at}.player.away is 'neutral' or 'think'`);
510
531
  if (p.leave !== undefined && p.leave !== 'despawn' && p.leave !== 'bot') throw new Error(`${at}.player.leave is 'despawn' or 'bot'`);
511
- player = { away: p.away ?? 'neutral', leave: p.leave ?? 'despawn' };
532
+ if (p.control !== undefined && !motion.some(([key, field]) => key === p.control && field.t === 'bit')) throw new Error(`${at}.player.control names a bit in motion`);
533
+ if (p.takeover !== undefined && !fields.some(([key, field]) => key === p.takeover && ['fix','i8','i16','i32','u8','u16','u32'].includes(field.t))) throw new Error(`${at}.player.takeover names a numeric field`);
534
+ if (p.control && typeof e.think !== 'function') throw new Error(`${at}.player.control needs think`);
535
+ player = { away: p.away ?? 'neutral', leave: p.leave ?? 'despawn', ...(p.control ? {control:p.control}:{}), ...(p.takeover ? {takeover:p.takeover}:{}) };
512
536
  if ((player.away === 'think' || player.leave === 'bot') && typeof e.think !== 'function') throw new Error(`${at}: away: 'think' and leave: 'bot' need a think(world, self) handler to steer the body`);
513
537
  }
514
538
  let body: KindTable['body'] = null;
@@ -517,9 +541,10 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
517
541
  const shapes = dims === 2 ? ['circle'] : ['sphere', 'capsule', 'box'];
518
542
  if (!b || !shapes.includes(b.shape)) throw new Error(`${at}.body.shape is ${shapes.map((s) => `'${s}'`).join(' or ')} when space.dims is ${dims}`);
519
543
  if (typeof b.radius !== 'number' || typeof b.maxSpeed !== 'number' || !Number.isFinite(b.radius) || !Number.isFinite(b.maxSpeed) || !(b.radius > 0) || !(b.maxSpeed >= 0)) throw new Error(`${at}.body needs radius (metres) and maxSpeed (metres a second)`);
544
+ if (b.terrain !== undefined && b.terrain !== 'exact') throw new Error(`${at}.body.terrain is 'exact', or omitted for legacy casts`);
520
545
  if (b.move !== undefined && b.move !== 'owner') throw new Error(`${at}.body.move is 'owner', or left out`);
521
546
  if (dims === 3 && b.height !== undefined && (!Number.isFinite(b.height) || b.height <= 0 || b.shape === 'capsule' && b.height < b.radius * 2)) throw new Error(`${at}.body.height must be finite, positive and at least twice the radius for a capsule`);
522
- body = { shape: b.shape, radius: b.radius, height: dims === 3 && b.shape === 'sphere' ? 2 * b.radius : typeof b.height === 'number' && b.height > 0 ? b.height : 0, maxSpeed: b.maxSpeed, sweep: b.sweep === true, owner: b.move === 'owner' };
547
+ body = { shape: b.shape, radius: b.radius, height: dims === 3 && b.shape === 'sphere' ? 2 * b.radius : typeof b.height === 'number' && b.height > 0 ? b.height : 0, maxSpeed: b.maxSpeed, sweep: b.sweep === true, owner: b.move === 'owner', ...(b.terrain === 'exact' ? {terrain: 'exact' as const} : {}) };
523
548
  }
524
549
  if (player && !body) throw new Error(`${at}: a player's kind needs a body`);
525
550
  const moveFn = move && typeof move[name] === 'function' ? move[name] as MoveFn : null;
@@ -535,14 +560,34 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
535
560
  if (e.collider !== undefined) {
536
561
  if (!body || player || body.owner) throw new Error(`${at}.collider needs a non-player body`);
537
562
  if (e.collider !== true) {
538
- if (!e.collider || typeof e.collider !== 'object' || Object.keys(e.collider).some(k => !['size', 'enabled'].includes(k))) throw new Error(`${at}.collider is true or {size, enabled}`);
563
+ if (!e.collider || typeof e.collider !== 'object' || Object.keys(e.collider).some(k => !['size', 'enabled', 'fields'].includes(k))) throw new Error(`${at}.collider is true or {size, enabled}`);
564
+ if (e.collider.fields !== undefined && (!Array.isArray(e.collider.fields) || e.collider.fields.length > 16 || !e.collider.fields.every(name => fields.some(([n,f]) => n === name && !f.visibility && !['struct','map','list','vec3','dir'].includes(f.t))))) throw new Error(`${at}.collider.fields names at most 16 scalar fields`);
539
565
  for (const [key, type] of [['size', 'vec3'], ['enabled', 'bit']]) {
540
566
  const name = (e.collider as Record<string, unknown>)[key];
541
567
  if (name !== undefined && (typeof name !== 'string' || !fields.some(([n, f]) => n === name && f.t === type))) throw new Error(`${at}.collider.${key} must name a ${type} field`);
542
568
  }
543
569
  }
544
570
  }
545
- const k: KindTable = { ...(e.collider ? { collider: e.collider } : {}), name, index: kinds.length, player, fields, motion, input, body, score: scores[0]?.[0] ?? null, tick: e.tick ?? null, think: e.think ?? null, on, commands: cmds, onRoom, guide: e.guide ?? null, move: moveFn };
571
+ if (e.query !== undefined) {
572
+ const q = e.query;
573
+ const word = (v: unknown): boolean => typeof v === 'string' && /^[a-zA-Z][a-zA-Z0-9_-]{0,31}$/.test(v);
574
+ if (!body || !q || typeof q !== 'object' || Object.keys(q).some(k => !['layer','tags','parts','profiles'].includes(k))) throw new Error(`${at}.query needs a body and {layer, tags, parts, profiles}`);
575
+ if (q.layer !== undefined && !word(q.layer)) throw new Error(`${at}.query.layer is a short name`);
576
+ if (q.tags !== undefined && (!Array.isArray(q.tags) || q.tags.length > 16 || !q.tags.every(word))) throw new Error(`${at}.query.tags holds at most 16 short names`);
577
+ const groups: [string, Record<string, HitRegion>][] = [];
578
+ if(q.parts !== undefined) groups.push(['parts',q.parts]);
579
+ if(q.profiles !== undefined) {
580
+ if(!q.profiles || typeof q.profiles!=='object' || Array.isArray(q.profiles) || Object.keys(q.profiles).length>8 || !Object.keys(q.profiles).every(word))throw new Error(`${at}.query.profiles holds at most eight named region sets`);
581
+ for(const [name,parts] of Object.entries(q.profiles))groups.push([`profiles.${name}`,parts]);
582
+ }
583
+ for (const [group,parts] of groups) {
584
+ if (!parts || typeof parts !== 'object' || Array.isArray(parts) || Object.keys(parts).length > 16) throw new Error(`${at}.query.${group} holds at most 16 shapes`);
585
+ for (const [name, part] of Object.entries(parts)) {
586
+ if (!word(name) || !part || !['sphere','capsule','box'].includes(part.shape) || !Number.isFinite(part.radius) || part.radius <= 0 || part.radius > 10000 || part.height !== undefined && (!Number.isFinite(part.height) || part.height <= 0 || part.height > 10000 || part.shape === 'capsule' && part.height < 2*part.radius) || part.offset !== undefined && !['x','y','z'].every(k => Number.isFinite((part.offset as any)[k]) && Math.abs((part.offset as any)[k]) <= 10000)) throw new Error(`${at}.query.${group}.${name} needs a finite shape, radius, height and offset`);
587
+ }
588
+ }
589
+ }
590
+ const k: KindTable = { ...(e.query ? {query: Object.freeze(e.query)} : {}), ...(e.collider ? { collider: e.collider } : {}), name, index: kinds.length, player, fields, motion, input, body, score: scores[0]?.[0] ?? null, tick: e.tick ?? null, think: e.think ?? null, on, commands: cmds, onRoom, guide: e.guide ?? null, move: moveFn };
546
591
  kinds.push(k);
547
592
  kindOf[name] = k;
548
593
  }
@@ -570,10 +615,21 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
570
615
  if (!a.questions || typeof a.questions !== 'object') throw new Error(`asks.${key}.questions is required; for example { advance: { type: 'noul', instructions: 'Should the party advance?' } }`);
571
616
  asks[key] = { ...a, stateFields: fieldList(a.state, `asks.${key}.state`) };
572
617
  }
618
+ if(room.records){const rec=room.records;
619
+ if(!/^server:[A-Za-z0-9_.:-]{1,57}$/.test(rec.key)||!fieldList(d.shared,'shared').some(([n,f])=>n===rec.identity&&f.t==='text')||Object.keys(rec.fields).length>16)throw new Error('room.records needs a server: key, shared text identity and at most 16 fields');
620
+ for(const k of kinds.filter(k=>k.player)){
621
+ if(rec.eligible&&!k.fields.some(([n,f])=>n===rec.eligible&&f.t==='bit'))throw new Error('room.records.eligible names a player bit field');
622
+ for(const [name,source] of Object.entries(rec.fields))if(!/^[a-z][A-Za-z0-9_]{0,31}$/.test(name)||['id','endedAt','place','won'].includes(name)||!k.fields.some(([n,f])=>n===source&&['u8','u16','u32','i8','i16','i32','fix'].includes(f.t)))throw new Error('room.records.fields maps result names to numeric player fields');
623
+ }
624
+ }
625
+ if (room.historySeconds !== undefined && (typeof room.historySeconds !== 'number' || !Number.isFinite(room.historySeconds) || room.historySeconds < 0 || room.historySeconds > 2)) throw new Error('room.historySeconds is from 0 to 2');
573
626
  const seats = Math.max(1, Math.min(Number.MAX_SAFE_INTEGER, Math.floor(Number(env.seats)) || 8));
627
+ const navigation:Record<string,readonly RouteNode[]> = Object.create(null);let graphNodes=0;
628
+ if(d.navigation){const entries=Object.entries(d.navigation);if(entries.length>8)throw new Error('navigation declares at most 8 graphs');for(const [name,graph] of entries){if(!/^[A-Za-z][A-Za-z0-9_]{0,47}$/.test(name))throw new Error('navigation graph names are identifiers up to 48 characters');navigation[name]=prepareGraph(graph);graphNodes+=navigation[name].length;if(graphNodes>8192)throw new Error('navigation declares at most 8192 nodes in total');}}
629
+ Object.freeze(navigation);
574
630
  return {
575
- contract: RULES_CONTRACT, save: SAVE_REVISION, dims, kinds, kindOf, events, commands, effects, effectNames: Object.keys(effects),
576
- shared: fieldList(d.shared, 'shared'), view, rounds, bots: Math.min(keep, seats), start: room.start ?? null, join: room.join ?? null, roomOn, asks,
631
+ navigation, contract: RULES_CONTRACT, save: SAVE_REVISION, dims, kinds, kindOf, events, commands, effects, effectNames: Object.keys(effects),
632
+ shared: fieldList(d.shared, 'shared'), view, ...(room.records?{records:room.records}:{}), ...(room.historySeconds ? {historySeconds:room.historySeconds}:{}), rounds, bots: Math.min(keep, seats), start: room.start ?? null, join: room.join ?? null, roomOn, asks,
577
633
  tune, publicTune, map, settings, seats,
578
634
  };
579
635
  }
@@ -585,21 +641,31 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
585
641
  * bundle, so none of the rules' handlers reach a browser with it.
586
642
  */
587
643
  export interface Schema {
644
+ records?: true;
588
645
  contract: 2;
589
646
  dims: 2 | 3;
590
647
  seats: number;
591
- kinds: { name: string; player: boolean; owner: boolean; radius: number; shape?: string; height?: number; maxSpeed: number; score: string | null; fields: FieldList; motion: FieldList; input: FieldList }[];
648
+ kinds: { query?: QueryDef; collider?: ColliderDef; name: string; player: boolean; control?: string; owner: boolean; radius: number; shape?: string; height?: number; terrain?: 'exact'; maxSpeed: number; score: string | null; fields: FieldList; motion: FieldList; input: FieldList }[];
592
649
  effects: Record<string, FieldList>;
593
650
  effectNames: string[];
594
651
  commands: Record<string, FieldList>;
595
652
  shared: FieldList;
653
+ historySeconds?: number;
596
654
  rounds: { seconds: number; breakSeconds: number } | null;
597
655
  settings: RoomSettings;
598
656
  }
657
+ function publicField(field: Field, hidden=false): Field {
658
+ hidden=hidden||!!field.visibility;
659
+ if(!hidden)return field;
660
+ const {init,...clean}=field;
661
+ if(clean.fields)clean.fields=Object.fromEntries(Object.entries(clean.fields).map(([key,value])=>[key,publicField(value,true)]));
662
+ if(clean.of)clean.of=publicField(clean.of,true);
663
+ return clean as Field;
664
+ }
599
665
  export function schemaOf(c: Compiled): Schema {
600
666
  return {
601
- contract: RULES_CONTRACT, dims: c.dims, seats: c.seats,
602
- kinds: c.kinds.map((k) => ({ name: k.name, player: Boolean(k.player), owner: Boolean(k.body?.owner), radius: k.body?.radius ?? 0, ...(c.dims === 3 && k.body ? { shape: k.body.shape, height: k.body.height || 2 * k.body.radius } : {}), maxSpeed: k.body?.maxSpeed ?? 0, score: k.score, fields: k.fields, motion: k.motion, input: k.input })),
667
+ contract: RULES_CONTRACT, dims: c.dims, seats: c.seats, ...(c.records?{records:true as const}:{}),
668
+ kinds: c.kinds.map((k) => ({ ...(k.query?{query:k.query}:{}), ...(k.collider?{collider:k.collider}:{}), ...(k.body?.terrain ? {terrain:k.body.terrain} : {}), name: k.name, player: Boolean(k.player), ...(k.player?.control ? {control:k.player.control}:{}), owner: Boolean(k.body?.owner), radius: k.body?.radius ?? 0, ...(c.dims === 3 && k.body ? { shape: k.body.shape, height: k.body.height || 2 * k.body.radius } : {}), maxSpeed: k.body?.maxSpeed ?? 0, score: k.score, fields: k.fields.map(([name,field])=>[name,publicField(field)]), motion: k.motion, input: k.input })),
603
669
  effects: c.effects, effectNames: c.effectNames, commands: c.commands, shared: c.shared, rounds: c.rounds, settings: c.settings,
604
670
  };
605
671
  }