@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.
@@ -2649,6 +2649,90 @@
2649
2649
  }
2650
2650
  }
2651
2651
  }
2652
+ },
2653
+ "0.45.3": {
2654
+ "files": {
2655
+ "CLAUDE.md": "ddf2b2a0376202f6",
2656
+ "README.md": "684b3cca8001673c",
2657
+ "HANDOFF.md": "29337e2ace95b2e7",
2658
+ "games/README.md": "5ad32fe80c61f117",
2659
+ "music/README.md": "a3dc042dadd39df8",
2660
+ "videos/README.md": "16babc3437d5545b",
2661
+ "posts/README.md": "37841aae476bd540",
2662
+ "site/README.md": "732894273a32dc3f",
2663
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
2664
+ },
2665
+ "sections": {
2666
+ "AGENTS.md": {
2667
+ "(intro)": "214aa46eb3a895d5",
2668
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
2669
+ "## Layout": "f3a280b8c96cd068",
2670
+ "## Apps: one screen, roles and parts": "8596f2b25edda3e2",
2671
+ "## Commands (all through the pinned CLI in node_modules)": "159151b8861fc051",
2672
+ "## The site": "0cb8dd9a6f8a0096",
2673
+ "## Making games": "bb715272fd1a615b",
2674
+ "## The Game Codex and progress": "0738ef719cef3a4c",
2675
+ "## Art direction and models": "a48fff7bf0a5f2fc",
2676
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
2677
+ "## Servers and AI seats": "1ccfd549d2369a60",
2678
+ "## Selling things (the shop)": "9e020d69dd6a954e",
2679
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
2680
+ "## Rules": "c48971c1437414c2",
2681
+ "## Telling Homie": "80b46fdd9e578db8",
2682
+ "## Beta": "9d02638207a26e73"
2683
+ }
2684
+ },
2685
+ "named": {
2686
+ "Homie Arcade": {
2687
+ "sections": {
2688
+ "AGENTS.md": {
2689
+ "## Commands (all through the pinned CLI in node_modules)": "2cdd530e48170bfd"
2690
+ }
2691
+ }
2692
+ }
2693
+ }
2694
+ },
2695
+ "0.46.0": {
2696
+ "files": {
2697
+ "CLAUDE.md": "ddf2b2a0376202f6",
2698
+ "README.md": "684b3cca8001673c",
2699
+ "HANDOFF.md": "29337e2ace95b2e7",
2700
+ "games/README.md": "5ad32fe80c61f117",
2701
+ "music/README.md": "a3dc042dadd39df8",
2702
+ "videos/README.md": "16babc3437d5545b",
2703
+ "posts/README.md": "37841aae476bd540",
2704
+ "site/README.md": "732894273a32dc3f",
2705
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
2706
+ },
2707
+ "sections": {
2708
+ "AGENTS.md": {
2709
+ "(intro)": "214aa46eb3a895d5",
2710
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
2711
+ "## Layout": "f3a280b8c96cd068",
2712
+ "## Apps: one screen, roles and parts": "8596f2b25edda3e2",
2713
+ "## Commands (all through the pinned CLI in node_modules)": "159151b8861fc051",
2714
+ "## The site": "0cb8dd9a6f8a0096",
2715
+ "## Making games": "bb715272fd1a615b",
2716
+ "## The Game Codex and progress": "0738ef719cef3a4c",
2717
+ "## Art direction and models": "a48fff7bf0a5f2fc",
2718
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
2719
+ "## Servers and AI seats": "1ccfd549d2369a60",
2720
+ "## Selling things (the shop)": "9e020d69dd6a954e",
2721
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
2722
+ "## Rules": "c48971c1437414c2",
2723
+ "## Telling Homie": "80b46fdd9e578db8",
2724
+ "## Beta": "9d02638207a26e73"
2725
+ }
2726
+ },
2727
+ "named": {
2728
+ "Homie Arcade": {
2729
+ "sections": {
2730
+ "AGENTS.md": {
2731
+ "## Commands (all through the pinned CLI in node_modules)": "2cdd530e48170bfd"
2732
+ }
2733
+ }
2734
+ }
2735
+ }
2652
2736
  }
2653
2737
  }
2654
2738
  }
@@ -2106,7 +2106,9 @@ from outside the studio run on its server.
2106
2106
  The Table writes the whole room to its SQLite `save` table in one synchronous transaction:
2107
2107
  entities, motion, shared state, rounds, seats and their tokens, pending events, timers and
2108
2108
  inputs, RNG state, tick, epoch, state hash and build hash. Saves larger than 2 MB use several
2109
- rows in that transaction. The default interval is `room.durability.movementSeconds: 1`;
2109
+ rows in that transaction. The default interval is `room.durability.movementSeconds: 1`
2110
+ (30 for a `lifetime: "persistent"` world, which writes its `durable` fields to a journal the moment they change and
2111
+ saves the rest on this interval);
2110
2112
  round end, `world.finish()` and pause save immediately. Both elapsed ticks and elapsed wall
2111
2113
  time bound the interval, including ticks that catch up after a delay. Cloudflare's output
2112
2114
  gate holds outgoing frames until preceding writes are confirmed.
@@ -2395,6 +2395,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2395
2395
  return;
2396
2396
  }
2397
2397
  case 'state': if (typeof m['k'] === 'string') applyState(m['k'], m['d']); return;
2398
+ case 'probe': if (typeof m.n === 'string') raw({t:'probeAck',n:m.n}); return;
2398
2399
  case 'pong': return onPong(m);
2399
2400
  case 'announce': {
2400
2401
  if (typeof m['id'] !== 'string') return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.45.2",
3
+ "version": "0.46.0",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -1,12 +1,13 @@
1
1
  import { castTerrain } from './terrain.ts';
2
+ import { castTerrain as legacyTerrain } from './terrain-legacy.ts';
2
3
  import { nearbyMap } from './map-index.ts';
3
4
  /** Upright 3D collision. Positions are feet; a rounded box is a box plus a sphere.
4
5
  * Capsules are a vertical segment plus a sphere. No engine-specific maths or time.
5
6
  */
6
- import { charge } from './guard.ts';
7
+ import { charge, G } from './guard.ts';
7
8
  import type { Vec3, MapBox, MapCircle, MapHeightTile } from './rules.ts';
8
9
 
9
- export interface BodyShape { shape: string; radius: number; height: number }
10
+ export interface BodyShape { shape: string; radius: number; height: number; terrain?: 'exact' }
10
11
  export interface Solid { min: Vec3; max: Vec3; r: number }
11
12
  export interface Hit3 { t: number; nx: number; ny: number; nz: number; id?: string }
12
13
  export interface Map3 {
@@ -14,7 +15,7 @@ export interface Map3 {
14
15
  bounds: MapBox; boxes: readonly MapBox[]; circles: readonly MapCircle[];
15
16
  heightTiles?: readonly MapHeightTile[];
16
17
  spheres?: readonly MapCircle[];
17
- capsules?: readonly (MapCircle & { height: number })[];
18
+ capsules?: readonly (MapCircle & { height: number; terrain?: 'exact' })[];
18
19
  }
19
20
  const EPS = 1e-7;
20
21
  const point = (x: number, y: number, z: number): {x: number; y: number; z: number} => ({ x, y, z });
@@ -33,8 +34,15 @@ function separation(a: Solid, b: Solid, d: Vec3, t: number): Vec3 {
33
34
  /** Continuous convex cast: each separating plane gives a lower bound on impact.
34
35
  * Every iteration is charged, and a fixed cap bounds grazing contacts as well.
35
36
  */
36
- export function castSolid(a: Solid, d: Vec3, b: Solid): Hit3 | null {
37
+ export function castSolid(a: Solid, d: Vec3, b: Solid, includeInside = false): Hit3 | null {
37
38
  charge(30);
39
+ if (includeInside) {
40
+ const gap = separation(a, b, d, 0);
41
+ const sq = gap.x*gap.x + gap.y*gap.y + gap.z*gap.z;
42
+ const radius=a.r+b.r;
43
+ const inside = radius > 0 ? sq < radius*radius : a.max.x > b.min.x && a.min.x < b.max.x && a.max.y > b.min.y && a.min.y < b.max.y && a.max.z > b.min.z && a.min.z < b.max.z;
44
+ if (inside) {const length = Math.sqrt(d.x*d.x+d.y*d.y+d.z*d.z)||1; return {t:0,nx:-d.x/length,ny:-d.y/length,nz:-d.z/length};}
45
+ }
38
46
  let t = 0;
39
47
  const r = a.r + b.r;
40
48
  let normal = point(0, 0, 0);
@@ -94,12 +102,27 @@ function castHeightTile(tile: MapHeightTile, p: Vec3, d: Vec3): Hit3 | null {
94
102
  return best;
95
103
  }
96
104
 
97
- export function castMap3(map: Map3, p: Vec3, d: Vec3, body: BodyShape): Hit3 | null {
105
+ let memoEpoch = -1;
106
+ let castMemo = new WeakMap<object, Map<string, Hit3 | null>>();
107
+ export function castMap3(map: Map3, p: Vec3, d: Vec3, body: BodyShape, hits?: Hit3[], includeInside = false): Hit3 | null {
108
+ // Exact casts can reuse identical immutable-scene queries within one handler.
109
+ // The epoch resets at every authority handler, including after restore.
110
+ if(body.terrain !== 'exact' || hits || !Number.isFinite(G.left)) return castMap3Uncached(map,p,d,body,hits,includeInside);
111
+ if(memoEpoch!==G.queryEpoch){memoEpoch=G.queryEpoch;castMemo=new WeakMap();}
112
+ charge(16);
113
+ let rows=castMemo.get(map);if(!rows){rows=new Map();castMemo.set(map,rows);}
114
+ const key=[p.x,p.y,p.z,d.x,d.y,d.z,body.shape,body.radius,body.height,includeInside].join(',');
115
+ if(rows.has(key))return rows.get(key)!;
116
+ const hit=castMap3Uncached(map,p,d,body,hits,includeInside);
117
+ if(rows.size<512)rows.set(key,hit);
118
+ return hit;
119
+ }
120
+ function castMap3Uncached(map: Map3, p: Vec3, d: Vec3, body: BodyShape, hits?: Hit3[], includeInside = false): Hit3 | null {
98
121
  map = nearbyMap(map, p, d, body.radius, body.height || body.radius * 2);
99
122
  charge(24);
100
123
  const a = solidAt(p, body), r = body.radius, height = body.height || r * 2;
101
124
  let best: Hit3 | null = null;
102
- const take = (h: Hit3 | null, id?: string): void => { if (h && h.t >= 0 && h.t <= 1 && (!best || h.t < best.t)) best = id ? {...h, id} : h; };
125
+ const take = (h: Hit3 | null, id?: string): void => { if (h && h.t >= 0 && h.t <= 1) { const hit = id ? {...h, id} : h; if (hits) {charge(8); hits.push(hit);} if (!best || h.t < best.t) best = hit; } };
103
126
  for (const axis of ['x', 'y', 'z'] as const) {
104
127
  const low = map.bounds.min[axis] + (axis === 'z' ? 0 : r);
105
128
  const high = map.bounds.max[axis] - (axis === 'z' ? height : r);
@@ -107,12 +130,29 @@ export function castMap3(map: Map3, p: Vec3, d: Vec3, body: BodyShape): Hit3 | n
107
130
  const n = point(0, 0, 0); n[axis] = d[axis] > 0 ? -1 : 1;
108
131
  take({ t: ((d[axis] > 0 ? high : low) - p[axis]) / d[axis], nx: n.x, ny: n.y, nz: n.z });
109
132
  }
110
- for (const tile of map.heightTiles ?? []) take(tile.base === undefined ? castHeightTile(tile, p, d) : castTerrain(tile, a, d));
111
- for (const b of map.boxes) take(castSolid(a, d, { ...b, r: 0 }), (b as any).id);
133
+ const exactTerrain = body.terrain === 'exact';
134
+ const terrain = [...(map.heightTiles ?? [])];
135
+ if(exactTerrain && d.z<0){charge(terrain.length*Math.ceil(Math.log2(terrain.length+1)));terrain.sort((a,b)=>(b.at.z+Math.max(...b.heights))-(a.at.z+Math.max(...a.heights)));}
136
+ // Visit the nearest possible tile contacts first so later casts are shortened.
137
+ // All-hit queries retain authored ordering; legacy casts retain cost and behavior.
138
+ if(exactTerrain && (d.x||d.y) && !hits){
139
+ const first=(t:MapHeightTile)=>{let near=0;for(const k of ['x','y'] as const){if(d[k])near=Math.max(near,((d[k]>0?t.at[k]-r:t.at[k]+t.size[k]+r)-p[k])/d[k]);}return near;};
140
+ charge(terrain.length*(16+Math.ceil(Math.log2(terrain.length+1))));const order=new Map(terrain.map(t=>[t,first(t)]));terrain.sort((a,b)=>order.get(a)!-order.get(b)!);
141
+ }
142
+ for (const tile of terrain) {
143
+ if(exactTerrain && !hits && best?.t===0)break;
144
+ // Once a nearer surface is known, later solids only need to beat that time.
145
+ // This also bounds work on densely tessellated terrain behind the first hit.
146
+ const limit = exactTerrain && !hits && best ? best.t : 1;
147
+ const delta = limit < 1 ? point(d.x * limit, d.y * limit, d.z * limit) : d;
148
+ const hit = tile.base === undefined ? castHeightTile(tile, p, delta) : (exactTerrain ? castTerrain : legacyTerrain)(tile, a, delta);
149
+ if (hit) take({...hit, t: hit.t * limit});
150
+ }
151
+ for (const b of map.boxes) take(castSolid(a, d, { ...b, r: 0 }, includeInside), (b as any).id);
112
152
  // Legacy map circles are vertical columns in a 3D map.
113
- for (const c of map.circles) take(castSolid(a, d, { min: point(c.at.x, c.at.y, map.bounds.min.z - r), max: point(c.at.x, c.at.y, map.bounds.max.z + r), r: c.r }), (c as any).id);
114
- for (const s of map.spheres ?? []) take(castSolid(a, d, { min: s.at, max: s.at, r: s.r }), (s as any).id);
115
- for (const c of map.capsules ?? []) take(castSolid(a, d, solidAt(c.at, { shape: 'capsule', radius: c.r, height: c.height })), (c as any).id);
153
+ for (const c of map.circles) take(castSolid(a, d, { min: point(c.at.x, c.at.y, map.bounds.min.z - r), max: point(c.at.x, c.at.y, map.bounds.max.z + r), r: c.r }, includeInside), (c as any).id);
154
+ for (const s of map.spheres ?? []) take(castSolid(a, d, { min: s.at, max: s.at, r: s.r }, includeInside), (s as any).id);
155
+ for (const c of map.capsules ?? []) take(castSolid(a, d, solidAt(c.at, { shape: 'capsule', radius: c.r, height: c.height }), includeInside), (c as any).id);
116
156
  return best;
117
157
  }
118
158
  export function restsOnMap(map: Map3, p: Vec3, body: BodyShape): boolean {
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.
@@ -77,7 +79,7 @@ export const IGNORE_MAX = 16;
77
79
  export const ANSWER_MAX = 256;
78
80
 
79
81
  export type Driver = 'person' | 'bot' | 'ai';
80
- export interface SeatInfo { seat: number; driver: Driver; owner: string }
82
+ export interface SeatInfo { seat: number; driver: Driver; owner: string; character?: import('./rules.ts').Character | null }
81
83
  /** One seat's step for a tick: its input values (already held to their types) and, for an owner-moved body, its claim. */
82
84
  export interface StepInput { values: Record<string, unknown>; claim?: { pos: Vec3; vel: Vec3; heading: Vec3; r: number } | null }
83
85
  export interface ResultRow { seat: number; id: string; driver: Driver; score: number; place: number }
@@ -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[];
@@ -159,6 +163,7 @@ export interface Core {
159
163
  round(): Extract<CoreOut, { t: 'round' }>;
160
164
  bodies(): Body[];
161
165
  /** The body in a seat, as the host runtime needs it for the input protocol. */
166
+ character(seat: number): import('./rules.ts').Character | null;
162
167
  bodyOf(seat: number): { id: string; kind: KindTable; r: number; away: boolean; driver: Driver } | null;
163
168
  /** What happened since the last call: rounds, effects, a changed roster, changed shared state, a new epoch, a failure. */
164
169
  drain(): CoreOut[];
@@ -179,7 +184,7 @@ export interface Core {
179
184
  * `noted`: told, as it happens, of a value the rules wrote that the runtime changed to make it fit, or dropped (pack.ts
180
185
  * `Adjusted`; also `effect`, `think` and `decision`): the handler, what was done, the field's name and what was written.
181
186
  */
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 {
187
+ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number) => import('./rules.ts').Character | null | undefined; characterSaved?: (seat: number, character: import('./rules.ts').Character) => void; 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
188
  const dims = c.dims;
184
189
  const tickHz = c.settings.tickHz;
185
190
  const dt = 1 / tickHz;
@@ -210,6 +215,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
210
215
  let restartAt = 0;
211
216
  let finishing = false;
212
217
  let endAsked = false;
218
+ let restartAsked = false;
213
219
  let vocabulary: Vocabulary | null = null;
214
220
  const restoredGoals = new Map<number, unknown>();
215
221
  const guideViews = new Map<number, Record<string, unknown>>();
@@ -369,6 +375,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
369
375
  * budget; and the counter and the scope are put back whatever happens. Nothing of the rules' is touched after this
370
376
  * returns.
371
377
  */
378
+ let dispatchDepth = 0;
372
379
  function run(kind: string, handler: string, ent: Ent | null, scope: Ctx['scope'], fn: () => void, always: boolean | 'body' = false): boolean {
373
380
  if (left <= 0) {
374
381
  cut = true;
@@ -387,7 +394,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
387
394
  const E = Error as unknown as { stackTraceLimit?: number };
388
395
  const limit = E.stackTraceLimit;
389
396
  E.stackTraceLimit = 0;
390
- G.left = quota;
397
+ G.queryEpoch++; G.left = quota;
391
398
  // 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
399
  if (noted) G.note = (what, at, written) => noted(kind, handler, what, at, written);
393
400
  let bad = false; let thrown: unknown;
@@ -463,7 +470,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
463
470
  }));
464
471
  let roundApi: Readonly<Record<string, unknown>> = Object.freeze({});
465
472
  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; } }));
473
+ 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
474
  };
468
475
  refreshRound();
469
476
  /** `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 +485,32 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
478
485
  return sharedRO;
479
486
  };
480
487
 
488
+ const historyTicks = Math.ceil((c.historySeconds ?? 0) * tickHz);
489
+ let poseHistory: [number, [string, number, number, number, number][]][] = [];
490
+ function rayTargets(options?: unknown): QueryTarget[] {
491
+ charge(8 * ents.size);
492
+ const targets: QueryTarget[] = [];
493
+ const filterKind=own(options,'kind'),filterLayer=own(options,'layer'),filterTag=own(options,'tag'),geometryOnly=own(options,'geometryOnly')===true;
494
+
495
+ const requested = own(options,'atTick');
496
+ if (requested !== undefined && (typeof requested !== 'number' || !Number.isFinite(requested))) throw new Error('ray.atTick is a finite simulation tick');
497
+ if (requested !== undefined && !historyTicks) throw new Error('ray.atTick needs room.historySeconds');
498
+ const allowance = cx.ent?.driver === 'person' ? Math.min(historyTicks,Math.max(0,opts.rewindTicks?.(cx.ent.seat) ?? 0)) : historyTicks;
499
+ const wanted = typeof requested === 'number' ? Math.max(tick-allowance,Math.min(tick,requested)) : tick;
500
+ let before: typeof poseHistory[number] | undefined, after: typeof poseHistory[number] | undefined;
501
+ if(wanted<tick)for(const frame of poseHistory){charge(2);if(frame[0]<=wanted)before=frame;if(frame[0]>=wanted){after=frame;break;}}
502
+ before ??= poseHistory[0];after ??= poseHistory[poseHistory.length-1];
503
+ for(const e of ents.values()) {
504
+ 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;
505
+ charge(64);
506
+ const row=e.kind.collider?colliderRow(e.id,e.pos,e.kind.body,e.kind.collider,e.f):null;
507
+ if(e.kind.collider&&!row)continue;
508
+ let at=e.pos;
509
+ 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};}}
510
+ 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});
511
+ }
512
+ return targets;
513
+ }
481
514
  function cast3(p: Vec3, d: Vec3, shape: BodyShape, ignored: Set<string>): Hit3 | null {
482
515
  let best = castMap3(c.map, p, d, shape);
483
516
  const solid = solidAt(p, shape);
@@ -514,6 +547,23 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
514
547
  if (cx.scope !== 'ent' && cx.scope !== 'room') throw new Error('world.send is for handlers');
515
548
  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
549
  },
550
+ dispatch: (target: unknown, ev: unknown, data?: unknown): boolean => {
551
+ charge(SEND);
552
+ if (cx.scope !== 'ent' && cx.scope !== 'room') throw new Error('world.dispatch is for handlers');
553
+ if (dispatchDepth >= 8) throw new Error('world.dispatch permits at most eight nested deliveries');
554
+ const payload = shapeData(c.events, ev, 'the event', data);
555
+ const e = typeof target === 'string' ? ents.get(target) : undefined;
556
+ const fn = e && typeof ev === 'string' ? e.kind.on[ev] : undefined;
557
+ if (!e || e.dead || !fn) return false;
558
+ // Synchronous delivery shares the caller's remaining quota. Each handler
559
+ // still writes only its own entity, and collection settlement stays scoped.
560
+ const parent = cx, parentTouched = touched, note = G.note;
561
+ cx = {scope:'ent',ent:e}; touched = []; dispatchDepth++;
562
+ if (noted) G.note = (what, at, written) => noted(e.kind.name, `on.${String(ev)}`, what, at, written);
563
+ try { fn(world,e.self,payload); }
564
+ finally { try { settle(); } finally { cx=parent; touched=parentTouched; G.note=note; dispatchDepth--; } }
565
+ return true;
566
+ },
517
567
  sendRoom: (ev: unknown, data?: unknown): void => {
518
568
  charge(SEND);
519
569
  if (cx.scope !== 'ent' && cx.scope !== 'room') throw new Error('world.sendRoom is for handlers');
@@ -608,7 +658,8 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
608
658
  charge(cost);
609
659
  return Object.freeze(found.map(viewOf));
610
660
  },
611
- ray: (from: unknown, direction: unknown, max: unknown): unknown => {
661
+ ray: (from: unknown, direction: unknown, max: unknown, options?: unknown): unknown => {
662
+ if (options !== undefined) return rayQuery(c.map, rayTargets(options), dims, from, direction, max, options, cx.ent?.id)[0];
612
663
  // Charged before the cast, for every shape it may test: the map's, and every entity in the room.
613
664
  charge(20 + 4 * ents.size);
614
665
  const p = V(from); const d = dir(direction, dims); const far = reach(max, 'world.ray');
@@ -630,6 +681,14 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
630
681
  if (!best) return undefined;
631
682
  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
683
  },
684
+ route: (graph: unknown, from: unknown, to: unknown, options?: unknown) => {
685
+ const radius=own(options,'radius')??.5,height=own(options,'height')??1.8;
686
+ 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');
687
+ 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);
688
+ },
689
+ 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);},
690
+ rays: (options?: unknown): unknown => raySnapshot(c.map,rayTargets(options),dims,options,cx.ent?.id),
691
+ 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
692
  sweep: (self: unknown, delta: unknown, o?: unknown): unknown => {
634
693
  const e = mine(self, 'world.sweep');
635
694
  charge(20 + 4 * ents.size);
@@ -688,13 +747,19 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
688
747
  Object.freeze(world);
689
748
 
690
749
  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
- })];
750
+ let collisionPrevious: CollisionRevision | null = null, collisionText = '';
751
+ const collisionState = (): CollisionRevision => {
752
+ const rows = [...ents.values()].flatMap(e => {
753
+ if (e.dead || !e.kind.collider || !e.kind.body) return [];
754
+ const row = colliderRow(e.id,e.pos,e.kind.body,e.kind.collider,e.f,e.kind.name,e.kind.query);
755
+ return row ? [row] : [];
756
+ });
757
+ const text = JSON.stringify(rows);
758
+ if (!collisionPrevious || text !== collisionText) {collisionText=text;collisionPrevious=[...(collisionPrevious ? [tick,tick+1] : opts.restore?.collision ?? [tick,tick+1]) as [number,number],rows];}
759
+ return collisionPrevious;
760
+ };
696
761
  let moveGeometry = c.map as import('./math.ts').MapShapes;
697
- const queries = collisionQueries(() => moveGeometry, () => moveShape, dims);
762
+ const queries = collisionQueries(() => moveGeometry, () => moveShape, dims, () => cx.ent?.id);
698
763
  const moveWorld = brand(Object.freeze(queries));
699
764
  const moveMap = brand(Object.freeze({ name: c.map.name, spot: mapApi.spot, spots: mapApi.spots, ...queries }));
700
765
  let moveShape = { shape: 'sphere', radius: 0, height: 0 };
@@ -774,7 +839,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
774
839
  return rows;
775
840
  }
776
841
  function joinBody(info: SeatInfo): Ent | null {
777
- const player = Object.freeze({ seat: info.seat, driver: info.driver, owner: info.owner });
842
+ const player = Object.freeze({ seat: info.seat, driver: info.driver, owner: info.owner, ...(info.character !== undefined ? { character: deepFreeze(info.character) } : {}) });
778
843
  let made: Ent | null = null;
779
844
  run('room', 'join', null, 'join', () => {
780
845
  // What room.join returns is read by its own data properties, and its fields are charged before they are held to their shapes.
@@ -800,6 +865,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
800
865
  } else {
801
866
  if (e) {
802
867
  if (e.kind.on.leave) run(e.kind.name, 'on.leave', e, 'ent', () => e.kind.on.leave(world, e.self, Object.freeze({})));
868
+ opts.characterSaved?.(e.seat, JSON.parse(JSON.stringify({ kind: e.kind.name, at: e.pos, heading: e.heading, fields: e.f, motion: e.m })));
803
869
  e.dead = true; ents.delete(e.id);
804
870
  }
805
871
  seats.delete(s.seat);
@@ -833,13 +899,19 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
833
899
  // The seat changed hands without a goodbye: its old holder has left.
834
900
  if (s && s.driver !== 'bot') { leaveBody(s); s = seats.get(info.seat); }
835
901
  let bot: Ent | null = s && s.id ? ents.get(s.id) ?? null : null;
836
- if (!bot && playerBodies().length >= c.bots) {
902
+ const previousBot = bot;
903
+ if (!bot && playerBodies().length >= c.bots || bot?.kind.player?.takeover) {
837
904
  // 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;
905
+ const priority = (e: Ent): number => e.kind.player?.takeover ? Number(e.f[e.kind.player.takeover]) : 0;
906
+ 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
907
  }
840
908
  if (bot) {
841
909
  const e = bot;
842
910
  seats.delete(e.seat);
911
+ if (previousBot && previousBot !== e) {
912
+ previousBot.seat = e.seat;
913
+ seats.set(previousBot.seat, {seat:previousBot.seat, driver:'bot', owner:'', id:previousBot.id, away:false});
914
+ }
843
915
  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
916
  seats.set(info.seat, { seat: info.seat, driver: info.driver, owner: info.owner, id: e.id, away: false });
845
917
  out.push({ t: 'seats' });
@@ -882,6 +954,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
882
954
  const seat = seats.get(e.seat);
883
955
  if (seat) leaveBody(seat);
884
956
  }
957
+ poseHistory = [];
885
958
  round = { n: round.n + 1, phase: 'live', endsAt: c.rounds && c.rounds.seconds > 0 ? tick + ticks(c.rounds.seconds) : 0, startedAt: tick };
886
959
  overAt = 0;
887
960
  refreshRound();
@@ -904,9 +977,10 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
904
977
  if (tick < restartAt) return;
905
978
  startMatch();
906
979
  // Seats stay through a finished match: every holder's body is made again, as if it had just joined.
907
- 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 }] : [])); }
980
+ 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, ...(opts.characterForSeat ? { character: opts.characterForSeat(s.seat) } : {}) } }, ...(s.away ? [{ op: 'away' as const, seat: s.seat, away: true }] : [])); }
908
981
  return;
909
982
  }
983
+ if (restartAsked) { restartAsked = false; endAsked = false; startRound(); return; }
910
984
  if (round.phase === 'live' && (endAsked || (round.endsAt > 0 && tick >= round.endsAt))) endRound();
911
985
  endAsked = false;
912
986
  if (overAt && tick >= overAt + 2) {
@@ -918,9 +992,12 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
918
992
  } else if (round.phase === 'over' && !overAt && tick >= round.endsAt) startRound();
919
993
  }
920
994
  function finishMatch(): void {
921
- finishing = false;
995
+ finishing = false; restartAsked = false;
922
996
  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
- for (const e of ents.values()) e.dead = true;
997
+ for (const e of ents.values()) {
998
+ if (e.kind.player) opts.characterSaved?.(e.seat, JSON.parse(JSON.stringify({ kind: e.kind.name, at: e.pos, heading: e.heading, fields: e.f, motion: e.m })));
999
+ e.dead = true;
1000
+ }
924
1001
  ents.clear(); spawns = []; queue = []; pendingAsks = []; areas = []; fx = [];
925
1002
  for (const s of seats.values()) s.id = null;
926
1003
  shared = initFields(c.shared, dims); sharedChanged();
@@ -1014,12 +1091,13 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
1014
1091
  const body = k.body as NonNullable<KindTable['body']>;
1015
1092
  let claim: StepInput['claim'] = null;
1016
1093
  if (k.player) {
1017
- const driven = (e.driver === 'person' || (e.driver === 'ai' && inputs.has(e.seat))) && !e.away;
1094
+ const permitted = !k.player.control || Boolean(e.m[k.player.control]);
1095
+ const driven = permitted && (e.driver === 'person' || (e.driver === 'ai' && inputs.has(e.seat))) && !e.away;
1018
1096
  if (driven) {
1019
1097
  const s = inputs.get(e.seat);
1020
1098
  e.input = Object.freeze(s ? coerceFields(k.input, s.values, dims) : initFields(k.input, dims));
1021
1099
  claim = s?.claim ?? null;
1022
- } else if (k.think && (e.driver !== 'person' || k.player.away === 'think')) {
1100
+ } else if (k.think && (!permitted || e.driver !== 'person' || k.player.away === 'think')) {
1023
1101
  // `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
1102
  let stepIn: Record<string, unknown> | null = null;
1025
1103
  run(k.name, 'think', e, 'ent', () => {
@@ -1068,7 +1146,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
1068
1146
  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
1147
  G.note = null;
1070
1148
  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);
1149
+ 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
1150
  }
1073
1151
  // Phase 2: for every entity, its commands and then its tick, starting from a different entity each tick.
1074
1152
  const list = [...ents.values()];
@@ -1109,6 +1187,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
1109
1187
  areas = [];
1110
1188
  if (finishing) finishMatch();
1111
1189
  }
1190
+ 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
1191
  if (fx.length) { out.push({ t: 'fx', tick, list: fx }); fx = []; }
1113
1192
  if (sharedDirty) { sharedDirty = false; out.push({ t: 'shared' }); }
1114
1193
  stats.tickUnits = c.settings.budget.tick - left;
@@ -1142,7 +1221,9 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
1142
1221
  function save(): SavedCore {
1143
1222
  return {
1144
1223
  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],
1224
+ ...(hasColliders?{collision:collisionState().slice(0,2) as [number,number]}:{}),
1225
+ ...(historyTicks?{history:poseHistory.map(([at,rows])=>[at,rows.map(row=>[...row])] as SavedCore['history'])}:{}),
1226
+ 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
1227
  ents: [...ents.values()].map(saveEnt), spawns: spawns.map(saveEnt),
1147
1228
  seats: [...seats.values()].map((s) => [s.seat, s.driver, s.owner, s.id, s.away ? 1 : 0]),
1148
1229
  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 +1240,8 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
1159
1240
  check(Array.isArray(value) && JSON.stringify(value) === JSON.stringify(packFields(list, unpackFields(list, value, dims), dims)));
1160
1241
  };
1161
1242
  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)));
1243
+ 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);
1244
+ 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
1245
  const askNames = new Set();
1164
1246
  check(Array.isArray(r.asks));
1165
1247
  for (const a of r.asks) {
@@ -1175,7 +1257,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
1175
1257
  check(Array.isArray(row) && row.length === 2 && uint(row[0]) && row[0] < c.seats && !views.has(row[0])); views.add(row[0]);
1176
1258
  check(JSON.stringify(row[1]) === JSON.stringify(coerceFields(c.view, row[1], dims)));
1177
1259
  }
1178
- check(r.intent === undefined || Array.isArray(r.intent) && r.intent.length === 2 && r.intent.every(bit));
1260
+ check(r.intent === undefined || Array.isArray(r.intent) && (r.intent.length === 2 || r.intent.length === 3) && r.intent.every(bit));
1179
1261
  for (const v of [r.tick, r.epoch, r.rng, r.nextId, r.seq, r.overAt, r.trips]) check(uint(v));
1180
1262
  check(r.tick < 0xffffffff);
1181
1263
  check(r.rng <= 4294967295 && r.epoch > 0 && r.nextId > 0 && r.seq > 0);
@@ -1284,10 +1366,16 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
1284
1366
  if (r) {
1285
1367
  if (r.v !== SAVE_REVISION) throw new Error('this save was written by another version of the runtime');
1286
1368
  validateSave(r);
1369
+ if(historyTicks){
1370
+ if(!Array.isArray(r.history)||r.history.length>historyTicks+1)throw new Error('saved query history is invalid');
1371
+ let previous=-1;
1372
+ 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]);}}
1373
+ poseHistory=r.history.map(([at,rows])=>[at,rows.map(row=>[...row])]);
1374
+ }
1287
1375
  pendingAsks = r.asks.map(a => ({ ...a, state: deepFreeze(a.state) }));
1288
1376
  if ([opts.restoreEpoch].some((e) => e !== undefined && (!Number.isSafeInteger(e) || e <= 0))) throw new Error('restored epoch is invalid');
1289
1377
  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;
1378
+ endAsked = r.intent?.[0] === 1; finishing = r.intent?.[1] === 1; restartAsked = r.intent?.[2] === 1;
1291
1379
  round = { n: r.round[0], phase: r.round[1] === 1 ? 'live' : 'over', endsAt: r.round[2], startedAt: r.round[3] };
1292
1380
  refreshRound();
1293
1381
  shared = unpackFields(c.shared, r.shared, dims); sharedChanged();
@@ -1304,6 +1392,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
1304
1392
  });
1305
1393
  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
1394
  ops = r.ops as typeof ops;
1395
+ if(hasColliders)collisionState();
1307
1396
  } else startMatch();
1308
1397
 
1309
1398
  function decisionResult(value: unknown): Record<string, unknown> {
@@ -1372,7 +1461,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
1372
1461
  if (!floorCall && value) guideViews.set(seat, value);
1373
1462
  return value;
1374
1463
  },
1375
- seatJoin: (info) => { ops.push({ op: 'join', info: { seat: info.seat, driver: info.driver, owner: String(info.owner ?? '').slice(0, 128) } }); },
1464
+ seatJoin: (info) => { ops.push({ op: 'join', info: { seat: info.seat, driver: info.driver, owner: String(info.owner ?? '').slice(0, 128), ...(info.character !== undefined ? { character: info.character } : {}) } }); },
1376
1465
  seatAway: (seat, away) => { ops.push({ op: 'away', seat, away }); },
1377
1466
  seatLeave: (seat) => { ops.push({ op: 'leave', seat }); },
1378
1467
  setPolicy: (p) => { policy = normalizeCorePolicy(p); },
@@ -1393,6 +1482,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
1393
1482
  return [[round.n, round.phase === 'live' ? 1 : 0, round.endsAt], list, ...(collision ? [collision] : [])];
1394
1483
  },
1395
1484
  shared: () => packFields(c.shared, shared, dims),
1485
+ character: (seat) => { const e = playerBodies().find(e => e.seat === seat); return e ? JSON.parse(JSON.stringify({ kind: e.kind.name, at: e.pos, heading: e.heading, fields: e.f, motion: e.m })) : null; },
1396
1486
  bodies: () => playerBodies().map((e) => ({ seat: e.seat, id: e.id, kind: e.kind.name, driver: e.driver, owner: e.owner, away: e.away, score: e.kind.score ? Number(e.f[e.kind.score]) || 0 : 0, r: e.r })),
1397
1487
  bodyOf: (seat) => { const s = seats.get(seat); const e = s?.id ? ents.get(s.id) : null; return e ? { id: e.id, kind: e.kind, r: e.r, away: e.away, driver: e.driver } : null; },
1398
1488
  drain: () => { const o = out; out = []; return o; },
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