@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/CHANGELOG.md +87 -0
- package/guides/game/RULES.md +276 -3
- package/guides/game/SKILL.md +46 -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 +2 -2
- package/lib/scaffold.mjs +9 -3
- package/lib/template-history.json +84 -0
- package/netplay/NETPLAY.md +3 -1
- package/netplay/netplay.ts +1 -0
- package/package.json +1 -1
- package/rules/collision.ts +51 -11
- package/rules/core.ts +116 -26
- package/rules/guard.ts +1 -1
- package/rules/host.ts +139 -19
- 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 +108 -29
- 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/characters.mjs +41 -0
- package/worker/functions-schema.mjs +16 -0
- package/worker/index.mjs +181 -17
- package/worker/players.mjs +3 -1
- package/worker/room-store.mjs +113 -11
- 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/worker/world-journal.mjs +109 -0
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,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
|
|
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
|
-
|
|
226
|
-
if (r.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|