@homie-rocks/studio 0.45.1 → 0.45.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/rules/core.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import {routeGraph} from './navigation.ts';
2
+ import { rayQuery, raySnapshot, type QueryTarget } from './query.ts';
1
3
  import { colliderSolid, castCollider2, colliderRow, collisionMap, collisionQueries, type CollisionRevision } from './live.ts';
2
4
  /*
3
5
  * core.ts — one room's world, stepped a tick at a time from a compiled rules module.
@@ -133,7 +135,9 @@ function errorText(error: unknown): string {
133
135
  export interface SavedCore {
134
136
  v: number; tick: number; epoch: number; rng: number; nextId: number; seq: number;
135
137
  round: [number, number, number, number]; overAt: number; match: [number, number]; trips: number;
136
- shared: unknown[]; policy: CorePolicy; intent?: [number, number];
138
+ shared: unknown[]; policy: CorePolicy; intent?: [number, number, number?];
139
+ collision?: [number,number];
140
+ history?: [number, [string, number, number, number, number][]][];
137
141
  asks: PendingAsk[];
138
142
  guideViews: [number, Record<string, unknown>][];
139
143
  ents: unknown[][]; spawns: unknown[][]; seats: [number, string, string, string | null, number][]; queue: unknown[][]; areas: unknown[][]; ops: unknown[];
@@ -179,7 +183,7 @@ export interface Core {
179
183
  * `noted`: told, as it happens, of a value the rules wrote that the runtime changed to make it fit, or dropped (pack.ts
180
184
  * `Adjusted`; also `effect`, `think` and `decision`): the handler, what was done, the field's name and what was written.
181
185
  */
182
- export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: number, input: Readonly<Record<string, unknown>>, before: import('./pack.ts').MoveBody, after: import('./pack.ts').MoveBody, geometry: import('./math.ts').MapShapes) => void; observe?: (kind: string, handler: string, error?: string) => void; noted?: (kind: string, handler: string, what: string, at: string, written: string) => void; seed?: number; epoch?: number; restore?: SavedCore | null; restoreEpoch?: number; stage?: string; decisions?: boolean } = {}): Core {
186
+ export function createCore(c: Compiled, opts: { rewindTicks?: (seat: number)=>number; label?: (seat:number,driver:Driver)=>string; moved?: (kind: string, tick: number, input: Readonly<Record<string, unknown>>, before: import('./pack.ts').MoveBody, after: import('./pack.ts').MoveBody, geometry: import('./math.ts').MapShapes, id: string) => void; observe?: (kind: string, handler: string, error?: string) => void; noted?: (kind: string, handler: string, what: string, at: string, written: string) => void; seed?: number; epoch?: number; restore?: SavedCore | null; restoreEpoch?: number; stage?: string; decisions?: boolean } = {}): Core {
183
187
  const dims = c.dims;
184
188
  const tickHz = c.settings.tickHz;
185
189
  const dt = 1 / tickHz;
@@ -210,6 +214,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
210
214
  let restartAt = 0;
211
215
  let finishing = false;
212
216
  let endAsked = false;
217
+ let restartAsked = false;
213
218
  let vocabulary: Vocabulary | null = null;
214
219
  const restoredGoals = new Map<number, unknown>();
215
220
  const guideViews = new Map<number, Record<string, unknown>>();
@@ -369,6 +374,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
369
374
  * budget; and the counter and the scope are put back whatever happens. Nothing of the rules' is touched after this
370
375
  * returns.
371
376
  */
377
+ let dispatchDepth = 0;
372
378
  function run(kind: string, handler: string, ent: Ent | null, scope: Ctx['scope'], fn: () => void, always: boolean | 'body' = false): boolean {
373
379
  if (left <= 0) {
374
380
  cut = true;
@@ -387,7 +393,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
387
393
  const E = Error as unknown as { stackTraceLimit?: number };
388
394
  const limit = E.stackTraceLimit;
389
395
  E.stackTraceLimit = 0;
390
- G.left = quota;
396
+ G.queryEpoch++; G.left = quota;
391
397
  // The build check is told of every written value that had to be changed to fit (pack.ts), with the handler it was in.
392
398
  if (noted) G.note = (what, at, written) => noted(kind, handler, what, at, written);
393
399
  let bad = false; let thrown: unknown;
@@ -463,7 +469,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
463
469
  }));
464
470
  let roundApi: Readonly<Record<string, unknown>> = Object.freeze({});
465
471
  const refreshRound = (): void => {
466
- roundApi = brand(Object.freeze({ n: round.n, phase: round.phase, endsAt: round.endsAt, end: (): void => { need('room', 'world.round.end()'); endAsked = true; } }));
472
+ roundApi = brand(Object.freeze({ n: round.n, phase: round.phase, endsAt: round.endsAt, end: (): void => { need('room', 'world.round.end()'); endAsked = true; }, restart: (): void => { need('room', 'world.round.restart()'); restartAsked = true; } }));
467
473
  };
468
474
  refreshRound();
469
475
  /** `world.shared`: in room scope the writable face of the stored record, held to its types as it is written; everywhere else a frozen record of the same stored values. */
@@ -478,6 +484,32 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
478
484
  return sharedRO;
479
485
  };
480
486
 
487
+ const historyTicks = Math.ceil((c.historySeconds ?? 0) * tickHz);
488
+ let poseHistory: [number, [string, number, number, number, number][]][] = [];
489
+ function rayTargets(options?: unknown): QueryTarget[] {
490
+ charge(8 * ents.size);
491
+ const targets: QueryTarget[] = [];
492
+ const filterKind=own(options,'kind'),filterLayer=own(options,'layer'),filterTag=own(options,'tag'),geometryOnly=own(options,'geometryOnly')===true;
493
+
494
+ const requested = own(options,'atTick');
495
+ if (requested !== undefined && (typeof requested !== 'number' || !Number.isFinite(requested))) throw new Error('ray.atTick is a finite simulation tick');
496
+ if (requested !== undefined && !historyTicks) throw new Error('ray.atTick needs room.historySeconds');
497
+ const allowance = cx.ent?.driver === 'person' ? Math.min(historyTicks,Math.max(0,opts.rewindTicks?.(cx.ent.seat) ?? 0)) : historyTicks;
498
+ const wanted = typeof requested === 'number' ? Math.max(tick-allowance,Math.min(tick,requested)) : tick;
499
+ let before: typeof poseHistory[number] | undefined, after: typeof poseHistory[number] | undefined;
500
+ if(wanted<tick)for(const frame of poseHistory){charge(2);if(frame[0]<=wanted)before=frame;if(frame[0]>=wanted){after=frame;break;}}
501
+ before ??= poseHistory[0];after ??= poseHistory[poseHistory.length-1];
502
+ for(const e of ents.values()) {
503
+ if(e.dead || !e.kind.body || typeof filterKind==='string'&&e.kind.name!==filterKind || geometryOnly&&!e.kind.collider || typeof filterLayer==='string'&&filterLayer!==(e.kind.query?.layer??(e.kind.collider?'geometry':'body')) || typeof filterTag==='string'&&!e.kind.query?.tags?.includes(filterTag))continue;
504
+ charge(64);
505
+ const row=e.kind.collider?colliderRow(e.id,e.pos,e.kind.body,e.kind.collider,e.f):null;
506
+ if(e.kind.collider&&!row)continue;
507
+ let at=e.pos;
508
+ if(!e.kind.collider && wanted<tick && before && after){charge(4*(before[1].length+after[1].length));const a=before[1].find(p=>p[0]===e.id&&p[4]===e.r),b=after[1].find(p=>p[0]===e.id&&p[4]===e.r);if(a&&b){const mix=after[0]===before[0]?0:Math.max(0,Math.min(1,(wanted-before[0])/(after[0]-before[0])));at={x:a[1]+(b[1]-a[1])*mix,y:a[2]+(b[2]-a[2])*mix,z:a[3]+(b[3]-a[3])*mix};}}
509
+ targets.push({id:e.id,kind:e.kind.name,query:e.kind.query,fields:e.f,geometry:Boolean(row),solid:row?colliderSolid(row):solidAt(at,e.kind.body),at});
510
+ }
511
+ return targets;
512
+ }
481
513
  function cast3(p: Vec3, d: Vec3, shape: BodyShape, ignored: Set<string>): Hit3 | null {
482
514
  let best = castMap3(c.map, p, d, shape);
483
515
  const solid = solidAt(p, shape);
@@ -514,6 +546,23 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
514
546
  if (cx.scope !== 'ent' && cx.scope !== 'room') throw new Error('world.send is for handlers');
515
547
  push({ due: tick + 1, to: typeof target === 'string' ? target.slice(0, 24) : '', kind: 'ev', ev: ev as string, data: shapeData(c.events, ev, 'the event', data), at: tick });
516
548
  },
549
+ dispatch: (target: unknown, ev: unknown, data?: unknown): boolean => {
550
+ charge(SEND);
551
+ if (cx.scope !== 'ent' && cx.scope !== 'room') throw new Error('world.dispatch is for handlers');
552
+ if (dispatchDepth >= 8) throw new Error('world.dispatch permits at most eight nested deliveries');
553
+ const payload = shapeData(c.events, ev, 'the event', data);
554
+ const e = typeof target === 'string' ? ents.get(target) : undefined;
555
+ const fn = e && typeof ev === 'string' ? e.kind.on[ev] : undefined;
556
+ if (!e || e.dead || !fn) return false;
557
+ // Synchronous delivery shares the caller's remaining quota. Each handler
558
+ // still writes only its own entity, and collection settlement stays scoped.
559
+ const parent = cx, parentTouched = touched, note = G.note;
560
+ cx = {scope:'ent',ent:e}; touched = []; dispatchDepth++;
561
+ if (noted) G.note = (what, at, written) => noted(e.kind.name, `on.${String(ev)}`, what, at, written);
562
+ try { fn(world,e.self,payload); }
563
+ finally { try { settle(); } finally { cx=parent; touched=parentTouched; G.note=note; dispatchDepth--; } }
564
+ return true;
565
+ },
517
566
  sendRoom: (ev: unknown, data?: unknown): void => {
518
567
  charge(SEND);
519
568
  if (cx.scope !== 'ent' && cx.scope !== 'room') throw new Error('world.sendRoom is for handlers');
@@ -608,7 +657,8 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
608
657
  charge(cost);
609
658
  return Object.freeze(found.map(viewOf));
610
659
  },
611
- ray: (from: unknown, direction: unknown, max: unknown): unknown => {
660
+ ray: (from: unknown, direction: unknown, max: unknown, options?: unknown): unknown => {
661
+ if (options !== undefined) return rayQuery(c.map, rayTargets(options), dims, from, direction, max, options, cx.ent?.id)[0];
612
662
  // Charged before the cast, for every shape it may test: the map's, and every entity in the room.
613
663
  charge(20 + 4 * ents.size);
614
664
  const p = V(from); const d = dir(direction, dims); const far = reach(max, 'world.ray');
@@ -630,6 +680,14 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
630
680
  if (!best) return undefined;
631
681
  return Object.freeze({ ...(best.id ? { entity: best.id } : {}), at: V({ x: p.x + d.x * far * best.t, y: p.y + d.y * far * best.t, z: 0 }), normal: V({ x: best.nx, y: best.ny, z: 0 }), dist: far * best.t });
632
682
  },
683
+ route: (graph: unknown, from: unknown, to: unknown, options?: unknown) => {
684
+ const radius=own(options,'radius')??.5,height=own(options,'height')??1.8;
685
+ if(typeof radius!=='number'||!Number.isFinite(radius)||radius<0||radius>100||typeof height!=='number'||!Number.isFinite(height)||height<0||height>200)throw new Error('route radius/height are finite body dimensions');
686
+ const named=typeof graph==='string';if(named&&!Object.hasOwn(c.navigation,graph))throw new Error('unknown navigation graph');return routeGraph(named?c.navigation[graph]:graph,from,to,rayTargets({geometryOnly:true}).map(t=>t.solid),radius,height,named);
687
+ },
688
+ label: (id: unknown): string => {charge(20);const e=typeof id==='string'?ents.get(id):undefined;if(!e?.kind.player)return '';return String(opts.label?.(e.seat,e.driver)??`Player ${e.seat+1}`).slice(0,40);},
689
+ rays: (options?: unknown): unknown => raySnapshot(c.map,rayTargets(options),dims,options,cx.ent?.id),
690
+ rayAll: (from: unknown, direction: unknown, max: unknown, options?: unknown): unknown => rayQuery(c.map, rayTargets(options), dims, from, direction, max, options, cx.ent?.id, true),
633
691
  sweep: (self: unknown, delta: unknown, o?: unknown): unknown => {
634
692
  const e = mine(self, 'world.sweep');
635
693
  charge(20 + 4 * ents.size);
@@ -688,13 +746,19 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
688
746
  Object.freeze(world);
689
747
 
690
748
  const hasColliders = c.kinds.some(k => k.collider);
691
- const collisionState = (): CollisionRevision => [tick, tick + 1, [...ents.values()].flatMap(e => {
692
- if (e.dead || !e.kind.collider || !e.kind.body) return [];
693
- const row = colliderRow(e.id, e.pos, e.kind.body, e.kind.collider, e.f);
694
- return row ? [row] : [];
695
- })];
749
+ let collisionPrevious: CollisionRevision | null = null, collisionText = '';
750
+ const collisionState = (): CollisionRevision => {
751
+ const rows = [...ents.values()].flatMap(e => {
752
+ if (e.dead || !e.kind.collider || !e.kind.body) return [];
753
+ const row = colliderRow(e.id,e.pos,e.kind.body,e.kind.collider,e.f,e.kind.name,e.kind.query);
754
+ return row ? [row] : [];
755
+ });
756
+ const text = JSON.stringify(rows);
757
+ if (!collisionPrevious || text !== collisionText) {collisionText=text;collisionPrevious=[...(collisionPrevious ? [tick,tick+1] : opts.restore?.collision ?? [tick,tick+1]) as [number,number],rows];}
758
+ return collisionPrevious;
759
+ };
696
760
  let moveGeometry = c.map as import('./math.ts').MapShapes;
697
- const queries = collisionQueries(() => moveGeometry, () => moveShape, dims);
761
+ const queries = collisionQueries(() => moveGeometry, () => moveShape, dims, () => cx.ent?.id);
698
762
  const moveWorld = brand(Object.freeze(queries));
699
763
  const moveMap = brand(Object.freeze({ name: c.map.name, spot: mapApi.spot, spots: mapApi.spots, ...queries }));
700
764
  let moveShape = { shape: 'sphere', radius: 0, height: 0 };
@@ -833,13 +897,19 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
833
897
  // The seat changed hands without a goodbye: its old holder has left.
834
898
  if (s && s.driver !== 'bot') { leaveBody(s); s = seats.get(info.seat); }
835
899
  let bot: Ent | null = s && s.id ? ents.get(s.id) ?? null : null;
836
- if (!bot && playerBodies().length >= c.bots) {
900
+ const previousBot = bot;
901
+ if (!bot && playerBodies().length >= c.bots || bot?.kind.player?.takeover) {
837
902
  // A person who joins a full room takes over a bot's body: the bot in the highest seat.
838
- for (const e of playerBodies()) if (e.driver === 'bot' && (!bot || e.seat > bot.seat)) bot = e;
903
+ const priority = (e: Ent): number => e.kind.player?.takeover ? Number(e.f[e.kind.player.takeover]) : 0;
904
+ for (const e of playerBodies()) if (e.driver === 'bot' && (!bot || priority(e) > priority(bot) || (priority(e) === priority(bot) && e.seat > bot.seat))) bot = e;
839
905
  }
840
906
  if (bot) {
841
907
  const e = bot;
842
908
  seats.delete(e.seat);
909
+ if (previousBot && previousBot !== e) {
910
+ previousBot.seat = e.seat;
911
+ seats.set(previousBot.seat, {seat:previousBot.seat, driver:'bot', owner:'', id:previousBot.id, away:false});
912
+ }
843
913
  e.goal = null; e.seat = info.seat; e.driver = info.driver; e.owner = info.owner; e.away = false; e.r = (e.r + 1) & 0xffff; e.allow = 0;
844
914
  seats.set(info.seat, { seat: info.seat, driver: info.driver, owner: info.owner, id: e.id, away: false });
845
915
  out.push({ t: 'seats' });
@@ -882,6 +952,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
882
952
  const seat = seats.get(e.seat);
883
953
  if (seat) leaveBody(seat);
884
954
  }
955
+ poseHistory = [];
885
956
  round = { n: round.n + 1, phase: 'live', endsAt: c.rounds && c.rounds.seconds > 0 ? tick + ticks(c.rounds.seconds) : 0, startedAt: tick };
886
957
  overAt = 0;
887
958
  refreshRound();
@@ -907,6 +978,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
907
978
  for (const s of [...seats.values()]) { seats.delete(s.seat); if (s.driver !== 'bot') ops.push({ op: 'join', info: { seat: s.seat, driver: s.driver, owner: s.owner } }, ...(s.away ? [{ op: 'away' as const, seat: s.seat, away: true }] : [])); }
908
979
  return;
909
980
  }
981
+ if (restartAsked) { restartAsked = false; endAsked = false; startRound(); return; }
910
982
  if (round.phase === 'live' && (endAsked || (round.endsAt > 0 && tick >= round.endsAt))) endRound();
911
983
  endAsked = false;
912
984
  if (overAt && tick >= overAt + 2) {
@@ -918,7 +990,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
918
990
  } else if (round.phase === 'over' && !overAt && tick >= round.endsAt) startRound();
919
991
  }
920
992
  function finishMatch(): void {
921
- finishing = false;
993
+ finishing = false; restartAsked = false;
922
994
  out.push({ t: 'round', n: round.n, phase: 'over', endsAt: tick + Math.max(3, ticks(c.rounds ? c.rounds.breakSeconds : 0)), startedAt: round.startedAt, results: results() });
923
995
  for (const e of ents.values()) e.dead = true;
924
996
  ents.clear(); spawns = []; queue = []; pendingAsks = []; areas = []; fx = [];
@@ -1014,12 +1086,13 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
1014
1086
  const body = k.body as NonNullable<KindTable['body']>;
1015
1087
  let claim: StepInput['claim'] = null;
1016
1088
  if (k.player) {
1017
- const driven = (e.driver === 'person' || (e.driver === 'ai' && inputs.has(e.seat))) && !e.away;
1089
+ const permitted = !k.player.control || Boolean(e.m[k.player.control]);
1090
+ const driven = permitted && (e.driver === 'person' || (e.driver === 'ai' && inputs.has(e.seat))) && !e.away;
1018
1091
  if (driven) {
1019
1092
  const s = inputs.get(e.seat);
1020
1093
  e.input = Object.freeze(s ? coerceFields(k.input, s.values, dims) : initFields(k.input, dims));
1021
1094
  claim = s?.claim ?? null;
1022
- } else if (k.think && (e.driver !== 'person' || k.player.away === 'think')) {
1095
+ } else if (k.think && (!permitted || e.driver !== 'person' || k.player.away === 'think')) {
1023
1096
  // `think` returns the step. It is held to the declared input inside the handler's own try and budget; a `think` that throws leaves the input neutral.
1024
1097
  let stepIn: Record<string, unknown> | null = null;
1025
1098
  run(k.name, 'think', e, 'ent', () => {
@@ -1068,7 +1141,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
1068
1141
  naming('body', 'pos'); e.pos = clampIn(V(own(b, 'pos')), body.radius, body.height || 2 * body.radius); naming('body', 'vel'); e.vel = V(own(b, 'vel')); naming('body', 'heading'); e.heading = dir(own(b, 'heading'), dims); naming();
1069
1142
  G.note = null;
1070
1143
  e.grounded = own(b, 'grounded') === true;
1071
- if (beforeMove) opts.moved!(k.name, tick, e.input, beforeMove, { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: { ...e.m } }, moveGeometry);
1144
+ if (beforeMove) opts.moved!(k.name, tick, e.input, beforeMove, { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: { ...e.m } }, moveGeometry, e.id);
1072
1145
  }
1073
1146
  // Phase 2: for every entity, its commands and then its tick, starting from a different entity each tick.
1074
1147
  const list = [...ents.values()];
@@ -1109,6 +1182,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
1109
1182
  areas = [];
1110
1183
  if (finishing) finishMatch();
1111
1184
  }
1185
+ if(historyTicks){const rows:[string,number,number,number,number][]=[];for(const e of ents.values())if(e.kind.body&&!e.kind.collider&&!e.dead)rows.push([e.id,e.pos.x,e.pos.y,e.pos.z,e.r]);left-=16*rows.length;poseHistory.push([tick,rows]);while(poseHistory.length>historyTicks+1)poseHistory.shift();}
1112
1186
  if (fx.length) { out.push({ t: 'fx', tick, list: fx }); fx = []; }
1113
1187
  if (sharedDirty) { sharedDirty = false; out.push({ t: 'shared' }); }
1114
1188
  stats.tickUnits = c.settings.budget.tick - left;
@@ -1142,7 +1216,9 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
1142
1216
  function save(): SavedCore {
1143
1217
  return {
1144
1218
  v: SAVE_REVISION, tick, epoch, rng, nextId, seq, round: [round.n, round.phase === 'live' ? 1 : 0, round.endsAt, round.startedAt], overAt, match: [playing, restartAt], trips,
1145
- shared: packFields(c.shared, shared, dims), policy: { ...policy }, intent: [endAsked ? 1 : 0, finishing ? 1 : 0], asks: pendingAsks, guideViews: [...guideViews],
1219
+ ...(hasColliders?{collision:collisionState().slice(0,2) as [number,number]}:{}),
1220
+ ...(historyTicks?{history:poseHistory.map(([at,rows])=>[at,rows.map(row=>[...row])] as SavedCore['history'])}:{}),
1221
+ shared: packFields(c.shared, shared, dims), policy: { ...policy }, intent: restartAsked ? [endAsked ? 1 : 0, finishing ? 1 : 0, 1] : [endAsked ? 1 : 0, finishing ? 1 : 0], asks: pendingAsks, guideViews: [...guideViews],
1146
1222
  ents: [...ents.values()].map(saveEnt), spawns: spawns.map(saveEnt),
1147
1223
  seats: [...seats.values()].map((s) => [s.seat, s.driver, s.owner, s.id, s.away ? 1 : 0]),
1148
1224
  queue: queue.map((q) => [q.due, q.from, q.fromId, q.seq, q.to, q.kind, q.ev, q.data, q.at, q.builtIn ? 1 : 0]),
@@ -1159,7 +1235,8 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
1159
1235
  check(Array.isArray(value) && JSON.stringify(value) === JSON.stringify(packFields(list, unpackFields(list, value, dims), dims)));
1160
1236
  };
1161
1237
  check(r.v === SAVE_REVISION);
1162
- check(Object.keys(r).every(k => ['v', 'tick', 'epoch', 'rng', 'nextId', 'seq', 'round', 'overAt', 'match', 'trips', 'shared', 'policy', 'intent', 'asks', 'guideViews', 'ents', 'spawns', 'seats', 'queue', 'areas', 'ops'].includes(k)));
1238
+ if(r.collision!==undefined)check(Array.isArray(r.collision)&&r.collision.length===2&&uint(r.collision[0])&&uint(r.collision[1])&&r.collision[0]<=r.tick&&r.collision[1]===r.collision[0]+1);
1239
+ check(Object.keys(r).every(k => ['v', 'tick', 'epoch', 'rng', 'nextId', 'seq', 'round', 'overAt', 'match', 'trips', 'shared', 'policy', 'intent', 'asks', 'guideViews', 'ents', 'spawns', 'seats', 'queue', 'areas', 'ops', 'history', 'collision'].includes(k)));
1163
1240
  const askNames = new Set();
1164
1241
  check(Array.isArray(r.asks));
1165
1242
  for (const a of r.asks) {
@@ -1175,7 +1252,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
1175
1252
  check(Array.isArray(row) && row.length === 2 && uint(row[0]) && row[0] < c.seats && !views.has(row[0])); views.add(row[0]);
1176
1253
  check(JSON.stringify(row[1]) === JSON.stringify(coerceFields(c.view, row[1], dims)));
1177
1254
  }
1178
- check(r.intent === undefined || Array.isArray(r.intent) && r.intent.length === 2 && r.intent.every(bit));
1255
+ check(r.intent === undefined || Array.isArray(r.intent) && (r.intent.length === 2 || r.intent.length === 3) && r.intent.every(bit));
1179
1256
  for (const v of [r.tick, r.epoch, r.rng, r.nextId, r.seq, r.overAt, r.trips]) check(uint(v));
1180
1257
  check(r.tick < 0xffffffff);
1181
1258
  check(r.rng <= 4294967295 && r.epoch > 0 && r.nextId > 0 && r.seq > 0);
@@ -1284,10 +1361,16 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
1284
1361
  if (r) {
1285
1362
  if (r.v !== SAVE_REVISION) throw new Error('this save was written by another version of the runtime');
1286
1363
  validateSave(r);
1364
+ if(historyTicks){
1365
+ if(!Array.isArray(r.history)||r.history.length>historyTicks+1)throw new Error('saved query history is invalid');
1366
+ let previous=-1;
1367
+ for(const frame of r.history){if(!Array.isArray(frame)||frame.length!==2||!Number.isInteger(frame[0])||frame[0]<=previous||frame[0]>r.tick||frame[0]<r.tick-historyTicks||!Array.isArray(frame[1])||frame[1].length>ENTITY_MAX)throw new Error('saved query history is invalid');previous=frame[0];const ids=new Set<string>();for(const p of frame[1]){if(!Array.isArray(p)||p.length!==5||typeof p[0]!=='string'||p[0].length>24||ids.has(p[0])||!p.slice(1,4).every(v=>typeof v==='number'&&Number.isFinite(v))||!Number.isInteger(p[4])||p[4]<0||p[4]>65535)throw new Error('saved query history is invalid');ids.add(p[0]);}}
1368
+ poseHistory=r.history.map(([at,rows])=>[at,rows.map(row=>[...row])]);
1369
+ }
1287
1370
  pendingAsks = r.asks.map(a => ({ ...a, state: deepFreeze(a.state) }));
1288
1371
  if ([opts.restoreEpoch].some((e) => e !== undefined && (!Number.isSafeInteger(e) || e <= 0))) throw new Error('restored epoch is invalid');
1289
1372
  tick = r.tick; epoch = opts.restoreEpoch ?? r.epoch; rng = r.rng; nextId = r.nextId; seq = r.seq; overAt = r.overAt; playing = r.match[0]; restartAt = r.match[1]; trips = r.trips;
1290
- endAsked = r.intent?.[0] === 1; finishing = r.intent?.[1] === 1;
1373
+ endAsked = r.intent?.[0] === 1; finishing = r.intent?.[1] === 1; restartAsked = r.intent?.[2] === 1;
1291
1374
  round = { n: r.round[0], phase: r.round[1] === 1 ? 'live' : 'over', endsAt: r.round[2], startedAt: r.round[3] };
1292
1375
  refreshRound();
1293
1376
  shared = unpackFields(c.shared, r.shared, dims); sharedChanged();
@@ -1304,6 +1387,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
1304
1387
  });
1305
1388
  areas = r.areas.map((a: any[]) => ({ from: a[0], fromId: a[1], seq: a[2], shape: a[3], ev: a[4], data: deepFreeze(coerceFields(c.events[a[4]] ?? [], a[5], dims)) }));
1306
1389
  ops = r.ops as typeof ops;
1390
+ if(hasColliders)collisionState();
1307
1391
  } else startMatch();
1308
1392
 
1309
1393
  function decisionResult(value: unknown): Record<string, unknown> {
package/rules/guard.ts CHANGED
@@ -37,7 +37,7 @@ export const refusedName = (k: string): boolean => REFUSED_NAMES.has(k) || k.sta
37
37
  * of the rules that ran last: the guarded module sets them as it goes, and the runtime reads them when a handler throws.
38
38
  * `note` is the build check's listener for a written value that had to be changed to fit (pack.ts); a room has none.
39
39
  */
40
- export const G = { left: Infinity, file: '', line: 0, note: null as null | ((what: string, at: string, written: string) => void) };
40
+ export const G = { queryEpoch: 0, left: Infinity, file: '', line: 0, note: null as null | ((what: string, at: string, written: string) => void) };
41
41
  /** The line a guarded call is on, set again after its arguments ran (they may have called into another file). */
42
42
  export function l(file: string, line: number): number { G.file = file; G.line = line; return line; }
43
43
 
package/rules/host.ts CHANGED
@@ -47,7 +47,7 @@ import { AGENT_RULES, useAgents, type Agents, type AgentsSaved, type Vocabulary
47
47
  import { DEFAULT_POLICY, type Netplay, type Peer, type Policy, type Slot } from '../netplay/netplay.ts';
48
48
  import { createCore } from './core.ts';
49
49
  import type { Core, CoreOut, Driver, SavedCore, StepInput } from './core.ts';
50
- import { coerce, coerceFields, num, own, fromBytes, initFields, toBytes, unpackVec } from './pack.ts';
50
+ import { coerce, coerceFields, num, own, fromBytes, initFields, toBytes, unpackVec, unpackFields } from './pack.ts';
51
51
  import type { Compiled, KindTable, Vec3 } from './rules.ts';
52
52
 
53
53
  export interface HostClock {
@@ -58,6 +58,7 @@ export interface HostClock {
58
58
  }
59
59
  export interface HostOptions {
60
60
  game: string;
61
+ record?(owner:string,key:string,value:Record<string,unknown>):void;
61
62
  /** The build check compares a movement step before any tick handler can change it. */
62
63
  moved?: NonNullable<Parameters<typeof createCore>[1]>['moved'];
63
64
  /** Told of every handler as it ends, and what it threw if it threw (core.ts `observe`): the build check listens. */
@@ -100,6 +101,8 @@ export interface Host {
100
101
  readonly paused: boolean;
101
102
  readonly people: number;
102
103
  readonly viewRadiusM: number | null;
104
+ readonly privateDelivery: boolean;
105
+ projectSnapshot(snap: any, seat: number | null): any;
103
106
  readonly viewSettings: Compiled['settings']['view'];
104
107
  frame(m: Record<string, unknown>): void;
105
108
  /** Start the tick loop (the first person has joined). */
@@ -118,8 +121,9 @@ export interface Host {
118
121
  readonly core: Core;
119
122
  }
120
123
 
121
- interface Entry { at: number; values: Record<string, unknown>; presses: string[]; claim: StepInput['claim'] }
124
+ interface Entry { at: number; values: Record<string, unknown>; presses: (string | [string, number])[]; claim: StepInput['claim'] }
122
125
  interface SeatIn {
126
+ pulses?: [string,number][];
123
127
  held: Record<string, unknown>; claim: StepInput['claim']; entries: Entry[];
124
128
  /** The newest stamp this seat has sent, the stamps not yet acknowledged, and the newest acknowledged. */
125
129
  newest: number; stamps: number[]; ack: number;
@@ -225,12 +229,14 @@ export function createHost(o: HostOptions): Host {
225
229
  for (const [seat, q] of saved.inputs) {
226
230
  check(uint(seat) && seat < c.seats && q && valuesOk(seat, q.held) && claimOk(q.claim) && uint(q.newest) && uint(q.ack) && uint(q.lastFrameTick) && Number.isInteger(q.lead) && q.lead >= -127 && q.lead <= 127);
227
231
  check(Array.isArray(q.stamps) && q.stamps.length <= 256 && q.stamps.every(uint) && Array.isArray(q.entries) && q.entries.length <= c.settings.tickHz + 1);
228
- for (const e of q.entries) check(uint(e.at) && valuesOk(seat, e.values) && claimOk(e.claim) && Array.isArray(e.presses) && e.presses.every((p) => c.kinds.some((k) => k.input.some(([name, fd]) => name === p && fd.t === 'press'))));
232
+ if(q.pulses!==undefined)check(Array.isArray(q.pulses)&&q.pulses.length<=16&&q.pulses.every(p=>Array.isArray(p)&&p.length===2&&c.kinds.some(k=>k.input.some(([n,f])=>n===p[0]&&f.t==='pulse'))&&Number.isInteger(p[1])&&p[1]>0&&p[1]<=255));
233
+ for (const e of q.entries) check(uint(e.at) && valuesOk(seat, e.values) && claimOk(e.claim) && Array.isArray(e.presses) && e.presses.every((p) => c.kinds.some((k) => k.input.some(([name, fd]) => (typeof p === 'string' ? name === p && fd.t === 'press' : Array.isArray(p) && p.length === 2 && name === p[0] && fd.t === 'pulse' && Number.isInteger(p[1]) && p[1] > 0 && p[1] <= 255)))));
229
234
  }
230
235
  }
231
236
  }
232
237
  if (saved && saved.v !== 1) throw new Error('this save was written by another version of the runtime');
233
- const core = createCore(c, { moved: o.moved, observe: o.observe, noted: o.noted, seed: Math.floor(random() * 4294967296) >>> 0, epoch: saved ? undefined : (Math.floor(random() * 4294967295) >>> 0) + 1, restore: saved?.core ?? null, restoreEpoch: o.restoreEpoch, stage: o.stage, decisions: !o.check });
238
+ const measuredRtt = new Map<number, number>();
239
+ const core = createCore(c, { rewindTicks: seat => Math.ceil(((measuredRtt.get(seat) ?? 0) + 150 + period) / period), label: (seat,driver)=>nameOf(seat,driver), moved: o.moved, observe: o.observe, noted: o.noted, seed: Math.floor(random() * 4294967296) >>> 0, epoch: saved ? undefined : (Math.floor(random() * 4294967295) >>> 0) + 1, restore: saved?.core ?? null, restoreEpoch: o.restoreEpoch, stage: o.stage, decisions: !o.check });
234
240
  let epoch = core.epoch;
235
241
  if (saved && (o.startPaused ?? o.restoreEpoch !== undefined)) for (const body of core.bodies()) if (body.driver !== 'bot' && body.owner !== 'reserved') core.seatAway(body.seat, true);
236
242
  let lastSaveAt = o.clock.now();
@@ -367,7 +373,7 @@ export function createHost(o: HostOptions): Host {
367
373
  // A restored room keeps each seat's held input, its newest stamp and its acknowledgement.
368
374
  for (const [seat, held, claim, newest, ack] of saved?.queues ?? []) queues.set(seat, { held, claim, entries: [], newest, stamps: [], ack, lead: 127, lastFrameTick: core.tick });
369
375
  for (const [seat, q] of saved?.inputs ?? []) queues.set(seat, q);
370
- const neutral = (kind: KindTable): Record<string, unknown> => { const v = initFields(kind.input, c.dims); for (const [name, fd] of kind.input) if (fd.t === 'press') v[name] = false; return v; };
376
+ const neutral = (kind: KindTable): Record<string, unknown> => { const v = initFields(kind.input, c.dims); for (const [name, fd] of kind.input) if (fd.t === 'press' || fd.t === 'pulse') v[name] = fd.t === 'press' ? false : 0; return v; };
371
377
  function queueOf(seat: number, kind: KindTable): SeatIn {
372
378
  let q = queues.get(seat);
373
379
  if (!q) { q = { held: neutral(kind), claim: null, entries: [], newest: 0, stamps: [], ack: 0, lead: 127, lastFrameTick: core.tick }; queues.set(seat, q); }
@@ -405,11 +411,11 @@ export function createHost(o: HostOptions): Host {
405
411
  if (j > K + tickHz) { stats.dropped += 1; break; }
406
412
  q.newest = j;
407
413
  const values: Record<string, unknown> = {};
408
- const presses: string[] = [];
414
+ const presses: (string | [string, number])[] = [];
409
415
  let i = 1;
410
416
  for (const [name, fd] of kind.input) {
411
417
  // Input values are held to their declared types, whatever was sent.
412
- if (fd.t === 'press') { values[name] = false; if (raw[i] === 1) presses.push(name); } else values[name] = coerce(fd, raw[i], c.dims);
418
+ if (fd.t === 'press') { values[name] = false; if (raw[i] === 1) presses.push(name); } else if (fd.t === 'pulse') { values[name] = 0; } else values[name] = coerce(fd, raw[i], c.dims);
413
419
  i += 1;
414
420
  }
415
421
  const claim = kind.body?.owner && raw.length >= i + 9 ? { pos: unpackVec([raw[i], raw[i + 1], raw[i + 2]], c.dims), vel: unpackVec([raw[i + 3], raw[i + 4], raw[i + 5]], c.dims), heading: unpackVec([raw[i + 6], raw[i + 7], raw[i + 8]], c.dims), r } : null;
@@ -437,6 +443,7 @@ export function createHost(o: HostOptions): Host {
437
443
  }
438
444
  function onFrame(m: Record<string, unknown>): void {
439
445
  switch (m.t) {
446
+ case 'latency': if(Number.isInteger(m.seat) && typeof m.ms==='number' && Number.isFinite(m.ms) && m.ms>=0) measuredRtt.set(m.seat as number,Math.min(m.ms,2000)); return;
440
447
  case 'vocabulary': setAgents((m.vocab ?? null) as Vocabulary | null); return;
441
448
  case 'decided': core.answer(String(m.n ?? ''), m); return;
442
449
  case 'in': return onIn(m);
@@ -486,6 +493,12 @@ export function createHost(o: HostOptions): Host {
486
493
  return;
487
494
  }
488
495
  if (agents && (kind === 'agent:do' || /^ask:/.test(kind) || (SPEECH.test(kind) && present.get(m.from as number) === false))) { if (agentInbox.length < 128) agentInbox.push(m); return; }
496
+ if(kind === 'pulse') {
497
+ const data=m.d,body=core.bodyOf(m.from as number);
498
+ if(!body || !hasHands(m.from as number,body.driver) || !Array.isArray(data) || data.length!==3 || data[0]!==epoch || typeof data[1]!=='string' || !body.kind.input.some(([name,fd])=>name===data[1]&&fd.t==='pulse'))return;
499
+ const value=coerce({t:'pulse'},data[2],c.dims) as number,q=queueOf(m.from as number,body.kind);q.pulses??=[];
500
+ if(value && q.pulses.length<16)q.pulses.push([data[1],value]);return;
501
+ }
489
502
  if (kind === 'cmd') { const d = m.d as unknown[]; if (Array.isArray(d) && typeof d[0] === 'string') core.command(m.from as number, d[0], d[1]); return; }
490
503
  // Speech keeps its payload, except the identity fields only the host may attach.
491
504
  if (SPEECH.test(kind)) o.send({ t: 'ev', from: m.from, k: kind, d: cleanSpeech(m.d) });
@@ -528,6 +541,14 @@ export function createHost(o: HostOptions): Host {
528
541
  for (const x of out) {
529
542
  if (x.t === 'round') {
530
543
  if (x.phase === 'over') forceSave = true;
544
+ if(!retime && x.results && c.records && o.record){
545
+ const declaration=c.records, shared=unpackFields(c.shared,core.shared(),c.dims), snapshot=core.snapshot();
546
+ for(const result of x.results){if(result.driver!=='person')continue;const row=(snapshot[1] as any[]).find(e=>e[0]===result.id);if(!row)continue;const kind=c.kinds[row[1]],fields=unpackFields(kind.fields,row[7],c.dims);if(declaration.eligible&&!fields[declaration.eligible])continue;
547
+ const value:Record<string,unknown>={id:String(shared[declaration.identity]),endedAt:Math.round(now),place:result.place,won:result.place===1&&x.results.filter(r=>r.place===1).length===1};
548
+ for(const [name,source] of Object.entries(declaration.fields))value[name]=Math.round(Number(fields[source]));
549
+ o.record(String(row[12]),declaration.key,value);
550
+ }
551
+ }
531
552
  const ms = (t: number): number => Math.round(now + (t - core.tick) * period);
532
553
  const drivers = new Map(core.bodies().map((b) => [b.seat, b.driver]));
533
554
  o.send({ t: 'round', ...(retime ? { retime: true } : {}), round: {
@@ -556,7 +577,7 @@ export function createHost(o: HostOptions): Host {
556
577
  for (const [seat, q] of queues) {
557
578
  const body = core.bodyOf(seat);
558
579
  if (!body) continue;
559
- const presses: string[] = [];
580
+ const presses: (string | [string, number])[] = [];
560
581
  while (q.entries.length && q.entries[0].at <= t) {
561
582
  const e = q.entries.shift() as Entry;
562
583
  q.held = e.values; q.claim = e.claim;
@@ -566,7 +587,8 @@ export function createHost(o: HostOptions): Host {
566
587
  if (t - q.lastFrameTick > tickHz && !q.entries.length) { q.held = neutral(body.kind); q.claim = null; }
567
588
  while (q.stamps.length && q.stamps[0] <= t) q.ack = q.stamps.shift() as number;
568
589
  const values = { ...q.held };
569
- for (const p of presses) values[p] = true;
590
+ for (const p of presses) { if (typeof p === 'string') values[p] = true; else values[p[0]] = p[1]; }
591
+ const pulse=q.pulses?.shift();if(pulse)values[pulse[0]]=pulse[1];
570
592
  inputs.set(seat, { values, claim: q.claim });
571
593
  }
572
594
  if (roundRebasePending) publishRebase(now);
@@ -769,6 +791,15 @@ export function createHost(o: HostOptions): Host {
769
791
  }
770
792
 
771
793
  return {
794
+ get privateDelivery() { return c.kinds.some(k => k.collider || k.fields.some(([,f]) => f.visibility)); },
795
+ projectSnapshot(snap, seat) {
796
+ return {...snap,d:[snap.d[0],snap.d[1].map((row: any[]) => {
797
+ const k=c.kinds[row[1]], own=seat!==null && row[9]===seat && row[10]!==1;
798
+ if(!k.fields.some(([,f])=>f.visibility))return row;
799
+ const fields=row[7].map((v: unknown,i: number)=>{const visibility=k.fields[i][1].visibility;return !visibility || visibility==='owner'&&own || visibility==='results'&&snap.d[0][1]===0 ? v : null;});
800
+ return [...row.slice(0,7),fields,...row.slice(8)];
801
+ }),...snap.d.slice(2)]};
802
+ },
772
803
  get viewSettings() { return c.settings.view; },
773
804
  get viewRadiusM() { return c.settings.view?.radiusM ?? null; },
774
805
  get tick() { return core.tick; },
@@ -69,7 +69,7 @@ export function snapshotEncoder(keyframeTicks, chained = false) {
69
69
  for (let i = 1; i < entity.length; i++) if (rowText(entity)[i] !== before[i]) { mask |= 1 << i; values.push(entity[i]); }
70
70
  if (mask) changed.push([id, mask, values]);
71
71
  }
72
- const out = { ...snap, d: { base: base.k, round: snap.d[0], ...(snap.d.length > 2 ? { collision: snap.d[2] } : {}), changed, removed: [...rows.keys()].filter(id => !present.has(id)), ...(chained ? { chain: true } : {}), ...(own ? { own } : {}) } };
72
+ const out = { ...snap, d: { base: base.k, round: snap.d[0], ...(snap.d.length > 2 && JSON.stringify(snap.d[2]) !== JSON.stringify(base.d[2]) ? { collision: snap.d[2] } : {}), changed, removed: [...rows.keys()].filter(id => !present.has(id)), ...(chained ? { chain: true } : {}), ...(own ? { own } : {}) } };
73
73
  if (chained) { base = snap; rows = new Map(snap.d[1].map(e => [e[0], rowText(e)])); }
74
74
  return out;
75
75
  },
@@ -103,7 +103,7 @@ export function snapshotDecoder() {
103
103
  const visible = new Map(rows);
104
104
  for (const id of d.removed) visible.delete(id);
105
105
  for (const e of d.own) visible.set(e[0], e);
106
- return { ...snap, d: [d.round, [...visible.values()], ...(d.collision === undefined ? [] : [d.collision])] };
106
+ return { ...snap, d: [d.round, [...visible.values()], ...(d.collision === undefined ? base.d.slice(2) : [d.collision])] };
107
107
  }
108
108
  const next = new Map(rows);
109
109
  for (const id of d.removed) next.delete(id);
@@ -122,7 +122,7 @@ export function snapshotDecoder() {
122
122
  next.set(entity[0], entity);
123
123
  }
124
124
  for (const e of d.own ?? []) next.set(e[0], e);
125
- const decoded = { ...snap, d: [d.round, [...next.values()], ...(d.collision === undefined ? [] : [d.collision])] };
125
+ const decoded = { ...snap, d: [d.round, [...next.values()], ...(d.collision === undefined ? base.d.slice(2) : [d.collision])] };
126
126
  if (d.chain) { base = decoded; rows = next; }
127
127
  return decoded;
128
128
  },
package/rules/live.ts CHANGED
@@ -1,22 +1,25 @@
1
+ import { rayQuery, raySnapshot, type QueryTarget } from './query.ts';
1
2
  import { overlapsTerrain } from './terrain.ts';
2
3
  import { nearbyMap } from './map-index.ts';
3
4
  /** Declared collision geometry, shared by authority and prediction. Entity fields
4
5
  * own its lifetime and dimensions; this projection contains no gameplay state. */
5
- import type { Vec3, ColliderDef } from './rules.ts';
6
+ import type { Vec3, ColliderDef, QueryDef } from './rules.ts';
6
7
  import type { MapShapes } from './math.ts';
7
8
  import { charge, own } from './guard.ts';
8
9
  import { castMap3, solidAt, type BodyShape } from './collision.ts';
9
10
  import { sweepMap, rayBox, rayCircle } from './math.ts';
10
11
 
11
- export type ColliderRow = [string, string, number, number, number, number, number, number];
12
+ export type ColliderRow = [string, string, number, number, number, number, number, number, {kind: string; query?: QueryDef; fields: Record<string, number | string | boolean>}?];
12
13
  export type CollisionRevision = [number, number, ColliderRow[]];
13
- export function colliderRow(id: string, at: Vec3, body: BodyShape, def: ColliderDef, fields: Record<string, unknown>): ColliderRow | null {
14
+ export function colliderRow(id: string, at: Vec3, body: BodyShape, def: ColliderDef, fields: Record<string, unknown>, kind?: string, query?: QueryDef): ColliderRow | null {
14
15
  const opts = def === true ? {} : def;
15
16
  if (opts.enabled && fields[opts.enabled] !== true) return null;
16
17
  const size = opts.size ? fields[opts.size] as Vec3 : null;
17
18
  const x = size ? size.x : body.radius * 2, y = size ? size.y : body.radius * 2, z = size ? size.z : body.height || body.radius * 2;
18
19
  if (![x, y, z].every(Number.isFinite) || x <= 0 || y <= 0 || z < 0) return null;
19
- return [id, size ? 'box' : body.shape, at.x, at.y, at.z, x, y, z];
20
+ const row: ColliderRow = [id, size ? 'box' : body.shape, at.x, at.y, at.z, x, y, z];
21
+ if(kind) row.push({kind, ...(query?{query}:{}), fields:Object.fromEntries((opts.fields ?? []).filter(name=>['number','string','boolean'].includes(typeof fields[name])).map(name=>[name,fields[name]])) as Record<string,number|string|boolean>});
22
+ return row;
20
23
  }
21
24
  export function colliderSolid(row: ColliderRow): ReturnType<typeof solidAt> {
22
25
  const [, shape, x, y, z, w, d, h] = row;
@@ -27,6 +30,10 @@ export function castCollider2(row: ColliderRow, p: Vec3, d: Vec3, radius: number
27
30
  const solid = colliderSolid(row);
28
31
  return row[1] === 'box' ? rayBox(p.x,p.y,d.x,d.y,solid.min,solid.max,radius) : rayCircle(p.x,p.y,d.x,d.y,row[2],row[3],row[5]/2+radius);
29
32
  }
33
+ const rowsOf = new WeakMap<object, readonly ColliderRow[]>();
34
+ export function collisionTargets(map: MapShapes): QueryTarget[] {
35
+ return (rowsOf.get(map) ?? []).map(row=>({id:row[0],kind:row[8]?.kind??'',query:row[8]?.query,fields:row[8]?.fields??{},geometry:true,solid:colliderSolid(row),at:{x:row[2],y:row[3],z:row[4]}}));
36
+ }
30
37
  export function collisionMap(map: MapShapes, rows: readonly ColliderRow[]): MapShapes {
31
38
  if (!rows.length) return map;
32
39
  const boxes: any[] = [], circles: any[] = [], spheres: any[] = [], capsules: any[] = [];
@@ -37,7 +44,9 @@ export function collisionMap(map: MapShapes, rows: readonly ColliderRow[]): MapS
37
44
  else if (shape === 'sphere') spheres.push({ at: { x, y, z: z + w / 2 }, r: w / 2, id } as any);
38
45
  else capsules.push({ at, r: w / 2, height: h, id } as any);
39
46
  }
40
- return { bounds: map.bounds, staticMap: map, boxes, circles, spheres, capsules };
47
+ const combined = { bounds: map.bounds, staticMap: map, boxes, circles, spheres, capsules };
48
+ rowsOf.set(combined, rows);
49
+ return combined;
41
50
  }
42
51
  /** Latest known revision at the requested tick. Unknown future edits are never
43
52
  * guessed: a later snapshot rebases the body and replays its pending inputs. */
@@ -45,14 +54,17 @@ export function revisionAt(history: readonly CollisionRevision[], tick: number):
45
54
  for (let i = history.length - 1; i >= 0; i--) if (history[i][1] <= tick) return history[i];
46
55
  return undefined;
47
56
  }
48
- export function collisionQueries(map: () => MapShapes, shape: () => BodyShape, dims: number): object {
57
+ export function collisionQueries(map: () => MapShapes, shape: () => BodyShape, dims: number, self: () => string | undefined = () => undefined): object {
49
58
  const position = (body: unknown): Vec3 => {
50
59
  const p = own(body, 'pos');
51
60
  const n = (k: string): number => { const v = own(p, k); return typeof v === 'number' && Number.isFinite(v) ? Math.fround(v) : 0; };
52
61
  return { x: n('x'), y: n('y'), z: n('z') };
53
62
  };
54
63
  return {
55
- sweep: (body: unknown, delta: unknown) => sweepMap(map(), body, delta, shape().radius, dims, shape()),
64
+ ray: (from: unknown, direction: unknown, max: unknown, options?: unknown) => {const m=map();return rayQuery(m.staticMap??m,collisionTargets(m),dims,from,direction,max,options,self())[0];},
65
+ rays: (options?: unknown) => {const m=map();return raySnapshot(m.staticMap??m,collisionTargets(m),dims,options,self());},
66
+ rayAll: (from: unknown, direction: unknown, max: unknown, options?: unknown) => {const m=map();return rayQuery(m.staticMap??m,collisionTargets(m),dims,from,direction,max,options,self(),true);},
67
+ sweep: (body: unknown, delta: unknown, options?: unknown) => sweepMap(map(), body, delta, shape().radius, dims, shape(), options),
56
68
  support: (body: unknown, distance: unknown = 0.002) => {
57
69
  charge(20);
58
70
  const p = position(body), m = map();
@@ -46,6 +46,12 @@ export function nearbyMap(map: MapShapes, p:Vec3, d:Vec3, radius:number, height:
46
46
  function visit(n:Node){if(!touches(n))return;if(n.entries){for(const e of n.entries)if(touches(e))found.push(e);}else{visit(n.left!);visit(n.right!);}}
47
47
  visit(root);charge(found.length*Math.ceil(Math.log2(found.length+1)));
48
48
  found.sort((a,b)=>a.order-b.order);for(const e of found)out[e.key].push(e.value);
49
- if(parent)for(const key of ['boxes','circles','spheres','capsules','heightTiles'] as const)for(const v of map[key]??[]){charge(12);out[key].push(v);}
49
+ if(parent)for(const key of ['boxes','circles','spheres','capsules','heightTiles'] as const)for(const raw of map[key]??[]){
50
+ const v:any=raw;let min:Vec3,max:Vec3;
51
+ if(key==='boxes'){min=v.min;max=v.max;}
52
+ else if(key==='heightTiles'){min={...v.at,z:v.at.z+Math.min(v.base??Infinity,...v.heights)};max={x:v.at.x+v.size.x,y:v.at.y+v.size.y,z:v.at.z+Math.max(...v.heights)};}
53
+ else{min={x:v.at.x-v.r,y:v.at.y-v.r,z:key==='circles'?-Infinity:key==='capsules'?v.at.z:v.at.z-v.r};max={x:v.at.x+v.r,y:v.at.y+v.r,z:key==='circles'?Infinity:key==='capsules'?v.at.z+v.height:v.at.z+v.r};}
54
+ if(touches({min,max}))out[key].push(raw);
55
+ }
50
56
  return out;
51
57
  }