@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/CHANGELOG.md +53 -0
- package/guides/game/RULES.md +222 -2
- package/lib/build.mjs +1 -1
- package/lib/rules-check-worker.mjs +6 -5
- package/lib/rules-check.mjs +2 -2
- package/lib/rules-guard.mjs +4 -3
- package/lib/rules-types.mjs +1 -1
- package/lib/template-history.json +84 -0
- package/netplay/netplay.ts +1 -0
- package/package.json +1 -1
- package/rules/collision.ts +51 -11
- package/rules/core.ts +105 -21
- package/rules/guard.ts +1 -1
- package/rules/host.ts +40 -9
- package/rules/interest.mjs +3 -3
- package/rules/live.ts +19 -7
- package/rules/map-index.ts +7 -1
- package/rules/math.ts +20 -8
- package/rules/navigation.ts +69 -0
- package/rules/pack.ts +2 -2
- package/rules/query.ts +103 -0
- package/rules/rules.ts +89 -23
- package/rules/terrain-legacy.ts +93 -0
- package/rules/terrain.ts +96 -11
- package/rules/types.ts +17 -7
- package/rules/view.ts +38 -19
- package/worker/index.mjs +29 -1
- package/worker/players.mjs +3 -1
- package/worker/room.mjs +12 -3
- package/worker/saves-shell.mjs +5 -1
- package/worker/saves.mjs +19 -1
- package/worker/version.mjs +1 -1
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
|
-
|
|
245
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|