@homie-rocks/studio 0.46.6 → 0.48.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.
@@ -66,12 +66,13 @@ export const REFUSED_NAMES = ['constructor', 'prototype', '__proto__', 'stack',
66
66
  /** The names JavaScript calls by itself to turn an object into a number or a text: no function may sit under one. */
67
67
  export const HOOK_NAMES = ['valueOf', 'toString', 'toJSON'];
68
68
  /** What the host runtime's own objects offer: `world`, `ctx`, the round, the map (rules/core.ts) and `world.math` (rules/math.ts). */
69
- export const WORLD_METHODS = ['ticks', 'random', 'route', 'label', 'send', 'dispatch', 'sendRoom', 'announce', 'sendArea', 'after', 'emit', 'spawn', 'despawn', 'place', 'near', 'inBox', 'ray', 'rayAll', 'rays', 'sweep', 'ask', 'goalDone', 'finish', 'end', 'restart', 'spot', 'spots'];
69
+ export const WORLD_METHODS = ['ticks', 'random', 'route', 'label', 'send', 'dispatch', 'sendRoom', 'announce', 'sendArea', 'after', 'emit', 'spawn', 'despawn', 'place', 'near', 'inBox', 'ray', 'rayAll', 'rays', 'sweep', 'ask', 'goalDone', 'finish', 'end', 'restart', 'spot', 'spots', 'replace', 'tile', 'distance', 'flow'];
70
70
  export const MATH_METHODS = ['sin', 'cos', 'tan', 'atan', 'atan2', 'asin', 'acos', 'exp', 'log', 'pow', 'hypot', 'rad', 'deg', 'clamp', 'lerp', 'vec', 'add', 'sub', 'scale', 'dot', 'cross', 'len', 'dist', 'norm', 'clampLen', 'lerpVec', 'dir', 'angle'];
71
71
  const VALUE_METHODS = new Set([...ARRAY_METHODS, ...STRING_METHODS, ...MAPSET_METHODS]);
72
- export const MOVE_METHODS = ['support', 'overlaps'];
73
- const NEW_METHODS = new Set(['route','label','dispatch','rayAll','rays','restart']);
74
- const CALLABLE = new Set([...VALUE_METHODS, ...WORLD_METHODS, ...MOVE_METHODS, ...MATH_METHODS]);
72
+ export const MOVE_METHODS = ['substeps', 'support', 'overlaps'];
73
+ export const PHYSICS_METHODS = ['step', 'cast', 'overlap'];
74
+ const NEW_METHODS = new Set(['step','cast','overlap','substeps','route','label','dispatch','rayAll','rays','restart','replace','tile','distance','flow']);
75
+ const CALLABLE = new Set([...VALUE_METHODS, ...WORLD_METHODS, ...MOVE_METHODS, ...PHYSICS_METHODS, ...MATH_METHODS]);
75
76
  const refusedName = (k) => REFUSED_NAMES.includes(k) || k.startsWith('toLocale');
76
77
  /** Operators that turn each operand into a number or a text. (`+` is handled beside them; `==` and `!=` are refused.) */
77
78
  const CONVERTING = new Set(['-', '*', '/', '%', '&', '|', '^', '<<', '>>', '>>>', '<', '>', '<=', '>=']);
@@ -2901,6 +2901,90 @@
2901
2901
  }
2902
2902
  }
2903
2903
  }
2904
+ },
2905
+ "0.47.0": {
2906
+ "files": {
2907
+ "CLAUDE.md": "ddf2b2a0376202f6",
2908
+ "README.md": "684b3cca8001673c",
2909
+ "HANDOFF.md": "29337e2ace95b2e7",
2910
+ "games/README.md": "5ad32fe80c61f117",
2911
+ "music/README.md": "a3dc042dadd39df8",
2912
+ "videos/README.md": "16babc3437d5545b",
2913
+ "posts/README.md": "37841aae476bd540",
2914
+ "site/README.md": "732894273a32dc3f",
2915
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
2916
+ },
2917
+ "sections": {
2918
+ "AGENTS.md": {
2919
+ "(intro)": "394cbbe934e80722",
2920
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
2921
+ "## Layout": "f3a280b8c96cd068",
2922
+ "## Apps: one screen, roles and parts": "8596f2b25edda3e2",
2923
+ "## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
2924
+ "## The site": "0cb8dd9a6f8a0096",
2925
+ "## Making games": "bb715272fd1a615b",
2926
+ "## The Game Codex and progress": "0738ef719cef3a4c",
2927
+ "## Art direction and models": "a48fff7bf0a5f2fc",
2928
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
2929
+ "## Servers and AI seats": "1ccfd549d2369a60",
2930
+ "## Selling things (the shop)": "9e020d69dd6a954e",
2931
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
2932
+ "## Rules": "27881d11d3ff3803",
2933
+ "## Telling Homie": "80b46fdd9e578db8",
2934
+ "## Beta": "9d02638207a26e73"
2935
+ }
2936
+ },
2937
+ "named": {
2938
+ "Homie Arcade": {
2939
+ "sections": {
2940
+ "AGENTS.md": {
2941
+ "## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
2942
+ }
2943
+ }
2944
+ }
2945
+ }
2946
+ },
2947
+ "0.48.0": {
2948
+ "files": {
2949
+ "CLAUDE.md": "ddf2b2a0376202f6",
2950
+ "README.md": "684b3cca8001673c",
2951
+ "HANDOFF.md": "29337e2ace95b2e7",
2952
+ "games/README.md": "5ad32fe80c61f117",
2953
+ "music/README.md": "a3dc042dadd39df8",
2954
+ "videos/README.md": "16babc3437d5545b",
2955
+ "posts/README.md": "37841aae476bd540",
2956
+ "site/README.md": "732894273a32dc3f",
2957
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
2958
+ },
2959
+ "sections": {
2960
+ "AGENTS.md": {
2961
+ "(intro)": "394cbbe934e80722",
2962
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
2963
+ "## Layout": "f3a280b8c96cd068",
2964
+ "## Apps: one screen, roles and parts": "8596f2b25edda3e2",
2965
+ "## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
2966
+ "## The site": "0cb8dd9a6f8a0096",
2967
+ "## Making games": "bb715272fd1a615b",
2968
+ "## The Game Codex and progress": "0738ef719cef3a4c",
2969
+ "## Art direction and models": "a48fff7bf0a5f2fc",
2970
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
2971
+ "## Servers and AI seats": "1ccfd549d2369a60",
2972
+ "## Selling things (the shop)": "9e020d69dd6a954e",
2973
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
2974
+ "## Rules": "27881d11d3ff3803",
2975
+ "## Telling Homie": "80b46fdd9e578db8",
2976
+ "## Beta": "9d02638207a26e73"
2977
+ }
2978
+ },
2979
+ "named": {
2980
+ "Homie Arcade": {
2981
+ "sections": {
2982
+ "AGENTS.md": {
2983
+ "## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
2984
+ }
2985
+ }
2986
+ }
2987
+ }
2904
2988
  }
2905
2989
  }
2906
2990
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.46.6",
3
+ "version": "0.48.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",
@@ -92,7 +92,8 @@
92
92
  "./functions": {
93
93
  "types": "./functions/functions.ts",
94
94
  "default": "./functions/functions.ts"
95
- }
95
+ },
96
+ "./rules/physics-engine": "./rules/physics-engine.ts"
96
97
  },
97
98
  "files": [
98
99
  "docs/",
@@ -165,5 +166,13 @@
165
166
  "scripts": {
166
167
  "test": "node --test --test-concurrency=2 --test-timeout=1800000 test/*.test.mjs",
167
168
  "prepack": "node lib/build-identity.mjs --check && node ../../scripts/authoring-guides.mjs --check"
169
+ },
170
+ "peerDependencies": {
171
+ "@homie-rocks/physics": "0.1.2"
172
+ },
173
+ "peerDependenciesMeta": {
174
+ "@homie-rocks/physics": {
175
+ "optional": true
176
+ }
168
177
  }
169
178
  }
@@ -11,6 +11,7 @@ export interface BodyShape { shape: string; radius: number; height: number; terr
11
11
  export interface Solid { min: Vec3; max: Vec3; r: number }
12
12
  export interface Hit3 { t: number; nx: number; ny: number; nz: number; id?: string }
13
13
  export interface Map3 {
14
+ wrap?: import('./rules.ts').MapWrap;
14
15
  staticMap?: Map3;
15
16
  bounds: MapBox; boxes: readonly MapBox[]; circles: readonly MapCircle[];
16
17
  heightTiles?: readonly MapHeightTile[];
package/rules/core.ts CHANGED
@@ -1,3 +1,8 @@
1
+ import { physicsWorld } from './physics.ts';
2
+ import { substeps } from './substeps.ts';
3
+ import { prepareMapReplacement, type MapRevision } from './map-state.ts';
4
+ import { tileApi } from './tiles.ts';
5
+ import { boundPosition, periodicDelta, relativePoint, blendPosition, wrapPoint } from './topology.mjs';
1
6
  import {routeGraph} from './navigation.ts';
2
7
  import { rayQuery, raySnapshot, type QueryTarget } from './query.ts';
3
8
  import { colliderSolid, castCollider2, colliderRow, collisionMap, collisionQueries, type CollisionRevision } from './live.ts';
@@ -140,7 +145,9 @@ export interface SavedCore {
140
145
  v: number; tick: number; epoch: number; rng: number; nextId: number; seq: number;
141
146
  round: [number, number, number, number]; overAt: number; match: [number, number]; trips: number;
142
147
  shared: unknown[]; policy: CorePolicy; intent?: [number, number, number?];
148
+ physics?: number[];
143
149
  collision?: [number,number];
150
+ maps?: {current:MapRevision|null;pending:MapRevision|null};
144
151
  history?: [number, [string, number, number, number, number][]][];
145
152
  asks: PendingAsk[];
146
153
  guideViews: [number, Record<string, unknown>][];
@@ -174,6 +181,7 @@ export interface Core {
174
181
  /** What happened since the last call: rounds, effects, a changed roster, changed shared state, a new epoch, a failure. */
175
182
  drain(): CoreOut[];
176
183
  save(): SavedCore;
184
+ dispose(): void;
177
185
  /** The `world` object rules are handed (a test walks it to prove nothing else is reachable). */
178
186
  readonly world: object;
179
187
  /**
@@ -191,6 +199,11 @@ export interface Core {
191
199
  * `Adjusted`; also `effect`, `think` and `decision`): the handler, what was done, the field's name and what was written.
192
200
  */
193
201
  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 {
202
+ if (opts.restore && (c.map.physics !== undefined) !== (opts.restore.physics !== undefined)) throw new Error('physics: save and game differ');
203
+ const physics = physicsWorld(c.map.physics, opts.restore?.physics);
204
+ let currentMap=c.map;
205
+ let mapRevision:MapRevision|null=null,pendingMap:MapRevision|null=null;
206
+ let pendingGeometry:typeof currentMap|null=null,mapCells=0,currentMapCells=0,pendingMapCells=0;
194
207
  const dims = c.dims;
195
208
  const tickHz = c.settings.tickHz;
196
209
  const dt = 1 / tickHz;
@@ -356,7 +369,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
356
369
  naming(kind.name, 'fields'); const f = coerceFields(kind.fields, init.fields, dims);
357
370
  naming(kind.name, 'motion'); const m = coerceFields(kind.motion, init.motion, dims); naming();
358
371
  const e: Ent = {
359
- id: `e${n.toString(36)}`, n, kind, pos: clampIn(V(at), kind.body?.radius ?? 0, kind.body ? kind.body.height || 2 * kind.body.radius : 0), vel: ZERO, heading: init.heading === undefined ? AHEAD : dir(init.heading, dims), grounded: dims === 2 || restsOnMap(c.map, clampIn(V(at), kind.body?.radius ?? 0, kind.body ? kind.body.height || 2 * kind.body.radius : 0), kind.body ?? { shape: 'sphere', radius: 0, height: 0 }),
372
+ id: `e${n.toString(36)}`, n, kind, pos: clampIn(V(at), kind.body?.radius ?? 0, kind.body ? kind.body.height || 2 * kind.body.radius : 0), vel: ZERO, heading: init.heading === undefined ? AHEAD : dir(init.heading, dims), grounded: dims === 2 || restsOnMap(currentMap, clampIn(V(at), kind.body?.radius ?? 0, kind.body ? kind.body.height || 2 * kind.body.radius : 0), kind.body ?? { shape: 'sphere', radius: 0, height: 0 }),
360
373
  r: 0, born: tick, arrived: false, why, dead: false,
361
374
  f, m,
362
375
  seat: player ? player.seat : -1, owner: player ? player.owner : '', driver: player ? player.driver : 'bot', away: false, goal: null,
@@ -366,10 +379,15 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
366
379
  return e;
367
380
  }
368
381
  function clampIn(p: Vec3, r: number, height = 0): Vec3 {
369
- const b = c.map.bounds;
370
- const x = Math.max(b.min.x + r, Math.min(b.max.x - r, p.x)); const y = Math.max(b.min.y + r, Math.min(b.max.y - r, p.y));
371
- const z = dims === 3 ? Math.max(b.min.z, Math.min(b.max.z - height, p.z)) : 0;
372
- return x === p.x && y === p.y && z === p.z ? p : V({ x, y, z });
382
+ if(!currentMap.wrap){
383
+ const b=currentMap.bounds;
384
+ const x=Math.max(b.min.x+r,Math.min(b.max.x-r,p.x));
385
+ const y=Math.max(b.min.y+r,Math.min(b.max.y-r,p.y));
386
+ const z=dims===3?Math.max(b.min.z,Math.min(b.max.z-height,p.z)):0;
387
+ return x===p.x&&y===p.y&&z===p.z?p:V({x,y,z});
388
+ }
389
+ const at = boundPosition(p,currentMap,r,height,dims);
390
+ return at.x===p.x&&at.y===p.y&&at.z===p.z?p:V(at);
373
391
  }
374
392
 
375
393
  /* ---------------------------------------------------------------- running a handler under the budget */
@@ -470,9 +488,15 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
470
488
  return k;
471
489
  };
472
490
  const mapApi = brand(Object.freeze({
473
- name: c.map.name,
474
- spot: (name: unknown): Vec3 | undefined => (typeof name === 'string' && Object.hasOwn(c.map.spots, name) ? c.map.spots[name][0] : undefined),
475
- spots: (name: unknown): readonly Vec3[] => (typeof name === 'string' && Object.hasOwn(c.map.spots, name) ? c.map.spots[name] : NO_SPOTS),
491
+ get name(){return currentMap.name;},
492
+ ...tileApi(()=>currentMap),
493
+ replace:(raw:unknown)=>{
494
+ need('room','world.map.replace()');
495
+ const prepared=prepareMapReplacement(raw,c.map);
496
+ pendingMap=Object.freeze([tick+1,prepared.raw]);pendingGeometry=prepared.map;pendingMapCells=prepared.cells;mapCells=currentMapCells+pendingMapCells;
497
+ },
498
+ spot: (name: unknown): Vec3 | undefined => (typeof name === 'string' && Object.hasOwn(currentMap.spots, name) ? currentMap.spots[name][0] : undefined),
499
+ spots: (name: unknown): readonly Vec3[] => (typeof name === 'string' && Object.hasOwn(currentMap.spots, name) ? currentMap.spots[name] : NO_SPOTS),
476
500
  }));
477
501
  let roundApi: Readonly<Record<string, unknown>> = Object.freeze({});
478
502
  const refreshRound = (): void => {
@@ -512,13 +536,13 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
512
536
  const row=e.kind.collider?colliderRow(e.id,e.pos,e.kind.body,e.kind.collider,e.f):null;
513
537
  if(e.kind.collider&&!row)continue;
514
538
  let at=e.pos;
515
- 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};}}
539
+ 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=blendPosition({x:a[1],y:a[2],z:a[3]},{x:b[1],y:b[2],z:b[3]},mix,currentMap.wrap);}}
516
540
  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});
517
541
  }
518
542
  return targets;
519
543
  }
520
544
  function cast3(p: Vec3, d: Vec3, shape: BodyShape, ignored: Set<string>): Hit3 | null {
521
- let best = castMap3(c.map, p, d, shape);
545
+ let best = castMap3(currentMap, p, d, shape);
522
546
  const solid = solidAt(p, shape);
523
547
  for (const other of ents.values()) {
524
548
  if (!other.kind.body || ignored.has(other.id)) continue;
@@ -535,6 +559,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
535
559
  tick: () => tick, dt: () => dt, round: () => roundApi, shared: sharedView, level: () => policy.level, levelMax: () => policy.levelMax, guideLevel: () => policy.guideLevel ?? policy.level, guideSeats: () => policy.guideSeats ?? EMPTY_GUIDE_SEATS, kids: () => Boolean(policy.kids), levelSet: () => Boolean(policy.levelSet),
536
560
  // What the running handler may still spend (0.46.3): a handler that plans can stop short of its share instead of being stopped.
537
561
  unitsLeft: () => Math.max(0, Math.min(2147483647, Math.floor(G.left))),
562
+ ...(physics ? {physics: () => physics.api} : {}),
538
563
  stage: () => stage, tune: () => c.tune, math: () => math, map: () => mapApi,
539
564
  };
540
565
  for (const [name, get] of Object.entries(getters)) Object.defineProperty(world, name, { get, enumerable: true });
@@ -568,8 +593,16 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
568
593
  const parent = cx, parentTouched = touched, note = G.note;
569
594
  cx = {scope:'ent',ent:e}; touched = []; dispatchDepth++;
570
595
  if (noted) G.note = (what, at, written) => noted(e.kind.name, `on.${String(ev)}`, what, at, written);
596
+ let failure: unknown;
571
597
  try { fn(world,e.self,payload); }
572
- finally { try { settle(); } finally { cx=parent; touched=parentTouched; G.note=note; dispatchDepth--; } }
598
+ catch(error) { failure=error; throw error; }
599
+ finally {
600
+ try { settle(); } catch(error) { failure=error; throw error; }
601
+ finally {
602
+ cx=parent; touched=parentTouched; G.note=note; dispatchDepth--;
603
+ opts.observe?.(e.kind.name, `on.${String(ev)}`, failure===undefined?undefined:errorText(failure));
604
+ }
605
+ }
573
606
  return true;
574
607
  },
575
608
  sendRoom: (ev: unknown, data?: unknown): void => {
@@ -651,7 +684,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
651
684
  e.pos = clampIn(V(at), e.kind.body?.radius ?? 0, e.kind.body ? e.kind.body.height || 2 * e.kind.body.radius : 0);
652
685
  e.vel = vel !== undefined ? V(vel) : ZERO;
653
686
  if (heading !== undefined) e.heading = dir(heading, dims);
654
- e.grounded = dims === 2 || restsOnMap(c.map, e.pos, e.kind.body ?? { shape: 'sphere', radius: 0, height: 0 });
687
+ e.grounded = dims === 2 || restsOnMap(currentMap, e.pos, e.kind.body ?? { shape: 'sphere', radius: 0, height: 0 });
655
688
  e.r = (e.r + 1) & 0xffff;
656
689
  e.allow = 0;
657
690
  },
@@ -662,8 +695,8 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
662
695
  let cost = 0;
663
696
  for (const e of ents.values()) {
664
697
  if (k && e.kind !== k) continue;
665
- const dx = e.pos.x - p.x; const dy = e.pos.y - p.y;
666
- const dz = dims === 3 ? e.pos.z - p.z : 0;
698
+ const dx = periodicDelta(p.x,e.pos.x,currentMap.wrap?.x); const dy = periodicDelta(p.y,e.pos.y,currentMap.wrap?.y);
699
+ const dz = dims === 3 ? periodicDelta(p.z,e.pos.z,currentMap.wrap?.z) : 0;
667
700
  const d2 = dx * dx + dy * dy + dz * dz;
668
701
  if (d2 <= R * R) { found.push({ d: d2, e }); cost += viewCost.get(e.kind) as number; }
669
702
  }
@@ -678,12 +711,13 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
678
711
  if (max.x - min.x > 2 * REACH_M || max.y - min.y > 2 * REACH_M || dims === 3 && max.z - min.z > 2 * REACH_M) throw new Error(`world.inBox reaches ${REACH_M} m from its centre at most`);
679
712
  const found: Ent[] = [];
680
713
  let cost = 0;
681
- for (const e of ents.values()) if ((!k || e.kind === k) && e.pos.x >= min.x && e.pos.x <= max.x && e.pos.y >= min.y && e.pos.y <= max.y && (dims === 2 || e.pos.z >= min.z && e.pos.z <= max.z)) { found.push(e); cost += viewCost.get(e.kind) as number; }
714
+ const centre={x:(min.x+max.x)/2,y:(min.y+max.y)/2,z:(min.z+max.z)/2};
715
+ for (const e of ents.values()) {const p=relativePoint(e.pos,centre,currentMap.wrap);if ((!k || e.kind === k) && p.x >= min.x && p.x <= max.x && p.y >= min.y && p.y <= max.y && (dims === 2 || p.z >= min.z && p.z <= max.z)) { found.push(e); cost += viewCost.get(e.kind) as number; }}
682
716
  charge(cost);
683
717
  return Object.freeze(found.map(viewOf));
684
718
  },
685
719
  ray: (from: unknown, direction: unknown, max: unknown, options?: unknown): unknown => {
686
- if (options !== undefined) return rayQuery(c.map, rayTargets(options), dims, from, direction, max, options, cx.ent?.id)[0];
720
+ if (options !== undefined || currentMap.wrap) return rayQuery(currentMap, rayTargets(options), dims, from, direction, max, options, cx.ent?.id)[0];
687
721
  // Charged before the cast, for every shape it may test: the map's, and every entity in the room.
688
722
  charge(20 + 4 * ents.size);
689
723
  const p = V(from); const d = dir(direction, dims); const far = reach(max, 'world.ray');
@@ -692,7 +726,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
692
726
  const hit = cast3(p, delta, { shape: 'sphere', radius: 0, height: 0 }, new Set(cx.ent ? [cx.ent.id] : []));
693
727
  return hit ? Object.freeze({ ...(hit.id ? { entity: hit.id } : {}), at: V({ x: p.x + delta.x * hit.t, y: p.y + delta.y * hit.t, z: p.z + delta.z * hit.t }), normal: V({ x: hit.nx, y: hit.ny, z: hit.nz }), dist: far * hit.t }) : undefined;
694
728
  }
695
- const cast = castMap(c.map, p.x, p.y, d.x * far, d.y * far, 0);
729
+ const cast = castMap(currentMap, p.x, p.y, d.x * far, d.y * far, 0);
696
730
  let best: Hit | null = cast.hit;
697
731
  for (const e of ents.values()) {
698
732
  if (!e.kind.body || e === cx.ent) continue;
@@ -711,12 +745,19 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
711
745
  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);
712
746
  },
713
747
  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);},
714
- rays: (options?: unknown): unknown => raySnapshot(c.map,rayTargets(options),dims,options,cx.ent?.id),
715
- rayAll: (from: unknown, direction: unknown, max: unknown, options?: unknown): unknown => rayQuery(c.map, rayTargets(options), dims, from, direction, max, options, cx.ent?.id, true),
748
+ rays: (options?: unknown): unknown => raySnapshot(currentMap,rayTargets(options),dims,options,cx.ent?.id),
749
+ rayAll: (from: unknown, direction: unknown, max: unknown, options?: unknown): unknown => rayQuery(currentMap, rayTargets(options), dims, from, direction, max, options, cx.ent?.id, true),
716
750
  sweep: (self: unknown, delta: unknown, o?: unknown): unknown => {
717
751
  const e = mine(self, 'world.sweep');
718
752
  charge(20 + 4 * ents.size);
719
753
  const d = V(delta);
754
+ if(currentMap.wrap){
755
+ const list=own(o,'ignore'),ignore:string[]=[];
756
+ if(Array.isArray(list))for(let i=0;i<list.length&&i<IGNORE_MAX;i++){const id=own(list,i);if(typeof id==='string')ignore.push(id);}
757
+ const scene=raySnapshot(currentMap,rayTargets(undefined),dims,{radius:e.kind.body?.radius??0,height:e.kind.body?.height||2*(e.kind.body?.radius??0),shape:e.kind.body?.shape==='circle'?'sphere':e.kind.body?.shape??'sphere',ignore},e.id);
758
+ const body={pos:e.pos,grounded:e.grounded};const hit=scene.sweep(body,d);
759
+ e.pos=V(wrapPoint(body.pos,currentMap.wrap));e.grounded=body.grounded;return hit;
760
+ }
720
761
  const radius = e.kind.body?.radius ?? 0;
721
762
  // 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.
722
763
  const list = own(o, 'ignore');
@@ -733,7 +774,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
733
774
  e.grounded = Boolean(support && support.nz > 0.5);
734
775
  return hit ? Object.freeze({ ...(hit.id ? { entity: hit.id } : {}), at: e.pos, normal: V({ x: hit.nx, y: hit.ny, z: hit.nz }) }) : undefined;
735
776
  }
736
- const cast = castMap(c.map, e.pos.x, e.pos.y, d.x, d.y, radius);
777
+ const cast = castMap(currentMap, e.pos.x, e.pos.y, d.x, d.y, radius);
737
778
  let best: Hit | null = cast.hit;
738
779
  for (const other of ents.values()) {
739
780
  if (other === e || !other.kind.body || ignore.has(other.id)) continue;
@@ -778,18 +819,20 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
778
819
  const row = colliderRow(e.id,e.pos,e.kind.body,e.kind.collider,e.f,e.kind.name,e.kind.query);
779
820
  return row ? [row] : [];
780
821
  });
781
- const text = JSON.stringify(rows);
782
- if (!collisionPrevious || text !== collisionText) {collisionText=text;collisionPrevious=[...(collisionPrevious ? [tick,tick+1] : opts.restore?.collision ?? [tick,tick+1]) as [number,number],rows];}
822
+ const metadata={...(currentMap.wrap?{wrap:currentMap.wrap}:{}),...((pendingMap??mapRevision)?{map:pendingMap??mapRevision,...(pendingMap&&mapRevision?{previousMap:mapRevision}:{})}:{})};
823
+ const text = JSON.stringify(Object.keys(metadata).length ? [rows,metadata] : rows);
824
+ if (!collisionPrevious || text !== collisionText) {collisionText=text;collisionPrevious=[...(collisionPrevious ? [tick,tick+1] : opts.restore?.collision ?? [tick,tick+1]) as [number,number],rows,...(Object.keys(metadata).length?[metadata]:[])];}
783
825
  return collisionPrevious;
784
826
  };
785
- let moveGeometry = c.map as import('./math.ts').MapShapes;
827
+ let moveGeometry = currentMap as import('./math.ts').MapShapes;
786
828
  const queries = collisionQueries(() => moveGeometry, () => moveShape, dims, () => cx.ent?.id);
787
829
  const moveWorld = brand(Object.freeze(queries));
788
- const moveMap = brand(Object.freeze({ name: c.map.name, spot: mapApi.spot, spots: mapApi.spots, ...queries }));
830
+ const moveMap = brand(Object.freeze({ get name(){return currentMap.name;}, spot: mapApi.spot, spots: mapApi.spots, ...tileApi(()=>currentMap), ...queries }));
789
831
  let moveShape = { shape: 'sphere', radius: 0, height: 0 };
790
832
  const moveCtx = brand({} as Record<string, unknown>);
791
833
  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 });
792
- Object.assign(moveCtx, { ticks: (seconds: unknown): number => { charge(1); return ticks(seconds); } });
834
+ Object.assign(moveCtx, { substeps: substeps(c.settings.tickHz), ticks: (seconds: unknown): number => { charge(1); return ticks(seconds); } });
835
+ if (physics) Object.assign(moveCtx, {physics: physics.movement});
793
836
  Object.freeze(moveCtx);
794
837
  const joinCtx = brand({} as Record<string, unknown>);
795
838
  for (const [name, get] of Object.entries({ map: () => mapApi, shared: sharedView, tune: () => c.tune, math: () => math, round: () => roundApi })) Object.defineProperty(joinCtx, name, { get, enumerable: true });
@@ -1037,6 +1080,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1037
1080
  /* ---------------------------------------------------------------- one tick */
1038
1081
 
1039
1082
  function inArea(a: any, p: Vec3): boolean {
1083
+ if(currentMap.wrap) p = relativePoint(p, a.k === 'b' ? {x:(a.min.x+a.max.x)/2,y:(a.min.y+a.max.y)/2,z:(a.min.z+a.max.z)/2} : a.at, currentMap.wrap);
1040
1084
  if (a.k === 'b') return p.x >= a.min.x && p.x <= a.max.x && p.y >= a.min.y && p.y <= a.max.y && (dims === 2 || p.z >= a.min.z && p.z <= a.max.z);
1041
1085
  const dx = p.x - a.at.x; const dy = p.y - a.at.y;
1042
1086
  const dz = dims === 3 ? p.z - a.at.z : 0;
@@ -1049,6 +1093,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1049
1093
  }
1050
1094
  function step(inputs: ReadonlyMap<number, StepInput> = new Map(), guides?: () => void): void {
1051
1095
  tick = (tick + 1) >>> 0;
1096
+ if(pendingMap&&pendingMap[0]<=tick){currentMap=pendingGeometry!;mapRevision=pendingMap;currentMapCells=pendingMapCells;mapCells=currentMapCells;pendingMapCells=0;pendingMap=null;pendingGeometry=null;moveGeometry=currentMap;}
1052
1097
  // The tick pays first for the state it sends: what the last snapshot packed. A room that holds more than a tick can send has nothing left for its handlers, and ends as any room does whose budget trips on every tick.
1053
1098
  left = c.settings.budget.tick - SNAP * snapCells;
1054
1099
  cut = false; stopped = false; threw = null;
@@ -1107,7 +1152,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1107
1152
  }
1108
1153
  guides?.();
1109
1154
  // All movers see the same world, independent of entity iteration order.
1110
- if (hasColliders) { left -= 64 * ents.size; moveGeometry = collisionMap(c.map, collisionState()[2]); }
1155
+ if (hasColliders) { left -= 64 * ents.size; moveGeometry = collisionMap(currentMap, collisionState()[2]); }
1111
1156
  // Phase 1: one input step and `move` for every body.
1112
1157
  for (const e of [...ents.values()]) {
1113
1158
  if (e.dead || !e.kind.body) continue;
@@ -1140,7 +1185,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1140
1185
  // Owner movement: the browser says where its body is, and the server holds the claim to maxSpeed.
1141
1186
  e.allow = Math.min(e.allow + body.maxSpeed * dt * 1.02, body.maxSpeed * 0.25 + 0.05);
1142
1187
  if (claim && claim.r === e.r) {
1143
- const dx = claim.pos.x - e.pos.x; const dy = claim.pos.y - e.pos.y;
1188
+ const dx = periodicDelta(e.pos.x,claim.pos.x,currentMap.wrap?.x); const dy = periodicDelta(e.pos.y,claim.pos.y,currentMap.wrap?.y);
1144
1189
  const dist = Math.sqrt(dx * dx + dy * dy);
1145
1190
  const go = Math.min(dist, e.allow);
1146
1191
  // The browser ran the game's own `move` and came further than `maxSpeed` allows: the body is held back.
@@ -1153,7 +1198,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1153
1198
  e.grounded = dims === 2;
1154
1199
  // The support cast is map work even when a browser supplies the pose.
1155
1200
  // Keep it under the same movement quota as a server-driven body's sweep.
1156
- if (dims === 3) run(k.name, 'move', e, 'move', () => { e.grounded = restsOnMap(c.map, e.pos, body); }, 'body');
1201
+ if (dims === 3) run(k.name, 'move', e, 'move', () => { e.grounded = restsOnMap(currentMap, e.pos, body); }, 'body');
1157
1202
  }
1158
1203
  continue;
1159
1204
  }
@@ -1244,8 +1289,10 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1244
1289
  }
1245
1290
  function save(): SavedCore {
1246
1291
  return {
1292
+ ...(physics ? {physics: physics.save()} : {}),
1247
1293
  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,
1248
- ...(hasColliders?{collision:collisionState().slice(0,2) as [number,number]}:{}),
1294
+ ...((hasColliders||mapRevision||pendingMap)?{collision:collisionState().slice(0,2) as [number,number]}:{}),
1295
+ ...((mapRevision||pendingMap)?{maps:{current:mapRevision,pending:pendingMap}}:{}),
1249
1296
  ...(historyTicks?{history:poseHistory.map(([at,rows])=>[at,rows.map(row=>[...row])] as SavedCore['history'])}:{}),
1250
1297
  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],
1251
1298
  ents: [...ents.values()].map(saveEnt), spawns: spawns.map(saveEnt),
@@ -1264,8 +1311,12 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1264
1311
  check(Array.isArray(value) && JSON.stringify(value) === JSON.stringify(packFields(list, unpackFields(list, value, dims), dims)));
1265
1312
  };
1266
1313
  check(r.v === SAVE_REVISION);
1314
+ if(r.maps!==undefined){
1315
+ check(r.maps&&Object.keys(r.maps).length===2&&'current' in r.maps&&'pending' in r.maps&&Object.keys(r.maps).every(k=>k==='current'||k==='pending'));
1316
+ for(const [name,value] of Object.entries(r.maps)){if(value===null)continue;check(Array.isArray(value)&&value.length===2&&uint(value[0])&&(name==='pending'?value[0]===r.tick+1:value[0]<=r.tick));prepareMapReplacement(value[1],c.map);}
1317
+ }
1267
1318
  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);
1268
- 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)));
1319
+ 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', 'maps', 'physics'].includes(k)));
1269
1320
  const askNames = new Set();
1270
1321
  check(Array.isArray(r.asks));
1271
1322
  for (const a of r.asks) {
@@ -1311,7 +1362,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1311
1362
  for (const i of [7, 8, 9]) check(JSON.stringify(w[i]) === JSON.stringify(packVec(unpackVec(w[i], 3), 3)));
1312
1363
  const goal = w[15] as any;
1313
1364
  if (goal !== null) check(goal && typeof goal === 'object' && !Array.isArray(goal) && Object.keys(goal).every(k => ['goal', 'args', 'from', 'at', 'asked', 'state'].includes(k)) && typeof goal.goal === 'string' && goal.args && typeof goal.args === 'object' && !Array.isArray(goal.args) && ['brain', 'floor'].includes(goal.from) && uint(goal.at) && goal.at <= r.tick && typeof goal.asked === 'boolean' && goal.state === 'active');
1314
- const pos = unpackVec(w[7] as number[], 3), bounds = c.map.bounds;
1365
+ const pos = unpackVec(w[7] as number[], 3), bounds = currentMap.bounds;
1315
1366
  check(pos.x >= bounds.min.x && pos.x <= bounds.max.x && pos.y >= bounds.min.y && pos.y <= bounds.max.y && (dims === 3 ? pos.z >= bounds.min.z && pos.z <= bounds.max.z : pos.z === 0));
1316
1367
  check(Number.isInteger(w[11]) && (w[11] as number) >= -1 && (w[11] as number) < c.seats && ['person', 'bot', 'ai'].includes(w[12] as string) && typeof w[13] === 'string' && w[13].length <= 128);
1317
1368
  if (k.player) { check(!bodySeats.has(w[11])); bodySeats.add(w[11]); }
@@ -1387,6 +1438,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1387
1438
  }
1388
1439
  }
1389
1440
  const r = opts.restore;
1441
+ try {
1390
1442
  if (r) {
1391
1443
  if (r.v !== SAVE_REVISION) throw new Error('this save was written by another version of the runtime');
1392
1444
  validateSave(r);
@@ -1398,6 +1450,12 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1398
1450
  }
1399
1451
  pendingAsks = r.asks.map(a => ({ ...a, state: deepFreeze(a.state) }));
1400
1452
  if ([opts.restoreEpoch].some((e) => e !== undefined && (!Number.isSafeInteger(e) || e <= 0))) throw new Error('restored epoch is invalid');
1453
+ if(r.maps){
1454
+ mapRevision=r.maps.current;pendingMap=r.maps.pending;
1455
+ if(mapRevision){const p=prepareMapReplacement(mapRevision[1],c.map);mapRevision=Object.freeze([mapRevision[0],p.raw]);currentMap=p.map;currentMapCells=p.cells;mapCells=p.cells;}
1456
+ if(pendingMap){const p=prepareMapReplacement(pendingMap[1],c.map);pendingMap=Object.freeze([pendingMap[0],p.raw]);pendingGeometry=p.map;pendingMapCells=p.cells;mapCells=currentMapCells+pendingMapCells;}
1457
+ moveGeometry=currentMap;
1458
+ }
1401
1459
  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;
1402
1460
  endAsked = r.intent?.[0] === 1; finishing = r.intent?.[1] === 1; restartAsked = r.intent?.[2] === 1;
1403
1461
  round = { n: r.round[0], phase: r.round[1] === 1 ? 'live' : 'over', endsAt: r.round[2], startedAt: r.round[3] };
@@ -1416,8 +1474,9 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1416
1474
  });
1417
1475
  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)) }));
1418
1476
  ops = r.ops as typeof ops;
1419
- if(hasColliders)collisionState();
1477
+ if(hasColliders||mapRevision||pendingMap)collisionState();
1420
1478
  } else startMatch();
1479
+ } catch (error) { physics?.dispose(); throw error; }
1421
1480
 
1422
1481
  function decisionResult(value: unknown): Record<string, unknown> {
1423
1482
  const v = plainData(value, { n: ANSWER_MAX });
@@ -1501,8 +1560,8 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1501
1560
  const list = [...ents.values()].map((e) => packEntity(e.kind, e, dims));
1502
1561
  // Include all three components of position, velocity and heading in a 3D snapshot.
1503
1562
  snapCells = packed.n + (dims === 3 ? 13 : 10) * list.length;
1504
- const collision = hasColliders ? collisionState() : null;
1505
- if (collision) snapCells += 3 + collision[2].length * 8;
1563
+ const collision = hasColliders||mapRevision||pendingMap ? collisionState() : currentMap.wrap ? [0,0,[],{wrap:currentMap.wrap}] : null;
1564
+ if (collision) snapCells += 3 + collision[2].length * 8 + mapCells;
1506
1565
  return [[round.n, round.phase === 'live' ? 1 : 0, round.endsAt], list, ...(collision ? [collision] : [])];
1507
1566
  },
1508
1567
  shared: () => packFields(c.shared, shared, dims),
@@ -1512,6 +1571,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1512
1571
  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; },
1513
1572
  drain: () => { const o = out; out = []; return o; },
1514
1573
  save,
1574
+ dispose: () => physics?.dispose(),
1515
1575
  world,
1516
1576
  stats,
1517
1577
  };
package/rules/host.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { periodicDelta } from './topology.mjs';
1
2
  import { stageTimings } from './timing.mjs';
2
3
  /*
3
4
  * host.ts — the host runtime: a rules module playing the host's part on the wire.
@@ -675,7 +676,7 @@ export function createHost(o: HostOptions): Host {
675
676
  const watching = all.filter((_, n) => { const a = x.list[n][3]; return !a || a[0] >= 0; });
676
677
  for (const seat of present.keys()) {
677
678
  const l = bodies.get(seat);
678
- const mine = !l ? watching : all.filter((_, n) => { const a = x.list[n][3]; return !a || a[1].includes(l.id) || (a[0] >= 0 && a[2] !== null && Math.hypot(a[2][0] - l.pos.x, a[2][1] - l.pos.y, a[2][2] - l.pos.z) <= a[0]); });
679
+ const mine = !l ? watching : all.filter((_, n) => { const a = x.list[n][3]; return !a || a[1].includes(l.id) || (a[0] >= 0 && a[2] !== null && Math.hypot(periodicDelta(l.pos.x,a[2][0],o.compiled.map.wrap?.x), periodicDelta(l.pos.y,a[2][1],o.compiled.map.wrap?.y), periodicDelta(l.pos.z,a[2][2],o.compiled.map.wrap?.z)) <= a[0]); });
679
680
  if (mine.length) o.send({ t: 'ev', from: null, k: 'fx', d: [x.tick, mine], to: seat });
680
681
  }
681
682
  if (watching.length) o.send({ t: 'ev', from: null, k: 'fx', d: [x.tick, watching], to: -1 });
@@ -931,6 +932,7 @@ export function createHost(o: HostOptions): Host {
931
932
  }
932
933
  function stop(): void {
933
934
  running = false; paused = false; ended = true;
935
+ core.dispose();
934
936
  disarm();
935
937
  agents?.stop();
936
938
  for (let i = recent.length - 1; i >= 0; i -= 1) if (recent[i].who === self) recent.splice(i, 1);