@homie-rocks/studio 0.44.0 → 0.44.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.44.0",
3
+ "version": "0.44.1",
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
  }
@@ -14,7 +14,7 @@ export interface Map3 {
14
14
  capsules?: readonly (MapCircle & { height: number })[];
15
15
  }
16
16
  const EPS = 1e-7;
17
- const point = (x: number, y: number, z: number): Vec3 => ({ x, y, z });
17
+ const point = (x: number, y: number, z: number): {x: number; y: number; z: number} => ({ x, y, z });
18
18
  export function solidAt(at: Vec3, body: BodyShape): Solid {
19
19
  const r = body.radius, height = body.height || 2 * r;
20
20
  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 };
@@ -93,7 +93,7 @@ export function castMap3(map: Map3, p: Vec3, d: Vec3, body: BodyShape): Hit3 | n
93
93
  charge(24);
94
94
  const a = solidAt(p, body), r = body.radius, height = body.height || r * 2;
95
95
  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; };
96
+ 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
97
  for (const axis of ['x', 'y', 'z'] as const) {
98
98
  const low = map.bounds.min[axis] + (axis === 'z' ? 0 : r);
99
99
  const high = map.bounds.max[axis] - (axis === 'z' ? height : r);
@@ -102,11 +102,11 @@ export function castMap3(map: Map3, p: Vec3, d: Vec3, body: BodyShape): Hit3 | n
102
102
  take({ t: ((d[axis] > 0 ? high : low) - p[axis]) / d[axis], nx: n.x, ny: n.y, nz: n.z });
103
103
  }
104
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 }));
105
+ for (const b of map.boxes) take(castSolid(a, d, { ...b, r: 0 }), (b as any).id);
106
106
  // 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 })));
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 }), (c as any).id);
108
+ for (const s of map.spheres ?? []) take(castSolid(a, d, { min: s.at, max: s.at, r: s.r }), (s as any).id);
109
+ 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
110
  return best;
111
111
  }
112
112
  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;
@@ -483,7 +484,10 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
483
484
  const solid = solidAt(p, shape);
484
485
  for (const other of ents.values()) {
485
486
  if (!other.kind.body || ignored.has(other.id)) continue;
486
- const hit = castSolid(solid, d, solidAt(other.pos, other.kind.body));
487
+ if (other.kind.collider) charge(48);
488
+ const row = other.kind.collider ? colliderRow(other.id, other.pos, other.kind.body, other.kind.collider, other.f) : null;
489
+ if (other.dead || other.kind.collider && !row) continue;
490
+ const hit = castSolid(solid, d, row ? colliderSolid(row) : solidAt(other.pos, other.kind.body));
487
491
  if (hit && (!best || hit.t < best.t)) best = { ...hit, id: other.id };
488
492
  }
489
493
  return best;
@@ -618,7 +622,10 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
618
622
  let best: Hit | null = cast.hit;
619
623
  for (const e of ents.values()) {
620
624
  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);
625
+ if (e.kind.collider) charge(48);
626
+ const row = e.kind.collider ? colliderRow(e.id, e.pos, e.kind.body, e.kind.collider, e.f) : null;
627
+ if (e.dead || e.kind.collider && !row) continue;
628
+ 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
629
  if (h && (!best || h.t < best.t)) best = { ...h, id: e.id };
623
630
  }
624
631
  if (!best) return undefined;
@@ -648,7 +655,10 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
648
655
  let best: Hit | null = cast.hit;
649
656
  for (const other of ents.values()) {
650
657
  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);
658
+ if (other.kind.collider) charge(48);
659
+ const row = other.kind.collider ? colliderRow(other.id, other.pos, other.kind.body, other.kind.collider, other.f) : null;
660
+ if (other.dead || other.kind.collider && !row) continue;
661
+ 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
662
  if (h && (!best || h.t < best.t)) best = { ...h, id: other.id };
653
663
  }
654
664
  const l = Math.sqrt(d.x * d.x + d.y * d.y);
@@ -678,11 +688,19 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
678
688
  });
679
689
  Object.freeze(world);
680
690
 
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;
691
+ const hasColliders = c.kinds.some(k => k.collider);
692
+ const collisionState = (): CollisionRevision => [tick, tick + 1, [...ents.values()].flatMap(e => {
693
+ if (e.dead || !e.kind.collider || !e.kind.body) return [];
694
+ const row = colliderRow(e.id, e.pos, e.kind.body, e.kind.collider, e.f);
695
+ return row ? [row] : [];
696
+ })];
697
+ let moveGeometry = c.map as import('./math.ts').MapShapes;
698
+ const queries = collisionQueries(() => moveGeometry, () => moveShape, dims);
699
+ const moveWorld = brand(Object.freeze(queries));
700
+ const moveMap = brand(Object.freeze({ name: c.map.name, spot: mapApi.spot, spots: mapApi.spots, ...queries }));
683
701
  let moveShape = { shape: 'sphere', radius: 0, height: 0 };
684
702
  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 });
703
+ 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
704
  Object.assign(moveCtx, { ticks: (seconds: unknown): number => { charge(1); return ticks(seconds); } });
687
705
  Object.freeze(moveCtx);
688
706
  const joinCtx = brand({} as Record<string, unknown>);
@@ -988,6 +1006,8 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
988
1006
  }
989
1007
  }
990
1008
  guides?.();
1009
+ // All movers see the same world, independent of entity iteration order.
1010
+ if (hasColliders) { left -= 64 * ents.size + 4 * mapShapes; moveGeometry = collisionMap(c.map, collisionState()[2]); }
991
1011
  // Phase 1: one input step and `move` for every body.
992
1012
  for (const e of [...ents.values()]) {
993
1013
  if (e.dead || !e.kind.body) continue;
@@ -1040,7 +1060,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
1040
1060
  if (!k.move) continue;
1041
1061
  const b = { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: e.mself };
1042
1062
  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;
1063
+ moveShape = body;
1044
1064
  run(k.name, 'move', e, 'move', () => (k.move as NonNullable<KindTable['move']>)(b, e.input, moveCtx), 'body');
1045
1065
  // The runtime rounds to 32-bit floats, here and in the browser, so both step from exactly the same numbers.
1046
1066
  // `b` is the runtime's own object: what `move` left in it is read by its own data properties and cannot throw.
@@ -1049,7 +1069,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
1049
1069
  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
1070
  G.note = null;
1051
1071
  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 } });
1072
+ 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
1073
  }
1054
1074
  // Phase 2: for every entity, its commands and then its tick, starting from a different entity each tick.
1055
1075
  const list = [...ents.values()];
@@ -1369,7 +1389,9 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
1369
1389
  const list = [...ents.values()].map((e) => packEntity(e.kind, e, dims));
1370
1390
  // Include all three components of position, velocity and heading in a 3D snapshot.
1371
1391
  snapCells = packed.n + (dims === 3 ? 13 : 10) * list.length;
1372
- return [[round.n, round.phase === 'live' ? 1 : 0, round.endsAt], list];
1392
+ const collision = hasColliders ? collisionState() : null;
1393
+ if (collision) snapCells += 3 + collision[2].length * 8;
1394
+ return [[round.n, round.phase === 'live' ? 1 : 0, round.endsAt], list, ...(collision ? [collision] : [])];
1373
1395
  },
1374
1396
  shared: () => packFields(c.shared, shared, dims),
1375
1397
  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,73 @@
1
+ /** Declared collision geometry, shared by authority and prediction. Entity fields
2
+ * own its lifetime and dimensions; this projection contains no gameplay state. */
3
+ import type { Vec3, ColliderDef } from './rules.ts';
4
+ import type { MapShapes } from './math.ts';
5
+ import { charge, own } from './guard.ts';
6
+ import { castMap3, solidAt, type BodyShape } from './collision.ts';
7
+ import { sweepMap, rayBox, rayCircle } from './math.ts';
8
+
9
+ export type ColliderRow = [string, string, number, number, number, number, number, number];
10
+ export type CollisionRevision = [number, number, ColliderRow[]];
11
+ export function colliderRow(id: string, at: Vec3, body: BodyShape, def: ColliderDef, fields: Record<string, unknown>): ColliderRow | null {
12
+ const opts = def === true ? {} : def;
13
+ if (opts.enabled && fields[opts.enabled] !== true) return null;
14
+ const size = opts.size ? fields[opts.size] as Vec3 : null;
15
+ const x = size ? size.x : body.radius * 2, y = size ? size.y : body.radius * 2, z = size ? size.z : body.height || body.radius * 2;
16
+ if (![x, y, z].every(Number.isFinite) || x <= 0 || y <= 0 || z < 0) return null;
17
+ return [id, size ? 'box' : body.shape, at.x, at.y, at.z, x, y, z];
18
+ }
19
+ export function colliderSolid(row: ColliderRow): ReturnType<typeof solidAt> {
20
+ const [, shape, x, y, z, w, d, h] = row;
21
+ 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 }
22
+ : solidAt({x,y,z}, {shape, radius:w/2, height:h});
23
+ }
24
+ export function castCollider2(row: ColliderRow, p: Vec3, d: Vec3, radius: number) {
25
+ const solid = colliderSolid(row);
26
+ 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);
27
+ }
28
+ export function collisionMap(map: MapShapes, rows: readonly ColliderRow[]): MapShapes {
29
+ if (!rows.length) return map;
30
+ const boxes = [...map.boxes], circles = [...map.circles], spheres = [...(map.spheres ?? [])], capsules = [...(map.capsules ?? [])];
31
+ for (const [id, shape, x, y, z, w, d, h] of rows) {
32
+ const at = { x, y, z };
33
+ 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);
34
+ else if (shape === 'circle') circles.push({ at, r: w / 2, id } as any);
35
+ else if (shape === 'sphere') spheres.push({ at: { x, y, z: z + w / 2 }, r: w / 2, id } as any);
36
+ else capsules.push({ at, r: w / 2, height: h, id } as any);
37
+ }
38
+ return { ...map, boxes, circles, spheres, capsules };
39
+ }
40
+ /** Latest known revision at the requested tick. Unknown future edits are never
41
+ * guessed: a later snapshot rebases the body and replays its pending inputs. */
42
+ export function revisionAt(history: readonly CollisionRevision[], tick: number): CollisionRevision | undefined {
43
+ for (let i = history.length - 1; i >= 0; i--) if (history[i][1] <= tick) return history[i];
44
+ return undefined;
45
+ }
46
+ export function collisionQueries(map: () => MapShapes, shape: () => BodyShape, dims: number): object {
47
+ const position = (body: unknown): Vec3 => {
48
+ const p = own(body, 'pos');
49
+ const n = (k: string): number => { const v = own(p, k); return typeof v === 'number' && Number.isFinite(v) ? Math.fround(v) : 0; };
50
+ return { x: n('x'), y: n('y'), z: n('z') };
51
+ };
52
+ return {
53
+ sweep: (body: unknown, delta: unknown) => sweepMap(map(), body, delta, shape().radius, dims, shape()),
54
+ support: (body: unknown, distance: unknown = 0.002) => {
55
+ charge(20);
56
+ const p = position(body), m = map();
57
+ const d = typeof distance === 'number' && Number.isFinite(distance) ? Math.max(0, Math.min(10000, distance)) : 0;
58
+ if (dims === 2) return Object.freeze({ at: Object.freeze(p), normal: Object.freeze({ x: 0, y: 0, z: 1 }), dist: 0 });
59
+ const h = castMap3(m, p, { x: 0, y: 0, z: -d }, shape());
60
+ 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;
61
+ },
62
+ overlaps: (body: unknown) => {
63
+ const m = map(), p = position(body), s = shape(), a = solidAt(p, s);
64
+ charge(20 + 8 * (m.boxes.length + m.circles.length + (m.spheres?.length ?? 0) + (m.capsules?.length ?? 0)));
65
+ const overlap = (b: ReturnType<typeof solidAt>): boolean => {
66
+ let sq = 0;
67
+ 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; }
68
+ 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);
69
+ };
70
+ return 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 })));
71
+ },
72
+ };
73
+ }
package/rules/math.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { collisionQueries } from './live.ts';
1
2
  /*
2
3
  * math.ts — `world.math` and `ctx.math`: the maths rules may not take from `Math`, in plain arithmetic.
3
4
  * =============================================================================
@@ -204,7 +205,7 @@ export function rayCircle(px: number, py: number, dx: number, dy: number, cx: nu
204
205
  return { t, nx: hx / l, ny: hy / l };
205
206
  }
206
207
  /** The same point against a box grown by r with round corners (a circle of radius r against the box). */
207
- function rayBox(px: number, py: number, dx: number, dy: number, min: Vec3, max: Vec3, r: number): Hit | null {
208
+ export function rayBox(px: number, py: number, dx: number, dy: number, min: Vec3, max: Vec3, r: number): Hit | null {
208
209
  let t0 = 0; let t1 = 1; let nx = 0; let ny = 0;
209
210
  const lo = [min.x - r, min.y - r]; const hi = [max.x + r, max.y + r];
210
211
  const p = [px, py]; const d = [dx, dy];
@@ -237,8 +238,8 @@ function rayBounds(px: number, py: number, dx: number, dy: number, b: { min: Vec
237
238
  /** A circle (or a point, r 0) moved along d against the static map: the first thing in the way, and how many shapes were tested. */
238
239
  export function castMap(map: MapShapes, px: number, py: number, dx: number, dy: number, r: number): { hit: Hit | null; tested: number } {
239
240
  let best: Hit | null = rayBounds(Math.max(map.bounds.min.x + r, Math.min(map.bounds.max.x - r, px)), Math.max(map.bounds.min.y + r, Math.min(map.bounds.max.y - r, py)), dx, dy, map.bounds, r);
240
- for (const b of map.boxes) { const h = rayBox(px, py, dx, dy, b.min, b.max, r); if (h && (!best || h.t < best.t)) best = h; }
241
- for (const c of map.circles) { const h = rayCircle(px, py, dx, dy, c.at.x, c.at.y, c.r + r); if (h && (!best || h.t < best.t)) best = h; }
241
+ for (const b of map.boxes) { const h = rayBox(px, py, dx, dy, b.min, b.max, r); if (h && (!best || h.t < best.t)) best = { ...h, id: (b as any).id }; }
242
+ for (const c of map.circles) { const h = rayCircle(px, py, dx, dy, c.at.x, c.at.y, c.r + r); if (h && (!best || h.t < best.t)) best = { ...h, id: (c as any).id }; }
242
243
  return { hit: best, tested: 4 + map.boxes.length + map.circles.length };
243
244
  }
244
245
 
@@ -264,7 +265,7 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
264
265
  const t = h ? Math.max(0, h.t - (length > 0 ? SKIN / length : 0)) : 1;
265
266
  const at = v(fr(px + dx * t), fr(py + dy * t), fr(p.z + d.z * t));
266
267
  put(body, 'pos', at); put(body, 'grounded', restsOnMap(map, at, shape));
267
- return h ? Object.freeze({ at, normal: v(h.nx, h.ny, h.nz) }) : undefined;
268
+ return h ? Object.freeze({ at, normal: v(h.nx, h.ny, h.nz), ...(h.id ? { entity: h.id } : {}) }) : undefined;
268
269
  }
269
270
  const { hit: h } = castMap(map, px, py, dx, dy, radius);
270
271
  put(body, 'grounded', true);
@@ -273,7 +274,7 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
273
274
  const t = l > 0 ? Math.max(0, h.t - SKIN / l) : 0;
274
275
  const at = v(fr(px + dx * t), fr(py + dy * t), 0);
275
276
  put(body, 'pos', at);
276
- return Object.freeze({ at, normal: v(h.nx, h.ny, 0) });
277
+ return Object.freeze({ at, normal: v(h.nx, h.ny, 0), ...(h.id ? { entity: h.id } : {}) });
277
278
  }
278
279
 
279
280
  /**
@@ -281,12 +282,14 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
281
282
  * map. One function, so that the browser (view.ts) and the person the build check plays hold the same one. The
282
283
  * server's own (core.ts `moveCtx`) is made beside the rest of its world, and charges as that does.
283
284
  */
284
- export function moveContext(o: { tick: () => number; tickHz: number; tune: unknown; map: MapShapes; name: string; spots: Readonly<Record<string, readonly unknown[]>>; radius: () => number; shape?: () => BodyShape; dims: number }): unknown {
285
+ export function moveContext(o: { tick: () => number; tickHz: number; tune: unknown; map: MapShapes; name: string; spots: Readonly<Record<string, readonly unknown[]>>; radius: () => number; shape?: () => BodyShape; dims: number; geometry?: () => MapShapes }): unknown {
285
286
  const none = Object.freeze([]);
287
+ const queries = collisionQueries(() => o.geometry?.() ?? o.map, () => o.shape?.() ?? {shape: 'sphere', radius: o.radius(), height: 2 * o.radius()}, o.dims);
286
288
  return brand(Object.freeze({
287
289
  get tick() { return o.tick(); }, dt: 1 / o.tickHz, tune: o.tune, math,
288
290
  // As the server's `ctx.ticks` reads it: a plain number, or nothing.
289
291
  ticks: (seconds: unknown): number => { const s = num(seconds); const n = Math.round(s * o.tickHz); return s > 0 && Number.isFinite(n) ? Math.max(1, n) : 0; },
290
- map: brand(Object.freeze({ name: o.name, spot: (name: string) => (own(o.spots, name) as readonly unknown[] | undefined)?.[0], spots: (name: string) => own(o.spots, name) ?? none, sweep: (body: unknown, delta: unknown) => sweepMap(o.map, body, delta, o.radius(), o.dims, o.shape?.()) })),
292
+ world: brand(Object.freeze(queries)),
293
+ map: brand(Object.freeze({ name: o.name, spot: (name: string) => (own(o.spots, name) as readonly unknown[] | undefined)?.[0], spots: (name: string) => own(o.spots, name) ?? none, ...queries })),
291
294
  }));
292
295
  }
package/rules/rules.ts CHANGED
@@ -123,6 +123,7 @@ export type Handler = (world: RuntimeWorld, self: RuntimeSelf, e?: any) => void;
123
123
  export type RoomHandler = (world: RuntimeWorld, e?: any) => void;
124
124
  export type MoveFn = (body: any, input: any, ctx: any) => void;
125
125
 
126
+ export type ColliderDef = true | { size?: string; enabled?: string };
126
127
  export interface BodyDef { shape: 'circle' | 'sphere' | 'capsule' | 'box'; radius: number; height?: number; maxSpeed: number; sweep?: boolean; move?: 'owner' }
127
128
  export interface GuideDef { view: (world: RuntimeWorld, self: RuntimeSelf) => unknown; floor?: (world: RuntimeWorld, self: RuntimeSelf, view: any) => unknown }
128
129
  export interface EntityDef {
@@ -131,6 +132,7 @@ export interface EntityDef {
131
132
  motion?: Fields;
132
133
  input?: Fields;
133
134
  body?: BodyDef;
135
+ collider?: ColliderDef;
134
136
  guide?: GuideDef;
135
137
  tick?: Handler;
136
138
  think?: (world: RuntimeWorld, self: RuntimeSelf) => Record<string, unknown>;
@@ -315,6 +317,7 @@ export interface KindTable {
315
317
  fields: FieldList;
316
318
  motion: FieldList;
317
319
  input: FieldList;
320
+ collider?: ColliderDef;
318
321
  body: null | { shape: string; radius: number; height: number; maxSpeed: number; sweep: boolean; owner: boolean };
319
322
  score: string | null;
320
323
  tick: Handler | null;
@@ -521,11 +524,22 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
521
524
  if (e.guide !== undefined && (typeof e.guide !== 'object' || typeof e.guide?.view !== 'function')) throw new Error(`${at}.guide needs view(world, self)`);
522
525
  const cells = cellsOfList(fields) + cellsOfList(motion);
523
526
  if (cells > KIND_CELLS_MAX) throw new Error(`${at}: its fields and motion may hold ${cells} values when they are full, and one entity holds ${KIND_CELLS_MAX} at most`);
524
- const k: KindTable = { name, index: kinds.length, player, fields, motion, input, body, score: scores[0]?.[0] ?? null, tick: e.tick ?? null, think: e.think ?? null, on, commands: cmds, onRoom, guide: e.guide ?? null, move: moveFn };
527
+ if (e.collider !== undefined) {
528
+ if (!body || player || body.owner) throw new Error(`${at}.collider needs a non-player body`);
529
+ if (e.collider !== true) {
530
+ if (!e.collider || typeof e.collider !== 'object' || Object.keys(e.collider).some(k => !['size', 'enabled'].includes(k))) throw new Error(`${at}.collider is true or {size, enabled}`);
531
+ for (const [key, type] of [['size', 'vec3'], ['enabled', 'bit']]) {
532
+ const name = (e.collider as Record<string, unknown>)[key];
533
+ if (name !== undefined && (typeof name !== 'string' || !fields.some(([n, f]) => n === name && f.t === type))) throw new Error(`${at}.collider.${key} must name a ${type} field`);
534
+ }
535
+ }
536
+ }
537
+ const k: KindTable = { ...(e.collider ? { collider: e.collider } : {}), name, index: kinds.length, player, fields, motion, input, body, score: scores[0]?.[0] ?? null, tick: e.tick ?? null, think: e.think ?? null, on, commands: cmds, onRoom, guide: e.guide ?? null, move: moveFn };
525
538
  kinds.push(k);
526
539
  kindOf[name] = k;
527
540
  }
528
541
 
542
+ if (kinds.some(k => k.collider) && kinds.some(k => k.player && k.body?.owner)) throw new Error("live colliders need server movement: omit body.move: 'owner' on players");
529
543
  const room = d.room ?? {};
530
544
  const roomOn = handlers<RoomHandler>(room.on, 'room.on');
531
545
  for (const key of Object.keys(roomOn)) if (!events[key] && !ROOM_EVENTS.includes(key) && !SEAT_EVENTS.includes(key) && key !== 'answer' && key !== 'undeliverable') throw new Error(`room.on.${key}: no event "${key}" is declared in shapes.events`);
package/rules/types.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** The build specialises these faces from a game's declarations; the runtime still checks every write. */
2
- import type { Vec3, BodyDef, Fields } from './rules.ts';
2
+ import type { Vec3, BodyDef, Fields, ColliderDef } from './rules.ts';
3
3
  import type { math } from './math.ts';
4
4
 
5
5
  export type Point = { readonly x: number; readonly y: number; readonly z?: number };
@@ -51,7 +51,7 @@ export type RoomEvents<E = Record<string, never>> = RoundEvents & { seatJoined:
51
51
  export type RoundEvents = { roundStart: { n: number }; roundOver: { n: number; results: readonly { seat: number; id: string; driver: 'person' | 'bot' | 'ai'; score: number; place: number }[] } };
52
52
  type GuideWorld<W> = Pick<W, Extract<keyof W, 'tick' | 'dt' | 'ticks' | 'math' | 'map' | 'tune' | 'stage' | 'level' | 'levelMax' | 'guideLevel' | 'guideSeats' | 'kids' | 'levelSet' | 'round' | 'shared' | 'near' | 'inBox' | 'ray'>>;
53
53
  export type Entity<W, S, I, E, C, V, Answer = BuiltIns['answer'], G = Goal, A = GuideRequest, D = GuideDecision> = {
54
- player?: true | { away?: 'neutral' | 'think'; leave?: 'despawn' | 'bot' }; fields?: Fields; motion?: Fields; input?: Fields; body?: BodyDef;
54
+ player?: true | { away?: 'neutral' | 'think'; leave?: 'despawn' | 'bot' }; fields?: Fields; motion?: Fields; input?: Fields; body?: BodyDef; collider?: ColliderDef;
55
55
  tick?: (world: W, self: S) => void;
56
56
  think?: (world: W, self: S) => Partial<I>;
57
57
  on?: { [K in keyof (Omit<E, keyof BuiltIns> & Omit<BuiltIns<E>, 'answer'> & { answer: Answer })]?: (world: W, self: S, event: EventData<(Omit<E, keyof BuiltIns> & Omit<BuiltIns<E>, 'answer'> & { answer: Answer })[K]>) => void };
@@ -60,4 +60,5 @@ export type Entity<W, S, I, E, C, V, Answer = BuiltIns['answer'], G = Goal, A =
60
60
  guide?: { view: (world: GuideWorld<W>, self: ReadonlyState<Omit<S, 'input'>>) => V; floor?: (world: W, self: S, view: GuideView<V, G, A>) => D };
61
61
  };
62
62
  export interface MoveBody<M> { get pos(): Vec3; set pos(value: Point); get vel(): Vec3; set vel(value: Point); get heading(): Vec3; set heading(value: Point); grounded: boolean; readonly motion: M }
63
- export type MoveContext<T, M> = Clock & { readonly tune: ReadonlyState<T>; readonly map: MapView & { sweep(body: MoveBody<M>, delta: Point): Hit | undefined } };
63
+ export interface MoveCollision<M> { sweep(body: MoveBody<M>, delta: Point): Hit | undefined; support(body: MoveBody<M>, distance?: number): (Hit & {dist: number}) | undefined; overlaps(body: MoveBody<M>): boolean }
64
+ export type MoveContext<T, M> = Clock & { readonly tune: ReadonlyState<T>; readonly map: MapView & MoveCollision<M>; readonly world: MoveCollision<M> };
package/rules/view.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { snapshotDecoder } from './interest.mjs';
2
+ import { collisionMap, revisionAt, type CollisionRevision } from './live.ts';
2
3
  import { rulesOutput } from '../netplay/rules-output.mjs';
3
4
  import { rulesCaps, rulesRates } from '../worker/limits.mjs';
4
5
  /*
@@ -127,7 +128,7 @@ export interface Room<R = unknown> {
127
128
  readonly __rules?: R;
128
129
  }
129
130
 
130
- interface Frame { k: number; e: number; at: number; round: [number, number, number]; ents: Map<string, Unpacked>; rows: number[][] }
131
+ interface Frame { k: number; e: number; at: number; round: [number, number, number]; ents: Map<string, Unpacked>; rows: number[][]; collision?: CollisionRevision }
131
132
  const frames = new WeakMap<object, Frame | null>();
132
133
  const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
133
134
 
@@ -168,11 +169,11 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
168
169
  if (!s || typeof s !== 'object') return null;
169
170
  if (frames.has(s)) return frames.get(s) ?? null;
170
171
  let f: Frame | null = null;
171
- const d = delivery.decode(s)?.d as [unknown, unknown] | null;
172
+ const d = delivery.decode(s)?.d as [unknown, unknown, CollisionRevision?] | null;
172
173
  if (Array.isArray(d) && Array.isArray(d[0]) && Array.isArray(d[1])) {
173
174
  const ents = new Map<string, Unpacked>();
174
175
  for (const w of d[1] as unknown[]) { const e = unpackEntity(schema.kinds, w, dims); if (e) ents.set(e.id, e); }
175
- f = { k: s.k, e: Number(s.e) || 0, at: clock(), round: [Number(d[0][0]) || 0, Number(d[0][1]) || 0, Number(d[0][2]) || 0], ents, rows: (Array.isArray(s.c) ? s.c : []) as unknown as number[][] };
176
+ f = { collision: d[2], k: s.k, e: Number(s.e) || 0, at: clock(), round: [Number(d[0][0]) || 0, Number(d[0][1]) || 0, Number(d[0][2]) || 0], ents, rows: (Array.isArray(s.c) ? s.c : []) as unknown as number[][] };
176
177
  }
177
178
  frames.set(s, f);
178
179
  return f;
@@ -249,7 +250,15 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
249
250
  const tickAt = (now: number): number => (base ? base.tick + (now - base.at) / period * speed : 0);
250
251
  /** The tick `move` is being run for: the step just taken, or the one after it while the own body is drawn between ticks. */
251
252
  let moveTick = 0;
252
- const moveCtx = moveContext({ tick: () => moveTick, tickHz, tune, map, name: game.map.name ?? 'main', spots, radius: () => myKind()?.radius ?? 0, shape: () => ({ shape: myKind()?.shape ?? 'sphere', radius: myKind()?.radius ?? 0, height: myKind()?.height ?? 0 }), dims });
253
+ let collisionHistory: CollisionRevision[] = [];
254
+ let cachedCollision: CollisionRevision | undefined;
255
+ let geometry = map as import('./math.ts').MapShapes;
256
+ const geometryAt = () => {
257
+ const revision = revisionAt(collisionHistory, moveTick);
258
+ if (revision !== cachedCollision) { geometry = collisionMap(map, revision?.[2] ?? []); cachedCollision = revision; }
259
+ return geometry;
260
+ };
261
+ const moveCtx = moveContext({ tick: () => moveTick, tickHz, tune, map, geometry: geometryAt, name: game.map.name ?? 'main', spots, radius: () => myKind()?.radius ?? 0, shape: () => ({ shape: myKind()?.shape ?? 'sphere', radius: myKind()?.radius ?? 0, height: myKind()?.height ?? 0 }), dims });
253
262
  function authorityRtt(): number {
254
263
  // The helper's ping ends at the relay. A browser host adds another network leg in both directions.
255
264
  // Snapshot stamps use the relay clock, so their observed age measures the complete downstream path.
@@ -299,6 +308,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
299
308
  const fn = moves[kindName];
300
309
  if (!fn) return body;
301
310
  moveTick = t;
311
+ // Like snapshot unpacking, prepare the bounded geometry outside the handler
312
+ // quota. Authority debits projection once per tick, not once per mover.
313
+ geometryAt();
302
314
  const out = stepMove(fn, body, input, moveCtx, Math.max(1, Math.floor(schema.settings.budget.tick / 4)), kindOf.get(kindName)?.motion ?? [], dims, (err) => { if (!(err instanceof BudgetError)) console.warn('[room] move', err); });
303
315
  const kind = kindOf.get(kindName), r = kind?.radius ?? 0;
304
316
  const height = kind?.height || 2 * r;
@@ -387,6 +399,12 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
387
399
  const before = latest;
388
400
  if (before && before.e === f.e && f.k <= before.k) return;
389
401
  latest = f;
402
+ if (!before || before.e !== f.e) collisionHistory = [];
403
+ const collision = f.collision;
404
+ if (collision) {
405
+ collisionHistory.push(collision);
406
+ if (collisionHistory.length > 128) collisionHistory.shift();
407
+ }
390
408
  if (before && before.e !== f.e) hostAges.length = 0;
391
409
  hostAges.push(Math.max(0, net.now() - s.st)); if (hostAges.length > 40) hostAges.shift();
392
410
  if (before && before.e !== f.e) fxQueue.length = 0;
@@ -5,15 +5,17 @@ import { defineMove, type Vec3 } from '@homie-rocks/studio/rules';
5
5
  import type { MoveBody, MoveContext } from '@homie-rocks/studio/rules/types';
6
6
 
7
7
  function slide<M>(body: MoveBody<M>, delta: Vec3, ctx: MoveContext<unknown, M>) {
8
- const before = body.pos, hit = ctx.map.sweep(body, delta);
8
+ const before = body.pos, hit = ctx.world.sweep(body, delta);
9
9
  if (!hit) return;
10
10
  const M = ctx.math, remaining = M.sub(delta, M.sub(body.pos, before));
11
11
  const into = M.dot(remaining, hit.normal);
12
- if (into < 0) ctx.map.sweep(body, M.sub(remaining, M.scale(hit.normal, into)));
12
+ if (into < 0) ctx.world.sweep(body, M.sub(remaining, M.scale(hit.normal, into)));
13
13
  }
14
14
 
15
15
  export const move = defineMove({
16
16
  runner(body, input, ctx) {
17
+ // Recheck support before jumping: a platform/cover may have moved or broken.
18
+ body.grounded = Boolean(ctx.world.support(body));
17
19
  const M = ctx.math, m = body.motion, T = ctx.tune;
18
20
  const dt = ctx.dt, knocked = m.knockAt > 0;
19
21
  const g0 = 2 * T.jumpHeight / (T.jumpRise * T.jumpRise);
@@ -32,7 +32,7 @@ export async function quoteCart(env,cat,origin,caller,input,request) {
32
32
  session = guest.session;
33
33
  caller = {...caller,id:session.player.id};
34
34
  }
35
- const quote = await customerOffer(env,'cart',{name:lines.map(l=>`${l.item.name} × ${l.quantity}`).join(', '),lines,player:caller.id??null,shop, sale:serviceTerms({scope:'cart',amount,currency:shop.currency,automaticTax:shop.automaticTax===true,taxBehavior:shop.taxBehavior,refundWindowDays:shop.refundDays,refund:`Studio refund window: ${shop.refundDays} days.`})});
35
+ const quote = await customerOffer(env,'cart',{name:lines.map(l=>`${l.item.name} × ${l.quantity}`).join(', '),lines,player:caller.id??null,shop, sale:serviceTerms({scope:'cart',amount,currency:shop.currency,automaticTax:shop.automaticTax===true,taxBehavior:shop.taxBehavior,refundWindowDays:shop.refundDays,refund:Number.isFinite(shop.refundDays)?`Studio refund window: ${shop.refundDays} days.`:undefined})});
36
36
  return {...quote,...(session?{account:{player:session.player,cookies:session.cookies}}:{})};
37
37
  }
38
38
  export async function fulfillCart(env,origin,order) {
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.44.0';
2
+ export const STUDIO_VERSION_TAG = '0.44.1';