@homie-rocks/studio 0.44.0 → 0.44.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.
@@ -2426,6 +2426,90 @@
2426
2426
  }
2427
2427
  }
2428
2428
  }
2429
+ },
2430
+ "0.44.1": {
2431
+ "files": {
2432
+ "CLAUDE.md": "ddf2b2a0376202f6",
2433
+ "README.md": "684b3cca8001673c",
2434
+ "HANDOFF.md": "29337e2ace95b2e7",
2435
+ "games/README.md": "5ad32fe80c61f117",
2436
+ "music/README.md": "a3dc042dadd39df8",
2437
+ "videos/README.md": "16babc3437d5545b",
2438
+ "posts/README.md": "37841aae476bd540",
2439
+ "site/README.md": "732894273a32dc3f",
2440
+ "site/src/worker.mjs": "34444a6ca4b14437"
2441
+ },
2442
+ "sections": {
2443
+ "AGENTS.md": {
2444
+ "(intro)": "214aa46eb3a895d5",
2445
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
2446
+ "## Layout": "f3a280b8c96cd068",
2447
+ "## Apps: one screen, roles and parts": "8596f2b25edda3e2",
2448
+ "## Commands (all through the pinned CLI in node_modules)": "159151b8861fc051",
2449
+ "## The site": "0cb8dd9a6f8a0096",
2450
+ "## Making games": "9e44559de535d842",
2451
+ "## The Game Codex and progress": "0738ef719cef3a4c",
2452
+ "## Art direction and models": "a48fff7bf0a5f2fc",
2453
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
2454
+ "## Servers and AI seats": "d0028361fe567add",
2455
+ "## Selling things (the shop)": "9e020d69dd6a954e",
2456
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
2457
+ "## Rules": "2e6b29afb85dcf7e",
2458
+ "## Telling Homie": "80b46fdd9e578db8",
2459
+ "## Beta": "9d02638207a26e73"
2460
+ }
2461
+ },
2462
+ "named": {
2463
+ "Homie Arcade": {
2464
+ "sections": {
2465
+ "AGENTS.md": {
2466
+ "## Commands (all through the pinned CLI in node_modules)": "2cdd530e48170bfd"
2467
+ }
2468
+ }
2469
+ }
2470
+ }
2471
+ },
2472
+ "0.44.3": {
2473
+ "files": {
2474
+ "CLAUDE.md": "ddf2b2a0376202f6",
2475
+ "README.md": "684b3cca8001673c",
2476
+ "HANDOFF.md": "29337e2ace95b2e7",
2477
+ "games/README.md": "5ad32fe80c61f117",
2478
+ "music/README.md": "a3dc042dadd39df8",
2479
+ "videos/README.md": "16babc3437d5545b",
2480
+ "posts/README.md": "37841aae476bd540",
2481
+ "site/README.md": "732894273a32dc3f",
2482
+ "site/src/worker.mjs": "34444a6ca4b14437"
2483
+ },
2484
+ "sections": {
2485
+ "AGENTS.md": {
2486
+ "(intro)": "214aa46eb3a895d5",
2487
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
2488
+ "## Layout": "f3a280b8c96cd068",
2489
+ "## Apps: one screen, roles and parts": "8596f2b25edda3e2",
2490
+ "## Commands (all through the pinned CLI in node_modules)": "159151b8861fc051",
2491
+ "## The site": "0cb8dd9a6f8a0096",
2492
+ "## Making games": "9e44559de535d842",
2493
+ "## The Game Codex and progress": "0738ef719cef3a4c",
2494
+ "## Art direction and models": "a48fff7bf0a5f2fc",
2495
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
2496
+ "## Servers and AI seats": "d0028361fe567add",
2497
+ "## Selling things (the shop)": "9e020d69dd6a954e",
2498
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
2499
+ "## Rules": "2e6b29afb85dcf7e",
2500
+ "## Telling Homie": "80b46fdd9e578db8",
2501
+ "## Beta": "9d02638207a26e73"
2502
+ }
2503
+ },
2504
+ "named": {
2505
+ "Homie Arcade": {
2506
+ "sections": {
2507
+ "AGENTS.md": {
2508
+ "## Commands (all through the pinned CLI in node_modules)": "2cdd530e48170bfd"
2509
+ }
2510
+ }
2511
+ }
2512
+ }
2429
2513
  }
2430
2514
  }
2431
2515
  }
@@ -1,23 +1,67 @@
1
+ import {createHash} from 'node:crypto';
1
2
  import {refreshWorkerConfig} from './worker-config.mjs';
2
3
  import {pathToFileURL} from 'node:url';
3
- import {existsSync,mkdirSync,writeFileSync,rmSync} from 'node:fs';
4
- import {join,relative} from 'node:path';
4
+ import {existsSync,mkdirSync,writeFileSync,renameSync,readFileSync,mkdtempSync,rmSync} from 'node:fs';
5
+ import {join,relative,basename,sep,resolve} from 'node:path';
5
6
  import {readConfig} from './routes.mjs';
7
+ import {workerDir} from './studio.mjs';
6
8
  import {paidReleases} from './parts-upload.mjs';
7
9
  /** Preserve dynamic imports as real Worker modules: page startup never evaluates MCP libraries. */
8
10
  export async function buildWorker(root,esbuild) {
9
11
  const source=join(root,'site/src/worker.mjs');if(!existsSync(source))return;
10
12
  const out=join(root,'site/src/runtime');
11
- refreshWorkerConfig(root);
13
+
12
14
  const alias={...(readConfig(root)?.alias??{})};
13
15
  const toolModule=join(root,'site/src/tools/index.mjs');
14
16
  const pricedTools=existsSync(toolModule) && (await import(pathToFileURL(toolModule).href+'?worker='+Date.now())).default.some(tool=>tool.price);
15
17
  if(paidReleases(root).length || existsSync(join(root,'shop.json')) || pricedTools)alias['@homie-rocks/studio/worker']='@homie-rocks/studio/worker/selling';
16
18
  const result=await esbuild.build({entryPoints:[source],absWorkingDir:root,alias,outdir:out,bundle:true,splitting:true,format:'esm',platform:'browser',mainFields:['module','main'],conditions:['workerd','worker','browser'],external:['node:*','cloudflare:*'],target:'es2022',write:false,minify:true,metafile:true,logLevel:'silent'});
17
- rmSync(out,{recursive:true,force:true});
19
+ // A watcher may already have read the previous entry while this build is
20
+ // publishing. Keep its entire graph available. Publish immutable modules first,
21
+ // then switch the single entry atomically; never remove a running graph.
22
+ const generation=createHash('sha256');
23
+ for(const file of result.outputFiles)generation.update(basename(file.path)).update(file.contents);
24
+ const id=generation.digest('hex').slice(0,24),dir=join(out,id);
18
25
  mkdirSync(out,{recursive:true});
19
- for(const file of result.outputFiles)writeFileSync(file.path,file.contents);
20
- const sizes=Object.entries(result.metafile.outputs).map(([path,value])=>({path:relative(root,path),bytes:value.bytes,imports:value.imports}));
26
+ if(!existsSync(dir)) {
27
+ const stage=mkdtempSync(join(out,'.generation-'));
28
+ try {
29
+ for(const file of result.outputFiles)writeFileSync(join(stage,basename(file.path)),file.contents);
30
+ try {renameSync(stage,dir);} catch(error) {if(!existsSync(dir))throw error;}
31
+ } finally {rmSync(stage,{recursive:true,force:true});}
32
+ }
33
+ const entry=`export * from './${id}/worker.js';\nexport {default} from './${id}/worker.js';\n`;
34
+ const target=join(out,'worker.js');
35
+ if(!existsSync(target)||readFileSync(target,'utf8')!==entry) {
36
+ const temp=join(out,`.worker-${process.pid}.tmp`);
37
+ writeFileSync(temp,entry);renameSync(temp,target);
38
+ }
39
+ // Wrangler's module root is the immutable generation, so old generations are
40
+ // retained locally for in-flight reloads but are never added to a deployment.
41
+ refreshWorkerConfig(root);
42
+ const current=join(out,'current.json'), pending=`${current}.${process.pid}.tmp`;
43
+ writeFileSync(pending,JSON.stringify({main:join(dir,'worker.js')})+'\n');renameSync(pending,current);
44
+ // Dev sometimes watches a copy with routes/remote AI removed. Publish its
45
+ // entry too, keeping its local bindings and its module root at one generation.
46
+ const local=join(workerDir(root),'.wrangler','homie-dev.wrangler.json');
47
+ if(existsSync(local)) {
48
+ const config=JSON.parse(readFileSync(local,'utf8'));
49
+ if(typeof config.main==='string' && config.main.startsWith(out+sep)) {
50
+ config.main=join(dir,'worker.js');
51
+ const text=JSON.stringify(config,null,2)+'\n';
52
+ if(readFileSync(local,'utf8')!==text) {const temp=`${local}.${process.pid}.tmp`;writeFileSync(temp,text);renameSync(temp,local);}
53
+ }
54
+ }
55
+ const sizes=Object.entries(result.metafile.outputs).map(([path,value])=>({path:relative(root,join(dir,basename(path))),bytes:value.bytes,imports:value.imports}));
21
56
  writeFileSync(join(out,'modules.json'),JSON.stringify(sizes,null,2)+'\n');
22
57
  return sizes;
23
58
  }
59
+
60
+ /** Local immutable entry; the committed config always names the stable shim. */
61
+ export function workerEntry(root) {
62
+ const main=readConfig(root)?.main;
63
+ // Explicit custom entry points remain the owner's choice.
64
+ if(typeof main!=='string' || resolve(workerDir(root),main)!==resolve(root,'site/src/runtime/worker.js'))return null;
65
+ const file=join(root,'site/src/runtime/current.json');
66
+ return existsSync(file)?JSON.parse(readFileSync(file,'utf8')).main:null;
67
+ }
@@ -1,4 +1,4 @@
1
- import {readFileSync,writeFileSync,existsSync} from 'node:fs';
1
+ import {readFileSync,writeFileSync,existsSync,renameSync} from 'node:fs';
2
2
  import {join} from 'node:path';
3
3
  import {configPath,readStudio,layoutOf,listExperiences} from './studio.mjs';
4
4
  import {wranglerConfig} from './scaffold.mjs';
@@ -14,7 +14,7 @@ function merge(base,custom) {
14
14
  export function updateWorkerConfig(root,generated) {
15
15
  const file=configPath(root),next=merge(parse(generated),read(join(root,'wrangler.custom.json')));
16
16
  const text='// Generated by homie-studio. Edit studio.json or wrangler.custom.json, then build.\n'+JSON.stringify(next,null,2)+'\n';
17
- if(!existsSync(file)||readFileSync(file,'utf8')!==text)writeFileSync(file,text);
17
+ if(!existsSync(file)||readFileSync(file,'utf8')!==text) {const temp=`${file}.${process.pid}.tmp`;writeFileSync(temp,text);renameSync(temp,file);}
18
18
  }
19
19
  export function refreshWorkerConfig(root,overrides={}) {
20
20
  const studio=readStudio(root),cf=studio.cloudflare;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.44.0",
3
+ "version": "0.44.3",
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",
@@ -121,7 +121,8 @@
121
121
  "apps/",
122
122
  "app-starters/",
123
123
  "tools/",
124
- "functions/"
124
+ "functions/",
125
+ "guides/"
125
126
  ],
126
127
  "engines": {
127
128
  "node": ">=22"
@@ -160,6 +161,7 @@
160
161
  "zod": "4.3.6"
161
162
  },
162
163
  "scripts": {
163
- "test": "node --test --test-concurrency=2 --test-timeout=1800000 test/*.test.mjs"
164
+ "test": "node --test --test-concurrency=2 --test-timeout=1800000 test/*.test.mjs",
165
+ "prepack": "node ../../scripts/authoring-guides.mjs --check"
164
166
  }
165
167
  }
@@ -1,3 +1,5 @@
1
+ import { castTerrain } from './terrain.ts';
2
+ import { nearbyMap } from './map-index.ts';
1
3
  /** Upright 3D collision. Positions are feet; a rounded box is a box plus a sphere.
2
4
  * Capsules are a vertical segment plus a sphere. No engine-specific maths or time.
3
5
  */
@@ -8,13 +10,14 @@ export interface BodyShape { shape: string; radius: number; height: number }
8
10
  export interface Solid { min: Vec3; max: Vec3; r: number }
9
11
  export interface Hit3 { t: number; nx: number; ny: number; nz: number; id?: string }
10
12
  export interface Map3 {
13
+ staticMap?: Map3;
11
14
  bounds: MapBox; boxes: readonly MapBox[]; circles: readonly MapCircle[];
12
15
  heightTiles?: readonly MapHeightTile[];
13
16
  spheres?: readonly MapCircle[];
14
17
  capsules?: readonly (MapCircle & { height: number })[];
15
18
  }
16
19
  const EPS = 1e-7;
17
- const point = (x: number, y: number, z: number): Vec3 => ({ x, y, z });
20
+ const point = (x: number, y: number, z: number): {x: number; y: number; z: number} => ({ x, y, z });
18
21
  export function solidAt(at: Vec3, body: BodyShape): Solid {
19
22
  const r = body.radius, height = body.height || 2 * r;
20
23
  if (body.shape === 'box') return { min: point(at.x - r, at.y - r, at.z), max: point(at.x + r, at.y + r, at.z + height), r: 0 };
@@ -74,15 +77,17 @@ function castHeightTile(tile: MapHeightTile, p: Vec3, d: Vec3): Hit3 | null {
74
77
  const x = p.x - tile.at.x, y = p.y - tile.at.y;
75
78
  let best: Hit3 | null = null;
76
79
  for (const half of [0, 1]) {
80
+ const alternate = tile.diagonal === '10-01';
77
81
  const gx = half === 0 ? (h10 - h00) / sx : (h11 - h01) / sx;
78
- const gy = half === 0 ? (h11 - h10) / sy : (h01 - h00) / sy;
79
- const gap = p.z - tile.at.z - h00 - gx * x - gy * y;
82
+ const gy = alternate ? (half === 0 ? h01 - h00 : h11 - h10) / sy : (half === 0 ? h11 - h10 : h01 - h00) / sy;
83
+ const base = alternate && half === 1 ? h10 + h01 - h11 : h00;
84
+ const gap = p.z - tile.at.z - base - gx * x - gy * y;
80
85
  const closing = gx * d.x + gy * d.y - d.z;
81
86
  if (gap < -EPS || closing <= EPS) continue;
82
87
  const t = Math.max(0, gap / closing);
83
88
  if (t > 1 || best && t >= best.t) continue;
84
89
  const u = (x + d.x * t) / sx, v = (y + d.y * t) / sy;
85
- if (u < -EPS || u > 1 + EPS || v < -EPS || v > 1 + EPS || (half === 0 ? v > u + EPS : u > v + EPS)) continue;
90
+ if (u < -EPS || u > 1 + EPS || v < -EPS || v > 1 + EPS || (alternate ? (half === 0 ? u + v > 1 + EPS : u + v < 1 - EPS) : (half === 0 ? v > u + EPS : u > v + EPS))) continue;
86
91
  const length = Math.sqrt(gx * gx + gy * gy + 1);
87
92
  best = { t, nx: -gx / length, ny: -gy / length, nz: 1 / length };
88
93
  }
@@ -90,10 +95,11 @@ function castHeightTile(tile: MapHeightTile, p: Vec3, d: Vec3): Hit3 | null {
90
95
  }
91
96
 
92
97
  export function castMap3(map: Map3, p: Vec3, d: Vec3, body: BodyShape): Hit3 | null {
98
+ map = nearbyMap(map, p, d, body.radius, body.height || body.radius * 2);
93
99
  charge(24);
94
100
  const a = solidAt(p, body), r = body.radius, height = body.height || r * 2;
95
101
  let best: Hit3 | null = null;
96
- const take = (h: Hit3 | null): void => { if (h && h.t >= 0 && h.t <= 1 && (!best || h.t < best.t)) best = h; };
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; };
97
103
  for (const axis of ['x', 'y', 'z'] as const) {
98
104
  const low = map.bounds.min[axis] + (axis === 'z' ? 0 : r);
99
105
  const high = map.bounds.max[axis] - (axis === 'z' ? height : r);
@@ -101,12 +107,12 @@ export function castMap3(map: Map3, p: Vec3, d: Vec3, body: BodyShape): Hit3 | n
101
107
  const n = point(0, 0, 0); n[axis] = d[axis] > 0 ? -1 : 1;
102
108
  take({ t: ((d[axis] > 0 ? high : low) - p[axis]) / d[axis], nx: n.x, ny: n.y, nz: n.z });
103
109
  }
104
- for (const tile of map.heightTiles ?? []) take(castHeightTile(tile, p, d));
105
- for (const b of map.boxes) take(castSolid(a, d, { ...b, r: 0 }));
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);
106
112
  // Legacy map circles are vertical columns in a 3D map.
107
- 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 }));
108
- for (const s of map.spheres ?? []) take(castSolid(a, d, { min: s.at, max: s.at, r: s.r }));
109
- for (const c of map.capsules ?? []) take(castSolid(a, d, solidAt(c.at, { shape: 'capsule', radius: c.r, height: c.height })));
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);
110
116
  return best;
111
117
  }
112
118
  export function restsOnMap(map: Map3, p: Vec3, body: BodyShape): boolean {
package/rules/core.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { colliderSolid, castCollider2, colliderRow, collisionMap, collisionQueries, type CollisionRevision } from './live.ts';
1
2
  /*
2
3
  * core.ts — one room's world, stepped a tick at a time from a compiled rules module.
3
4
  * =============================================================================
@@ -41,7 +42,7 @@
41
42
  import { castMap3, castSolid, restsOnMap, solidAt, type BodyShape, type Hit3 } from './collision.ts';
42
43
  import { argsWhy, type Vocabulary } from '../agents/agents.ts';
43
44
  import { BudgetError, G, brand, charge, deepFreeze, plainData } from './guard.ts';
44
- import { SKIN, castMap, exact, math, rayCircle, sweepMap } from './math.ts';
45
+ import { SKIN, castMap, exact, math, rayCircle } from './math.ts';
45
46
  import type { Hit } from './math.ts';
46
47
  import { AHEAD, ZERO, coerce, coerceFields, dir, est, estFields, initFields, mutable, num, own, packEntity, packFields, packVec, packed, said, thaw, naming, unpackFields, unpackVec, vec3 } from './pack.ts';
47
48
  import { ENTITY_MAX, QUEUE_MAX, REACH_M, SAVE_REVISION, cellsOf } from './rules.ts';
@@ -178,7 +179,7 @@ export interface Core {
178
179
  * `noted`: told, as it happens, of a value the rules wrote that the runtime changed to make it fit, or dropped (pack.ts
179
180
  * `Adjusted`; also `effect`, `think` and `decision`): the handler, what was done, the field's name and what was written.
180
181
  */
181
- 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) => 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 {
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 {
182
183
  const dims = c.dims;
183
184
  const tickHz = c.settings.tickHz;
184
185
  const dt = 1 / tickHz;
@@ -244,7 +245,6 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
244
245
  let stateHeavy = false;
245
246
  const viewCost = new Map<KindTable, number>();
246
247
  for (const k of c.kinds) viewCost.set(k, VIEW + k.fields.length + k.motion.length);
247
- const mapShapes = 4 + c.map.boxes.length + c.map.circles.length;
248
248
 
249
249
  const ticks = (seconds: unknown): number => { const s = num(seconds); const n = Math.round(s * tickHz); return s > 0 && Number.isFinite(n) ? Math.max(1, n) : 0; };
250
250
  const V = (v: unknown): Vec3 => vec3(v, dims);
@@ -483,7 +483,10 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
483
483
  const solid = solidAt(p, shape);
484
484
  for (const other of ents.values()) {
485
485
  if (!other.kind.body || ignored.has(other.id)) continue;
486
- const hit = castSolid(solid, d, solidAt(other.pos, other.kind.body));
486
+ if (other.kind.collider) charge(48);
487
+ const row = other.kind.collider ? colliderRow(other.id, other.pos, other.kind.body, other.kind.collider, other.f) : null;
488
+ if (other.dead || other.kind.collider && !row) continue;
489
+ const hit = castSolid(solid, d, row ? colliderSolid(row) : solidAt(other.pos, other.kind.body));
487
490
  if (hit && (!best || hit.t < best.t)) best = { ...hit, id: other.id };
488
491
  }
489
492
  return best;
@@ -607,7 +610,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
607
610
  },
608
611
  ray: (from: unknown, direction: unknown, max: unknown): unknown => {
609
612
  // Charged before the cast, for every shape it may test: the map's, and every entity in the room.
610
- charge(20 + 4 * (mapShapes + ents.size));
613
+ charge(20 + 4 * ents.size);
611
614
  const p = V(from); const d = dir(direction, dims); const far = reach(max, 'world.ray');
612
615
  if (dims === 3) {
613
616
  const delta = { x: d.x * far, y: d.y * far, z: d.z * far };
@@ -618,7 +621,10 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
618
621
  let best: Hit | null = cast.hit;
619
622
  for (const e of ents.values()) {
620
623
  if (!e.kind.body || e === cx.ent) continue;
621
- const h = rayCircle(p.x, p.y, d.x * far, d.y * far, e.pos.x, e.pos.y, e.kind.body.radius);
624
+ if (e.kind.collider) charge(48);
625
+ const row = e.kind.collider ? colliderRow(e.id, e.pos, e.kind.body, e.kind.collider, e.f) : null;
626
+ if (e.dead || e.kind.collider && !row) continue;
627
+ const h = row ? castCollider2(row, p, {x:d.x*far,y:d.y*far,z:0}, 0) : rayCircle(p.x, p.y, d.x * far, d.y * far, e.pos.x, e.pos.y, e.kind.body.radius);
622
628
  if (h && (!best || h.t < best.t)) best = { ...h, id: e.id };
623
629
  }
624
630
  if (!best) return undefined;
@@ -626,7 +632,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
626
632
  },
627
633
  sweep: (self: unknown, delta: unknown, o?: unknown): unknown => {
628
634
  const e = mine(self, 'world.sweep');
629
- charge(20 + 4 * (mapShapes + ents.size));
635
+ charge(20 + 4 * ents.size);
630
636
  const d = V(delta);
631
637
  const radius = e.kind.body?.radius ?? 0;
632
638
  // The ids to pass through: the first few of a list, read as plain texts. A longer list is not searched once for every entity.
@@ -648,7 +654,10 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
648
654
  let best: Hit | null = cast.hit;
649
655
  for (const other of ents.values()) {
650
656
  if (other === e || !other.kind.body || ignore.has(other.id)) continue;
651
- const h = rayCircle(e.pos.x, e.pos.y, d.x, d.y, other.pos.x, other.pos.y, other.kind.body.radius + radius);
657
+ if (other.kind.collider) charge(48);
658
+ const row = other.kind.collider ? colliderRow(other.id, other.pos, other.kind.body, other.kind.collider, other.f) : null;
659
+ if (other.dead || other.kind.collider && !row) continue;
660
+ const h = row ? castCollider2(row, e.pos, d, radius) : rayCircle(e.pos.x, e.pos.y, d.x, d.y, other.pos.x, other.pos.y, other.kind.body.radius + radius);
652
661
  if (h && (!best || h.t < best.t)) best = { ...h, id: other.id };
653
662
  }
654
663
  const l = Math.sqrt(d.x * d.x + d.y * d.y);
@@ -678,11 +687,19 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
678
687
  });
679
688
  Object.freeze(world);
680
689
 
681
- const moveMap = brand(Object.freeze({ name: c.map.name, spot: mapApi.spot, spots: mapApi.spots, sweep: (body: unknown, delta: unknown): unknown => sweepMap(c.map, body, delta, moveRadius, dims, moveShape) }));
682
- let moveRadius = 0;
690
+ 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
+ })];
696
+ let moveGeometry = c.map as import('./math.ts').MapShapes;
697
+ const queries = collisionQueries(() => moveGeometry, () => moveShape, dims);
698
+ const moveWorld = brand(Object.freeze(queries));
699
+ const moveMap = brand(Object.freeze({ name: c.map.name, spot: mapApi.spot, spots: mapApi.spots, ...queries }));
683
700
  let moveShape = { shape: 'sphere', radius: 0, height: 0 };
684
701
  const moveCtx = brand({} as Record<string, unknown>);
685
- for (const [name, get] of Object.entries({ tick: () => tick, dt: () => dt, tune: () => c.publicTune, math: () => math, map: () => moveMap })) Object.defineProperty(moveCtx, name, { get, enumerable: true });
702
+ for (const [name, get] of Object.entries({ tick: () => tick, dt: () => dt, tune: () => c.publicTune, math: () => math, map: () => moveMap, world: () => moveWorld })) Object.defineProperty(moveCtx, name, { get, enumerable: true });
686
703
  Object.assign(moveCtx, { ticks: (seconds: unknown): number => { charge(1); return ticks(seconds); } });
687
704
  Object.freeze(moveCtx);
688
705
  const joinCtx = brand({} as Record<string, unknown>);
@@ -988,6 +1005,8 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
988
1005
  }
989
1006
  }
990
1007
  guides?.();
1008
+ // All movers see the same world, independent of entity iteration order.
1009
+ if (hasColliders) { left -= 64 * ents.size; moveGeometry = collisionMap(c.map, collisionState()[2]); }
991
1010
  // Phase 1: one input step and `move` for every body.
992
1011
  for (const e of [...ents.values()]) {
993
1012
  if (e.dead || !e.kind.body) continue;
@@ -1040,7 +1059,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
1040
1059
  if (!k.move) continue;
1041
1060
  const b = { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: e.mself };
1042
1061
  const beforeMove = opts.moved ? { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: { ...e.m } } : null;
1043
- moveRadius = body.radius; moveShape = body;
1062
+ moveShape = body;
1044
1063
  run(k.name, 'move', e, 'move', () => (k.move as NonNullable<KindTable['move']>)(b, e.input, moveCtx), 'body');
1045
1064
  // The runtime rounds to 32-bit floats, here and in the browser, so both step from exactly the same numbers.
1046
1065
  // `b` is the runtime's own object: what `move` left in it is read by its own data properties and cannot throw.
@@ -1049,7 +1068,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
1049
1068
  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();
1050
1069
  G.note = null;
1051
1070
  e.grounded = own(b, 'grounded') === true;
1052
- 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 } });
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);
1053
1072
  }
1054
1073
  // Phase 2: for every entity, its commands and then its tick, starting from a different entity each tick.
1055
1074
  const list = [...ents.values()];
@@ -1369,7 +1388,9 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
1369
1388
  const list = [...ents.values()].map((e) => packEntity(e.kind, e, dims));
1370
1389
  // Include all three components of position, velocity and heading in a 3D snapshot.
1371
1390
  snapCells = packed.n + (dims === 3 ? 13 : 10) * list.length;
1372
- return [[round.n, round.phase === 'live' ? 1 : 0, round.endsAt], list];
1391
+ const collision = hasColliders ? collisionState() : null;
1392
+ if (collision) snapCells += 3 + collision[2].length * 8;
1393
+ return [[round.n, round.phase === 'live' ? 1 : 0, round.endsAt], list, ...(collision ? [collision] : [])];
1373
1394
  },
1374
1395
  shared: () => packFields(c.shared, shared, dims),
1375
1396
  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 })),
@@ -10,7 +10,9 @@ export function interestSnapshot(snap, seat, radiusM) {
10
10
  const p = e[3], q = own[3];
11
11
  return e === own || (p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2 + ((p[2] ?? 0) - (q[2] ?? 0)) ** 2 <= radiusM ** 2;
12
12
  }) : seat === null ? entities : own ? entities : [];
13
- return { ...snap, d: [round, visible], c: (snap.c ?? []).filter(row => row[0] === seat) };
13
+ // Collision revisions are prediction inputs, independent of visual interest.
14
+ // Deliver all geometry: authored sweeps/dashes need not fit inside the view radius.
15
+ return { ...snap, d: [round, visible, ...snap.d.slice(2)], c: (snap.c ?? []).filter(row => row[0] === seat) };
14
16
  }
15
17
 
16
18
  export function snapshotEncoder(keyframeTicks) {
@@ -33,7 +35,7 @@ export function snapshotEncoder(keyframeTicks) {
33
35
  for (let i = 1; i < entity.length; i++) if (JSON.stringify(entity[i]) !== before[i]) { mask |= 1 << i; values.push(entity[i]); }
34
36
  if (mask) changed.push([id, mask, values]);
35
37
  }
36
- return { ...snap, d: { base: base.k, round: snap.d[0], changed, removed: [...rows.keys()].filter(id => !present.has(id)) } };
38
+ return { ...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)) } };
37
39
  },
38
40
  };
39
41
  }
@@ -73,7 +75,7 @@ export function snapshotDecoder() {
73
75
  if (j !== change[2].length) return null;
74
76
  next.set(entity[0], entity);
75
77
  }
76
- return { ...snap, d: [d.round, [...next.values()]] };
78
+ return { ...snap, d: [d.round, [...next.values()], ...(d.collision === undefined ? [] : [d.collision])] };
77
79
  },
78
80
  };
79
81
  }
package/rules/live.ts ADDED
@@ -0,0 +1,75 @@
1
+ import { overlapsTerrain } from './terrain.ts';
2
+ import { nearbyMap } from './map-index.ts';
3
+ /** Declared collision geometry, shared by authority and prediction. Entity fields
4
+ * own its lifetime and dimensions; this projection contains no gameplay state. */
5
+ import type { Vec3, ColliderDef } from './rules.ts';
6
+ import type { MapShapes } from './math.ts';
7
+ import { charge, own } from './guard.ts';
8
+ import { castMap3, solidAt, type BodyShape } from './collision.ts';
9
+ import { sweepMap, rayBox, rayCircle } from './math.ts';
10
+
11
+ export type ColliderRow = [string, string, number, number, number, number, number, number];
12
+ 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
+ const opts = def === true ? {} : def;
15
+ if (opts.enabled && fields[opts.enabled] !== true) return null;
16
+ const size = opts.size ? fields[opts.size] as Vec3 : null;
17
+ 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
+ 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
+ }
21
+ export function colliderSolid(row: ColliderRow): ReturnType<typeof solidAt> {
22
+ const [, shape, x, y, z, w, d, h] = row;
23
+ return shape === 'box' ? { min: { x: x-w/2, y: y-d/2, z }, max: { x:x+w/2, y:y+d/2, z:z+h }, r:0 }
24
+ : solidAt({x,y,z}, {shape, radius:w/2, height:h});
25
+ }
26
+ export function castCollider2(row: ColliderRow, p: Vec3, d: Vec3, radius: number) {
27
+ const solid = colliderSolid(row);
28
+ 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
+ }
30
+ export function collisionMap(map: MapShapes, rows: readonly ColliderRow[]): MapShapes {
31
+ if (!rows.length) return map;
32
+ const boxes: any[] = [], circles: any[] = [], spheres: any[] = [], capsules: any[] = [];
33
+ for (const [id, shape, x, y, z, w, d, h] of rows) {
34
+ const at = { x, y, z };
35
+ if (shape === 'box') boxes.push({ min: { x: x - w / 2, y: y - d / 2, z }, max: { x: x + w / 2, y: y + d / 2, z: z + h }, id } as any);
36
+ else if (shape === 'circle') circles.push({ at, r: w / 2, id } as any);
37
+ else if (shape === 'sphere') spheres.push({ at: { x, y, z: z + w / 2 }, r: w / 2, id } as any);
38
+ else capsules.push({ at, r: w / 2, height: h, id } as any);
39
+ }
40
+ return { bounds: map.bounds, staticMap: map, boxes, circles, spheres, capsules };
41
+ }
42
+ /** Latest known revision at the requested tick. Unknown future edits are never
43
+ * guessed: a later snapshot rebases the body and replays its pending inputs. */
44
+ export function revisionAt(history: readonly CollisionRevision[], tick: number): CollisionRevision | undefined {
45
+ for (let i = history.length - 1; i >= 0; i--) if (history[i][1] <= tick) return history[i];
46
+ return undefined;
47
+ }
48
+ export function collisionQueries(map: () => MapShapes, shape: () => BodyShape, dims: number): object {
49
+ const position = (body: unknown): Vec3 => {
50
+ const p = own(body, 'pos');
51
+ const n = (k: string): number => { const v = own(p, k); return typeof v === 'number' && Number.isFinite(v) ? Math.fround(v) : 0; };
52
+ return { x: n('x'), y: n('y'), z: n('z') };
53
+ };
54
+ return {
55
+ sweep: (body: unknown, delta: unknown) => sweepMap(map(), body, delta, shape().radius, dims, shape()),
56
+ support: (body: unknown, distance: unknown = 0.002) => {
57
+ charge(20);
58
+ const p = position(body), m = map();
59
+ const d = typeof distance === 'number' && Number.isFinite(distance) ? Math.max(0, Math.min(10000, distance)) : 0;
60
+ if (dims === 2) return Object.freeze({ at: Object.freeze(p), normal: Object.freeze({ x: 0, y: 0, z: 1 }), dist: 0 });
61
+ const h = castMap3(m, p, { x: 0, y: 0, z: -d }, shape());
62
+ return h && h.nz > 0.5 ? Object.freeze({ at: Object.freeze({ ...p, z: p.z - d * h.t }), normal: Object.freeze({ x: h.nx, y: h.ny, z: h.nz }), dist: d * h.t, ...(h.id ? { entity: h.id } : {}) }) : undefined;
63
+ },
64
+ overlaps: (body: unknown) => {
65
+ const p = position(body), s = shape(), a = solidAt(p, s), m = nearbyMap(map(), p, {x:0,y:0,z:0}, s.radius, s.height || 2*s.radius, dims);
66
+ charge(20 + 8 * (m.boxes.length + m.circles.length + (m.spheres?.length ?? 0) + (m.capsules?.length ?? 0)));
67
+ const overlap = (b: ReturnType<typeof solidAt>): boolean => {
68
+ let sq = 0;
69
+ for (const k of dims === 2 ? ['x', 'y'] as const : ['x', 'y', 'z'] as const) { const gap = Math.max(0, a.min[k] - b.max[k], b.min[k] - a.max[k]); sq += gap * gap; }
70
+ return sq < (a.r + b.r) ** 2 || a.r + b.r === 0 && a.min.x < b.max.x && a.max.x > b.min.x && a.min.y < b.max.y && a.max.y > b.min.y && (dims === 2 || a.min.z < b.max.z && a.max.z > b.min.z);
71
+ };
72
+ return (m.heightTiles ?? []).some(t => t.base !== undefined && overlapsTerrain(t, a)) || m.boxes.some(b => overlap({ ...b, r: 0 })) || m.circles.some(b => overlap({ min: {...b.at,z:m.bounds.min.z}, max: {...b.at,z:m.bounds.max.z}, r: b.r })) || (m.spheres ?? []).some(b => overlap({ min: b.at, max: b.at, r: b.r })) || (m.capsules ?? []).some(b => overlap(solidAt(b.at, { shape: 'capsule', radius: b.r, height: b.height })));
73
+ },
74
+ };
75
+ }
@@ -0,0 +1,51 @@
1
+ /** Immutable build-time BVH. Queries charge nodes visited and narrow-phase work,
2
+ * never the number of shapes in the level. Live geometry is a separate overlay. */
3
+ import { charge } from './guard.ts';
4
+ import type { MapShapes } from './math.ts';
5
+ import type { Vec3 } from './rules.ts';
6
+ type Key = 'boxes' | 'circles' | 'spheres' | 'capsules' | 'heightTiles';
7
+ type Entry = { key: Key; value: any; min: Vec3; max: Vec3; order: number };
8
+ type Node = { min: Vec3; max: Vec3; left?: Node; right?: Node; entries?: Entry[] };
9
+ const indexes = new WeakMap<object, Node>();
10
+ const axes = ['x','y','z'] as const;
11
+ export function indexMap(map: MapShapes): void {
12
+ const entries: Entry[] = [];
13
+ for (const key of ['boxes','circles','spheres','capsules','heightTiles'] as const) for (const value of map[key] ?? []) {
14
+ const v:any=value; let min:Vec3,max:Vec3;
15
+ if(key==='boxes') { min=v.min;max=v.max; }
16
+ 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)}; }
17
+ 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}; }
18
+ entries.push({key,value,min,max,order:entries.length});
19
+ }
20
+ function build(es:Entry[]):Node {
21
+ const min={x:Infinity,y:Infinity,z:Infinity},max={x:-Infinity,y:-Infinity,z:-Infinity};
22
+ for(const e of es)for(const k of axes){min[k]=Math.min(min[k],e.min[k]);max[k]=Math.max(max[k],e.max[k]);}
23
+ if(es.length<=8)return {min,max,entries:es};
24
+ // Choose the widest finite axis; unbounded columns still split horizontally.
25
+ let k: 'x' | 'y' | 'z' = max.x-min.x>=max.y-min.y?'x':'y';
26
+ if(Number.isFinite(max.z-min.z) && max.z-min.z>max[k]-min[k])k='z';
27
+ es.sort((a,b)=>(a.min[k]+a.max[k])-(b.min[k]+b.max[k])||a.order-b.order);
28
+ const mid=es.length>>1;return {min,max,left:build(es.slice(0,mid)),right:build(es.slice(mid))};
29
+ }
30
+ indexes.set(map,build(entries));
31
+ }
32
+ /** The segment of a body's reference point against bounds expanded by its extents. */
33
+ export function nearbyMap(map: MapShapes, p:Vec3, d:Vec3, radius:number, height:number, dims=3):MapShapes {
34
+ const parent=map.staticMap;
35
+ const root=indexes.get(parent??map);
36
+ if(!root)return map;
37
+ const out:any={bounds:map.bounds,boxes:[],circles:[],spheres:[],capsules:[],heightTiles:[]};
38
+ function touches(n:{min:Vec3;max:Vec3}):boolean {
39
+ charge(12);let near=0,far=1;
40
+ for(const k of dims===2?['x','y'] as const:axes){const lo=n.min[k]-(k==='z'?height:radius),hi=n.max[k]+(k==='z'?0:radius);
41
+ if(d[k]===0){if(p[k]<lo-1e-7||p[k]>hi+1e-7)return false;}
42
+ else {let a=(lo-p[k])/d[k],b=(hi-p[k])/d[k];if(a>b)[a,b]=[b,a];near=Math.max(near,a);far=Math.min(far,b);if(near>far+1e-7)return false;}
43
+ }return true;
44
+ }
45
+ const found:Entry[]=[];
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
+ visit(root);charge(found.length*Math.ceil(Math.log2(found.length+1)));
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);}
50
+ return out;
51
+ }