@homie-rocks/studio 0.45.2 → 0.46.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.
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,15 @@ 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 durable?: boolean;
65
+ readonly visibility?: 'owner' | 'server' | 'results';
59
66
  /** `text`: the most characters. `list` and `map`: the most entries. */
60
67
  readonly max?: number;
61
68
  /** `list` and `map`: what each entry is. */
@@ -63,11 +70,11 @@ export interface Field {
63
70
  /** `struct`: its fields, in order. */
64
71
  readonly fields?: Readonly<Record<string, Field>>;
65
72
  }
66
- export interface FieldOptions { init?: unknown; score?: boolean }
73
+ export interface FieldOptions { durable?: boolean; init?: unknown; score?: boolean; visibility?: 'owner' | 'server' | 'results' }
67
74
  export type Fields = Readonly<Record<string, Field>>;
68
75
 
69
76
  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 } : {}) });
77
+ Object.freeze({ t, ...extra, ...(o?.durable !== undefined ? {durable:o.durable} : {}), ...(o?.visibility ? {visibility:o.visibility} : {}), ...(o && o.init !== undefined ? { init: o.init } : {}), ...(o && o.score === true ? { score: true as const } : {}) });
71
78
  const plain = (t: FieldKind) => (o?: FieldOptions): Field => made(t, o);
72
79
 
73
80
  /**
@@ -85,6 +92,7 @@ export const f = brand(Object.freeze({
85
92
  map: (of: Field, max: number, o?: FieldOptions): Field => made('map', o, { of, max }),
86
93
  struct: (fields: Fields, o?: FieldOptions): Field => made('struct', o, { fields: Object.freeze({ ...fields }) }),
87
94
  press: (): Field => made('press', undefined),
95
+ pulse: (): Field => made('pulse', undefined),
88
96
  }));
89
97
 
90
98
  /**
@@ -125,16 +133,20 @@ export type Handler = (world: RuntimeWorld, self: RuntimeSelf, e?: any) => void;
125
133
  export type RoomHandler = (world: RuntimeWorld, e?: any) => void;
126
134
  export type MoveFn = (body: any, input: any, ctx: any) => void;
127
135
 
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' }
136
+ export type ColliderDef = true | { size?: string; enabled?: string; fields?: readonly string[] };
137
+ export interface HitRegion { shape: 'sphere' | 'capsule' | 'box'; radius: number; height?: number; offset?: Vec3 }
138
+ export interface QueryDef { layer?: string; tags?: readonly string[]; parts?: Record<string, HitRegion>; profiles?: Record<string, Record<string, HitRegion>> }
139
+ export interface BodyDef { shape: 'circle' | 'sphere' | 'capsule' | 'box'; radius: number; height?: number; maxSpeed: number; sweep?: boolean; move?: 'owner'; terrain?: 'exact' }
130
140
  export interface GuideDef { view: (world: RuntimeWorld, self: RuntimeSelf) => unknown; floor?: (world: RuntimeWorld, self: RuntimeSelf, view: any) => unknown }
141
+ export interface RoundRecords {key:string; identity:string; eligible?:string; fields:Readonly<Record<string,string>>}
131
142
  export interface EntityDef {
132
- player?: true | { away?: 'neutral' | 'think'; leave?: 'despawn' | 'bot' };
143
+ player?: true | { away?: 'neutral' | 'think'; leave?: 'despawn' | 'bot'; control?: string; takeover?: string };
133
144
  fields?: Fields;
134
145
  motion?: Fields;
135
146
  input?: Fields;
136
147
  body?: BodyDef;
137
148
  collider?: ColliderDef;
149
+ query?: QueryDef;
138
150
  guide?: GuideDef;
139
151
  tick?: Handler;
140
152
  think?: (world: RuntimeWorld, self: RuntimeSelf) => Record<string, unknown>;
@@ -153,11 +165,14 @@ export interface RulesDef {
153
165
  room?: {
154
166
  rounds?: { seconds: number; breakSeconds: number };
155
167
  bots?: { keep: number };
168
+ records?: RoundRecords;
169
+ historySeconds?: number;
156
170
  start?: RoomHandler;
157
- 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> };
171
+ join?: (ctx: any, player: { seat: number; driver: 'person' | 'bot' | 'ai'; owner: string; character?: Character | null }) => { kind: string; at: Vec3; heading?: Vec3; fields?: Record<string, unknown>; motion?: Record<string, unknown> };
158
172
  on?: Record<string, RoomHandler>;
159
173
  };
160
174
  asks?: Record<string, AskDef>;
175
+ navigation?: Record<string,readonly RouteNode[]>;
161
176
  map?: string;
162
177
  }
163
178
 
@@ -175,16 +190,21 @@ export function defineMove<M extends Record<string, MoveFn>>(def: M): M {
175
190
 
176
191
  /* ------------------------------------------------------------------ settings (game.json "room") */
177
192
 
193
+ export interface Character { kind: string; at: Vec3; heading: Vec3; fields: Record<string, unknown>; motion: Record<string, unknown> }
194
+
178
195
  export interface RoomSettings {
179
196
  host: 'server' | 'browser';
180
197
  offline: boolean;
198
+ /** Persistent rooms retain their committed world until explicitly replaced. */
199
+ lifetime?: 'match' | 'persistent';
200
+ characters?: boolean;
181
201
  tickHz: number;
182
202
  inputHz: number;
183
203
  /** Omit the radius to send the whole room. No implicit visibility or bandwidth cap. */
184
204
  view: { radiusM: number | null; precisionM?: number; nearM?: number | null; farHz?: number | null };
185
205
  durability: { movementSeconds: number };
186
206
  budget: { tick: number };
187
- predict: { catchM: number | null; catchUp: number; snapM: number | null; blendMs: number; interpMs: number | null };
207
+ predict: { idleHold?: boolean; catchM: number | null; catchUp: number; snapM: number | null; blendMs: number; interpMs: number | null };
188
208
  }
189
209
  /**
190
210
  * The default `budget.tick`: 500,000 units at 20 ticks a second or fewer, and less at a faster rate, so a second of
@@ -218,12 +238,16 @@ export function roomSettings(raw: unknown): { settings: RoomSettings; problems:
218
238
  if (!Number.isFinite(n) || n < 0) { problems.push(`"room.${name}" is a number, zero or more; using its default`); return d; }
219
239
  return n;
220
240
  };
221
- const known = new Set(['host', 'offline', 'tickHz', 'inputHz', 'durability', 'budget', 'predict', 'view']);
241
+ const known = new Set(['host', 'offline', 'tickHz', 'inputHz', 'durability', 'budget', 'predict', 'view', 'lifetime', 'characters']);
222
242
  for (const key of Object.keys(r)) if (!known.has(key)) problems.push(`"room.${key}" is not a setting (host, offline, tickHz, inputHz, durability, budget, predict, view)`);
223
243
  let host: 'server' | 'browser' = 'server';
224
244
  if (r.host !== undefined) { if (r.host === 'server' || r.host === 'browser') host = r.host; else problems.push('"room.host" is "server" or "browser"; using "server"'); }
225
- let offline = true;
226
- if (r.offline !== undefined) { if (typeof r.offline === 'boolean') offline = r.offline; else problems.push('"room.offline" is true or false; using true'); }
245
+ if (r.lifetime !== undefined && !['match', 'persistent'].includes(r.lifetime)) problems.push('"room.lifetime" is "match" or "persistent"');
246
+ if (r.lifetime === 'persistent' && host !== 'server') problems.push('"room.lifetime: persistent" needs room.host "server"');
247
+ if (r.characters !== undefined && typeof r.characters !== 'boolean') problems.push('"room.characters" is true or false');
248
+ if (r.characters === true && r.lifetime !== 'persistent') problems.push('"room.characters" needs room.lifetime "persistent"');
249
+ let offline = r.lifetime !== 'persistent';
250
+ if (r.offline !== undefined) { if (typeof r.offline === 'boolean') offline = r.offline; else problems.push(`"room.offline" is true or false; using ${offline}`); }
227
251
  const tickHz = whole('tickHz', r.tickHz, 1, 60, 20);
228
252
  const inputHz = whole('inputHz', r.inputHz, 1, tickHz, tickHz);
229
253
  if (host === 'browser' && r.view?.radiusM != null) problems.push('"room.view.radiusM" needs room.host "server"; browser hosting sends the whole room');
@@ -234,11 +258,11 @@ export function roomSettings(raw: unknown): { settings: RoomSettings; problems:
234
258
  const p = r.predict && typeof r.predict === 'object' ? r.predict : {};
235
259
  return {
236
260
  settings: {
237
- host, offline, tickHz, inputHz,
261
+ host, offline, tickHz, inputHz, ...(r.lifetime === 'persistent' ? { lifetime: 'persistent' as const } : {}), ...(r.characters === true ? { characters: true } : {}),
238
262
  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
- durability: { movementSeconds: whole('durability.movementSeconds', r.durability?.movementSeconds, 1, 60, 1) },
263
+ durability: { movementSeconds: whole('durability.movementSeconds', r.durability?.movementSeconds, 1, 60, r.lifetime === 'persistent' ? 30 : 1) },
240
264
  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) },
265
+ 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
266
  },
243
267
  problems,
244
268
  };
@@ -321,12 +345,13 @@ export type FieldList = readonly (readonly [string, Field])[];
321
345
  export interface KindTable {
322
346
  name: string;
323
347
  index: number;
324
- player: null | { away: 'neutral' | 'think'; leave: 'despawn' | 'bot' };
348
+ player: null | { away: 'neutral' | 'think'; leave: 'despawn' | 'bot'; control?: string; takeover?: string };
325
349
  fields: FieldList;
326
350
  motion: FieldList;
327
351
  input: FieldList;
328
352
  collider?: ColliderDef;
329
- body: null | { shape: string; radius: number; height: number; maxSpeed: number; sweep: boolean; owner: boolean };
353
+ query?: QueryDef;
354
+ body: null | { shape: string; radius: number; height: number; maxSpeed: number; sweep: boolean; owner: boolean; terrain?: 'exact' };
330
355
  score: string | null;
331
356
  tick: Handler | null;
332
357
  think: EntityDef['think'] | null;
@@ -337,6 +362,7 @@ export interface KindTable {
337
362
  move: MoveFn | null;
338
363
  }
339
364
  export interface Compiled {
365
+ navigation: Readonly<Record<string,readonly RouteNode[]>>;
340
366
  contract: 2;
341
367
  /** `SAVE_REVISION`, so that what hashes these declarations hashes it too. */
342
368
  save: number;
@@ -349,6 +375,8 @@ export interface Compiled {
349
375
  effectNames: string[];
350
376
  shared: FieldList;
351
377
  view: FieldList;
378
+ records?: RoundRecords;
379
+ historySeconds?: number;
352
380
  rounds: { seconds: number; breakSeconds: number } | null;
353
381
  bots: number;
354
382
  start: RoomHandler | null;
@@ -371,7 +399,7 @@ export const BUILT_IN_FIELDS = Object.freeze(['id', 'kind', 'pos', 'vel', 'headi
371
399
 
372
400
  const NAME = /^[a-z][A-Za-z0-9_]{0,31}$/;
373
401
  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']);
402
+ const TYPES = new Set(['u8', 'u16', 'u32', 'i8', 'i16', 'i32', 'bit', 'fix', 'vec3', 'dir', 'tick', 'ticks', 'ref', 'text', 'list', 'map', 'struct', 'press', 'pulse']);
375
403
 
376
404
  /**
377
405
  * One declared field, checked, and made again as the runtime's own: a frozen record of its type, its sizes and its
@@ -385,6 +413,8 @@ function cleanField(fd: unknown, where: string, inInput: boolean): Field {
385
413
  const init = own(x, 'init');
386
414
  return Object.freeze({
387
415
  t: x.t,
416
+ ...(x.durable ? {durable:true} : {}),
417
+ ...(x.visibility ? {visibility:x.visibility} : {}),
388
418
  ...(x.t === 'text' || x.t === 'list' || x.t === 'map' ? { max: x.max } : {}),
389
419
  ...(x.t === 'list' || x.t === 'map' ? { of: cleanField(x.of, `${where} (its entries)`, false) } : {}),
390
420
  ...(x.t === 'struct' ? { fields: Object.freeze(Object.fromEntries(Object.entries(x.fields ?? {}).map(([key, sub]) => [key, cleanField(sub, `${where}.${key}`, false)]))) } : {}),
@@ -395,8 +425,11 @@ function cleanField(fd: unknown, where: string, inInput: boolean): Field {
395
425
  function checkField(fd: unknown, where: string, inInput: boolean): void {
396
426
  const x = fd as Field;
397
427
  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()`);
428
+ if ((x.t === 'press' || x.t === 'pulse') && !inInput) throw new Error(`${where}: f.press() is for input only (a button that fires once)`);
429
+ 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()`);
430
+ if (x.durable !== undefined && (typeof x.durable !== 'boolean' || inInput || !/^(entities\.[^.]+\.fields|shared)\.[^.]+$/.test(where))) throw new Error(`${where}: durable belongs on a top-level state field`);
431
+ if (x.visibility !== undefined && !/^entities\.[^.]+\.fields\.[^.]+$/.test(where)) throw new Error(`${where}: visibility belongs on a top-level entity field`);
432
+ 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
433
  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
434
  if (x.t === 'list' || x.t === 'map') {
402
435
  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`);
@@ -468,6 +501,7 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
468
501
  const dims = d.space?.dims;
469
502
  if (dims !== 2 && dims !== 3) throw new Error('rules name their space: space: { dims: 2 } or { dims: 3 }');
470
503
  const settings = env.settings ?? ROOM_DEFAULTS;
504
+ if (settings.characters && Object.values(d.entities ?? {}).some(e => typeof e.player === 'object' && e.player.leave === 'bot')) throw new Error('Account characters return on leaving; player.leave must be despawn');
471
505
  const map = env.map ?? compileMap({ bounds: { min: [-32, -32], max: [32, 32] } });
472
506
  if (dims === 2 && (map.spheres.length || map.capsules.length || map.heightTiles.length)) throw new Error('spheres, capsules and height tiles need space.dims: 3');
473
507
  if (dims === 3 && map.bounds.max.z <= map.bounds.min.z) throw new Error('a 3D map needs bounds with positive height');
@@ -508,7 +542,10 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
508
542
  if (!p || typeof p !== 'object') throw new Error(`${at}.player is true, or { away, leave }`);
509
543
  if (p.away !== undefined && p.away !== 'neutral' && p.away !== 'think') throw new Error(`${at}.player.away is 'neutral' or 'think'`);
510
544
  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' };
545
+ 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`);
546
+ 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`);
547
+ if (p.control && typeof e.think !== 'function') throw new Error(`${at}.player.control needs think`);
548
+ player = { away: p.away ?? 'neutral', leave: p.leave ?? 'despawn', ...(p.control ? {control:p.control}:{}), ...(p.takeover ? {takeover:p.takeover}:{}) };
512
549
  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
550
  }
514
551
  let body: KindTable['body'] = null;
@@ -517,9 +554,10 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
517
554
  const shapes = dims === 2 ? ['circle'] : ['sphere', 'capsule', 'box'];
518
555
  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
556
  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)`);
557
+ if (b.terrain !== undefined && b.terrain !== 'exact') throw new Error(`${at}.body.terrain is 'exact', or omitted for legacy casts`);
520
558
  if (b.move !== undefined && b.move !== 'owner') throw new Error(`${at}.body.move is 'owner', or left out`);
521
559
  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' };
560
+ 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
561
  }
524
562
  if (player && !body) throw new Error(`${at}: a player's kind needs a body`);
525
563
  const moveFn = move && typeof move[name] === 'function' ? move[name] as MoveFn : null;
@@ -535,14 +573,34 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
535
573
  if (e.collider !== undefined) {
536
574
  if (!body || player || body.owner) throw new Error(`${at}.collider needs a non-player body`);
537
575
  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}`);
576
+ 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}`);
577
+ 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
578
  for (const [key, type] of [['size', 'vec3'], ['enabled', 'bit']]) {
540
579
  const name = (e.collider as Record<string, unknown>)[key];
541
580
  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
581
  }
543
582
  }
544
583
  }
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 };
584
+ if (e.query !== undefined) {
585
+ const q = e.query;
586
+ const word = (v: unknown): boolean => typeof v === 'string' && /^[a-zA-Z][a-zA-Z0-9_-]{0,31}$/.test(v);
587
+ 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}`);
588
+ if (q.layer !== undefined && !word(q.layer)) throw new Error(`${at}.query.layer is a short name`);
589
+ 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`);
590
+ const groups: [string, Record<string, HitRegion>][] = [];
591
+ if(q.parts !== undefined) groups.push(['parts',q.parts]);
592
+ if(q.profiles !== undefined) {
593
+ 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`);
594
+ for(const [name,parts] of Object.entries(q.profiles))groups.push([`profiles.${name}`,parts]);
595
+ }
596
+ for (const [group,parts] of groups) {
597
+ if (!parts || typeof parts !== 'object' || Array.isArray(parts) || Object.keys(parts).length > 16) throw new Error(`${at}.query.${group} holds at most 16 shapes`);
598
+ for (const [name, part] of Object.entries(parts)) {
599
+ 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`);
600
+ }
601
+ }
602
+ }
603
+ 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
604
  kinds.push(k);
547
605
  kindOf[name] = k;
548
606
  }
@@ -570,10 +628,21 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
570
628
  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
629
  asks[key] = { ...a, stateFields: fieldList(a.state, `asks.${key}.state`) };
572
630
  }
631
+ if(room.records){const rec=room.records;
632
+ 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');
633
+ for(const k of kinds.filter(k=>k.player)){
634
+ 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');
635
+ 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');
636
+ }
637
+ }
638
+ 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
639
  const seats = Math.max(1, Math.min(Number.MAX_SAFE_INTEGER, Math.floor(Number(env.seats)) || 8));
640
+ const navigation:Record<string,readonly RouteNode[]> = Object.create(null);let graphNodes=0;
641
+ 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');}}
642
+ Object.freeze(navigation);
574
643
  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,
644
+ navigation, contract: RULES_CONTRACT, save: SAVE_REVISION, dims, kinds, kindOf, events, commands, effects, effectNames: Object.keys(effects),
645
+ 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
646
  tune, publicTune, map, settings, seats,
578
647
  };
579
648
  }
@@ -585,21 +654,31 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
585
654
  * bundle, so none of the rules' handlers reach a browser with it.
586
655
  */
587
656
  export interface Schema {
657
+ records?: true;
588
658
  contract: 2;
589
659
  dims: 2 | 3;
590
660
  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 }[];
661
+ 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
662
  effects: Record<string, FieldList>;
593
663
  effectNames: string[];
594
664
  commands: Record<string, FieldList>;
595
665
  shared: FieldList;
666
+ historySeconds?: number;
596
667
  rounds: { seconds: number; breakSeconds: number } | null;
597
668
  settings: RoomSettings;
598
669
  }
670
+ function publicField(field: Field, hidden=false): Field {
671
+ hidden=hidden||!!field.visibility;
672
+ if(!hidden)return field;
673
+ const {init,...clean}=field;
674
+ if(clean.fields)clean.fields=Object.fromEntries(Object.entries(clean.fields).map(([key,value])=>[key,publicField(value,true)]));
675
+ if(clean.of)clean.of=publicField(clean.of,true);
676
+ return clean as Field;
677
+ }
599
678
  export function schemaOf(c: Compiled): Schema {
600
679
  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 })),
680
+ contract: RULES_CONTRACT, dims: c.dims, seats: c.seats, ...(c.records?{records:true as const}:{}),
681
+ 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
682
  effects: c.effects, effectNames: c.effectNames, commands: c.commands, shared: c.shared, rounds: c.rounds, settings: c.settings,
604
683
  };
605
684
  }
@@ -0,0 +1,93 @@
1
+ /** Solid triangulated height tiles. Each source triangle is a closed vertical
2
+ * prism; their union preserves noncoplanar samples without a convex-hull fill. */
3
+ import { charge } from './guard.ts';
4
+ import type { Vec3, MapHeightTile } from './rules.ts';
5
+ import type { Solid, Hit3 } from './collision.ts';
6
+ type Plane = { n: Vec3; c: number };
7
+ type Prism = { vertices: Vec3[]; faces: Vec3[][]; planes: Plane[]; edges: Vec3[] };
8
+ const cache = new WeakMap<MapHeightTile, Prism[]>();
9
+ const sub=(a:Vec3,b:Vec3):Vec3=>({x:a.x-b.x,y:a.y-b.y,z:a.z-b.z});
10
+ const add=(a:Vec3,b:Vec3,t=1):Vec3=>({x:a.x+b.x*t,y:a.y+b.y*t,z:a.z+b.z*t});
11
+ const dot=(a:Vec3,b:Vec3)=>a.x*b.x+a.y*b.y+a.z*b.z;
12
+ const cross=(a:Vec3,b:Vec3):Vec3=>({x:a.y*b.z-a.z*b.y,y:a.z*b.x-a.x*b.z,z:a.x*b.y-a.y*b.x});
13
+ const clamp=(x:number)=>Math.max(0,Math.min(1,x));
14
+ export function prepareTile(t:MapHeightTile):void {
15
+ if(t.base===undefined)return;
16
+ const top=t.heights.map((h,i)=>({x:t.at.x+(i%2)*t.size.x,y:t.at.y+(i>>1)*t.size.y,z:t.at.z+h}));
17
+ const tris=t.diagonal==='10-01'?[[0,1,2],[1,3,2]]:[[0,1,3],[0,3,2]];
18
+ const prisms:Prism[]=[];
19
+ for(const tri of tris){
20
+ const upper=tri.map(i=>top[i]),lower=upper.map(v=>({...v,z:t.at.z+t.base!}));
21
+ if(upper.every(v=>v.z===lower[0].z))continue;
22
+ const vertices=[...upper,...lower],faces=[upper,[...lower].reverse()];
23
+ for(let i=0;i<3;i++){const j=(i+1)%3;faces.push([upper[i],lower[i],lower[j]],[upper[i],lower[j],upper[j]]);}
24
+ const center=vertices.reduce((s,v)=>add(s,v,1/6),{x:0,y:0,z:0}),planes:Plane[]=[],valid:Vec3[][]=[],edges:Vec3[]=[];
25
+ for(const face of faces){let n=cross(sub(face[1],face[0]),sub(face[2],face[0]));const len=Math.sqrt(dot(n,n));if(len<1e-12)continue;n={x:n.x/len,y:n.y/len,z:n.z/len};let c=dot(n,face[0]);if(dot(n,center)>c){n={x:-n.x,y:-n.y,z:-n.z};c=-c;}planes.push({n,c});valid.push(face);for(let i=0;i<3;i++)edges.push(sub(face[(i+1)%3],face[i]));}
26
+ prisms.push({vertices,faces:valid,planes,edges});
27
+ }
28
+ cache.set(t,prisms);
29
+ }
30
+ // Closest point on a triangle, including vertex and edge Voronoi regions.
31
+ function triangle(p:Vec3,a:Vec3,b:Vec3,c:Vec3):Vec3 {
32
+ const ab=sub(b,a),ac=sub(c,a),ap=sub(p,a),d1=dot(ab,ap),d2=dot(ac,ap);
33
+ if(d1<=0&&d2<=0)return a;
34
+ const bp=sub(p,b),d3=dot(ab,bp),d4=dot(ac,bp);if(d3>=0&&d4<=d3)return b;
35
+ const vc=d1*d4-d3*d2;if(vc<=0&&d1>=0&&d3<=0)return add(a,ab,d1/(d1-d3));
36
+ const cp=sub(p,c),d5=dot(ab,cp),d6=dot(ac,cp);if(d6>=0&&d5<=d6)return c;
37
+ const vb=d5*d2-d1*d6;if(vb<=0&&d2>=0&&d6<=0)return add(a,ac,d2/(d2-d6));
38
+ const va=d3*d6-d5*d4;if(va<=0&&d4-d3>=0&&d5-d6>=0)return add(b,sub(c,b),(d4-d3)/(d4-d3+d5-d6));
39
+ const den=1/(va+vb+vc);return add(add(a,ab,vb*den),ac,vc*den);
40
+ }
41
+ function segmentPair(a:Vec3,b:Vec3,c:Vec3,d:Vec3):Vec3 {
42
+ const u=sub(b,a),v=sub(d,c),w=sub(a,c),aa=dot(u,u),bb=dot(u,v),cc=dot(v,v),dd=dot(u,w),ee=dot(v,w);
43
+ let s=aa?clamp((bb*ee-cc*dd)/(aa*cc-bb*bb)||0):0;
44
+ let t=cc?clamp((bb*s+ee)/cc):0;
45
+ s=aa?clamp((bb*t-dd)/aa):0;t=cc?clamp((bb*s+ee)/cc):0;
46
+ return sub(add(a,u,s),add(c,v,t));
47
+ }
48
+ function clip(prism:Prism,p:Vec3,d:Vec3):Hit3|null {
49
+ let near=0,far=1,n={x:0,y:0,z:0};
50
+ for(const plane of prism.planes){charge(8);const gap=plane.c-dot(plane.n,p),speed=dot(plane.n,d);if(Math.abs(speed)<1e-12){if(gap< -1e-9)return null;continue;}const t=gap/speed;if(speed<0){if(t>=near){near=t;n=plane.n;}}else far=Math.min(far,t);if(near>far+1e-9)return null;}
51
+ return near>=0&&near<=1&&far>=0?{t:near,nx:n.x,ny:n.y,nz:n.z}:null;
52
+ }
53
+ function distance(prism:Prism,a:Vec3,b:Vec3):Vec3 {
54
+ if(clip(prism,a,sub(b,a)))return {x:0,y:0,z:0};
55
+ let best={x:Infinity,y:Infinity,z:Infinity},sq=Infinity;
56
+ const take=(v:Vec3)=>{const n=dot(v,v);if(n<sq){sq=n;best=v;}};
57
+ for(const [x,y,z] of prism.faces){charge(36);take(sub(a,triangle(a,x,y,z)));take(sub(b,triangle(b,x,y,z)));take(segmentPair(a,b,x,y));take(segmentPair(a,b,y,z));take(segmentPair(a,b,z,x));}
58
+ return best;
59
+ }
60
+ function boxCast(prism:Prism,a:Solid,d:Vec3):Hit3|null {
61
+ const axes:Vec3[]=[{x:1,y:0,z:0},{x:0,y:1,z:0},{x:0,y:0,z:1},...prism.planes.map(p=>p.n)];
62
+ for(const e of prism.edges)for(const k of axes.slice(0,3))axes.push(cross(e,k));
63
+ let near=0,far=1,normal={x:0,y:0,z:0};
64
+ for(const axis of axes){charge(16);const len=Math.sqrt(dot(axis,axis));if(len<1e-10)continue;const n={x:axis.x/len,y:axis.y/len,z:axis.z/len};let lo=Infinity,hi=-Infinity;
65
+ for(const v of prism.vertices){const p=dot(n,v);lo=Math.min(lo,p);hi=Math.max(hi,p);}
66
+ let amin=0,amax=0;for(const k of ['x','y','z'] as const){amin+=n[k]*(n[k]>=0?a.min[k]:a.max[k]);amax+=n[k]*(n[k]>=0?a.max[k]:a.min[k]);}
67
+ const speed=dot(n,d);if(Math.abs(speed)<1e-12){if(amax<=lo+1e-9||amin>=hi-1e-9)return null;continue;}
68
+ let enter=(lo-amax)/speed,exit=(hi-amin)/speed;if(enter>exit)[enter,exit]=[exit,enter];
69
+ if(enter>=near){near=enter;normal={x:n.x*(speed>0?-1:1),y:n.y*(speed>0?-1:1),z:n.z*(speed>0?-1:1)};}far=Math.min(far,exit);if(near>far)return null;
70
+ }
71
+ return near<=1&&dot(normal,d)<0?{t:near,nx:normal.x,ny:normal.y,nz:normal.z}:null;
72
+ }
73
+ export function castTerrain(tile:MapHeightTile,a:Solid,d:Vec3):Hit3|null {
74
+ charge(12);
75
+ let best:Hit3|null=null;
76
+ for(const prism of cache.get(tile)??[]){
77
+ let hit:Hit3|null=null;
78
+ if(a.r===0)hit=a.min.x===a.max.x&&a.min.y===a.max.y&&a.min.z===a.max.z?clip(prism,a.min,d):boxCast(prism,a,d);
79
+ else {let t=0;for(let i=0;i<32;i++){charge(24);const v=distance(prism,add(a.min,d,t),add(a.max,d,t)),len=Math.sqrt(dot(v,v)),gap=len-a.r;if(i===0&&gap< -1e-7)break;if(len===0)break;const n={x:v.x/len,y:v.y/len,z:v.z/len},closing=-dot(n,d);if(closing<=1e-9)break;if(gap<=1e-7){hit={t,nx:n.x,ny:n.y,nz:n.z};break;}t+=gap/closing;if(t>1)break;if(i===31)hit={t,nx:n.x,ny:n.y,nz:n.z};}}
80
+ if(hit&&(!best||hit.t<best.t))best=hit;
81
+ }return best;
82
+ }
83
+ export function overlapsTerrain(tile:MapHeightTile,a:Solid):boolean {
84
+ for(const prism of cache.get(tile)??[]){
85
+ if(a.r>0){const v=distance(prism,a.min,a.max);if(dot(v,v)<a.r*a.r-1e-9)return true;}
86
+ else {
87
+ // SAT overlap uses the same face, edge and box axes as continuous casts.
88
+ const axes=[{x:1,y:0,z:0},{x:0,y:1,z:0},{x:0,y:0,z:1},...prism.planes.map(p=>p.n)];
89
+ for(const e of prism.edges)for(const k of axes.slice(0,3))axes.push(cross(e,k));
90
+ let separated=false;for(const n of axes){charge(16);if(dot(n,n)<1e-18)continue;const ps=prism.vertices.map(v=>dot(n,v));let lo=0,hi=0;for(const k of ['x','y','z'] as const){lo+=n[k]*(n[k]>=0?a.min[k]:a.max[k]);hi+=n[k]*(n[k]>=0?a.max[k]:a.min[k]);}if(hi<=Math.min(...ps)+1e-9||lo>=Math.max(...ps)-1e-9){separated=true;break;}}if(!separated)return true;
91
+ }
92
+ }return false;
93
+ }