@homie-rocks/studio 0.46.5 → 0.47.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.
@@ -2859,6 +2859,90 @@
2859
2859
  }
2860
2860
  }
2861
2861
  }
2862
+ },
2863
+ "0.46.6": {
2864
+ "files": {
2865
+ "CLAUDE.md": "ddf2b2a0376202f6",
2866
+ "README.md": "684b3cca8001673c",
2867
+ "HANDOFF.md": "29337e2ace95b2e7",
2868
+ "games/README.md": "5ad32fe80c61f117",
2869
+ "music/README.md": "a3dc042dadd39df8",
2870
+ "videos/README.md": "16babc3437d5545b",
2871
+ "posts/README.md": "37841aae476bd540",
2872
+ "site/README.md": "732894273a32dc3f",
2873
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
2874
+ },
2875
+ "sections": {
2876
+ "AGENTS.md": {
2877
+ "(intro)": "394cbbe934e80722",
2878
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
2879
+ "## Layout": "f3a280b8c96cd068",
2880
+ "## Apps: one screen, roles and parts": "8596f2b25edda3e2",
2881
+ "## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
2882
+ "## The site": "0cb8dd9a6f8a0096",
2883
+ "## Making games": "bb715272fd1a615b",
2884
+ "## The Game Codex and progress": "0738ef719cef3a4c",
2885
+ "## Art direction and models": "a48fff7bf0a5f2fc",
2886
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
2887
+ "## Servers and AI seats": "1ccfd549d2369a60",
2888
+ "## Selling things (the shop)": "9e020d69dd6a954e",
2889
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
2890
+ "## Rules": "27881d11d3ff3803",
2891
+ "## Telling Homie": "80b46fdd9e578db8",
2892
+ "## Beta": "9d02638207a26e73"
2893
+ }
2894
+ },
2895
+ "named": {
2896
+ "Homie Arcade": {
2897
+ "sections": {
2898
+ "AGENTS.md": {
2899
+ "## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
2900
+ }
2901
+ }
2902
+ }
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
+ }
2862
2946
  }
2863
2947
  }
2864
2948
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.46.5",
3
+ "version": "0.47.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",
@@ -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,6 @@
1
+ import { prepareMapReplacement, type MapRevision } from './map-state.ts';
2
+ import { tileApi } from './tiles.ts';
3
+ import { boundPosition, periodicDelta, relativePoint, blendPosition, wrapPoint } from './topology.mjs';
1
4
  import {routeGraph} from './navigation.ts';
2
5
  import { rayQuery, raySnapshot, type QueryTarget } from './query.ts';
3
6
  import { colliderSolid, castCollider2, colliderRow, collisionMap, collisionQueries, type CollisionRevision } from './live.ts';
@@ -141,6 +144,7 @@ export interface SavedCore {
141
144
  round: [number, number, number, number]; overAt: number; match: [number, number]; trips: number;
142
145
  shared: unknown[]; policy: CorePolicy; intent?: [number, number, number?];
143
146
  collision?: [number,number];
147
+ maps?: {current:MapRevision|null;pending:MapRevision|null};
144
148
  history?: [number, [string, number, number, number, number][]][];
145
149
  asks: PendingAsk[];
146
150
  guideViews: [number, Record<string, unknown>][];
@@ -191,6 +195,9 @@ export interface Core {
191
195
  * `Adjusted`; also `effect`, `think` and `decision`): the handler, what was done, the field's name and what was written.
192
196
  */
193
197
  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 {
198
+ let currentMap=c.map;
199
+ let mapRevision:MapRevision|null=null,pendingMap:MapRevision|null=null;
200
+ let pendingGeometry:typeof currentMap|null=null,mapCells=0,currentMapCells=0,pendingMapCells=0;
194
201
  const dims = c.dims;
195
202
  const tickHz = c.settings.tickHz;
196
203
  const dt = 1 / tickHz;
@@ -356,7 +363,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
356
363
  naming(kind.name, 'fields'); const f = coerceFields(kind.fields, init.fields, dims);
357
364
  naming(kind.name, 'motion'); const m = coerceFields(kind.motion, init.motion, dims); naming();
358
365
  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 }),
366
+ 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
367
  r: 0, born: tick, arrived: false, why, dead: false,
361
368
  f, m,
362
369
  seat: player ? player.seat : -1, owner: player ? player.owner : '', driver: player ? player.driver : 'bot', away: false, goal: null,
@@ -366,10 +373,15 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
366
373
  return e;
367
374
  }
368
375
  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 });
376
+ if(!currentMap.wrap){
377
+ const b=currentMap.bounds;
378
+ const x=Math.max(b.min.x+r,Math.min(b.max.x-r,p.x));
379
+ const y=Math.max(b.min.y+r,Math.min(b.max.y-r,p.y));
380
+ const z=dims===3?Math.max(b.min.z,Math.min(b.max.z-height,p.z)):0;
381
+ return x===p.x&&y===p.y&&z===p.z?p:V({x,y,z});
382
+ }
383
+ const at = boundPosition(p,currentMap,r,height,dims);
384
+ return at.x===p.x&&at.y===p.y&&at.z===p.z?p:V(at);
373
385
  }
374
386
 
375
387
  /* ---------------------------------------------------------------- running a handler under the budget */
@@ -470,9 +482,15 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
470
482
  return k;
471
483
  };
472
484
  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),
485
+ get name(){return currentMap.name;},
486
+ ...tileApi(()=>currentMap),
487
+ replace:(raw:unknown)=>{
488
+ need('room','world.map.replace()');
489
+ const prepared=prepareMapReplacement(raw,c.map);
490
+ pendingMap=Object.freeze([tick+1,prepared.raw]);pendingGeometry=prepared.map;pendingMapCells=prepared.cells;mapCells=currentMapCells+pendingMapCells;
491
+ },
492
+ spot: (name: unknown): Vec3 | undefined => (typeof name === 'string' && Object.hasOwn(currentMap.spots, name) ? currentMap.spots[name][0] : undefined),
493
+ spots: (name: unknown): readonly Vec3[] => (typeof name === 'string' && Object.hasOwn(currentMap.spots, name) ? currentMap.spots[name] : NO_SPOTS),
476
494
  }));
477
495
  let roundApi: Readonly<Record<string, unknown>> = Object.freeze({});
478
496
  const refreshRound = (): void => {
@@ -512,13 +530,13 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
512
530
  const row=e.kind.collider?colliderRow(e.id,e.pos,e.kind.body,e.kind.collider,e.f):null;
513
531
  if(e.kind.collider&&!row)continue;
514
532
  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};}}
533
+ 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
534
  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
535
  }
518
536
  return targets;
519
537
  }
520
538
  function cast3(p: Vec3, d: Vec3, shape: BodyShape, ignored: Set<string>): Hit3 | null {
521
- let best = castMap3(c.map, p, d, shape);
539
+ let best = castMap3(currentMap, p, d, shape);
522
540
  const solid = solidAt(p, shape);
523
541
  for (const other of ents.values()) {
524
542
  if (!other.kind.body || ignored.has(other.id)) continue;
@@ -568,8 +586,16 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
568
586
  const parent = cx, parentTouched = touched, note = G.note;
569
587
  cx = {scope:'ent',ent:e}; touched = []; dispatchDepth++;
570
588
  if (noted) G.note = (what, at, written) => noted(e.kind.name, `on.${String(ev)}`, what, at, written);
589
+ let failure: unknown;
571
590
  try { fn(world,e.self,payload); }
572
- finally { try { settle(); } finally { cx=parent; touched=parentTouched; G.note=note; dispatchDepth--; } }
591
+ catch(error) { failure=error; throw error; }
592
+ finally {
593
+ try { settle(); } catch(error) { failure=error; throw error; }
594
+ finally {
595
+ cx=parent; touched=parentTouched; G.note=note; dispatchDepth--;
596
+ opts.observe?.(e.kind.name, `on.${String(ev)}`, failure===undefined?undefined:errorText(failure));
597
+ }
598
+ }
573
599
  return true;
574
600
  },
575
601
  sendRoom: (ev: unknown, data?: unknown): void => {
@@ -651,7 +677,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
651
677
  e.pos = clampIn(V(at), e.kind.body?.radius ?? 0, e.kind.body ? e.kind.body.height || 2 * e.kind.body.radius : 0);
652
678
  e.vel = vel !== undefined ? V(vel) : ZERO;
653
679
  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 });
680
+ e.grounded = dims === 2 || restsOnMap(currentMap, e.pos, e.kind.body ?? { shape: 'sphere', radius: 0, height: 0 });
655
681
  e.r = (e.r + 1) & 0xffff;
656
682
  e.allow = 0;
657
683
  },
@@ -662,8 +688,8 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
662
688
  let cost = 0;
663
689
  for (const e of ents.values()) {
664
690
  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;
691
+ const dx = periodicDelta(p.x,e.pos.x,currentMap.wrap?.x); const dy = periodicDelta(p.y,e.pos.y,currentMap.wrap?.y);
692
+ const dz = dims === 3 ? periodicDelta(p.z,e.pos.z,currentMap.wrap?.z) : 0;
667
693
  const d2 = dx * dx + dy * dy + dz * dz;
668
694
  if (d2 <= R * R) { found.push({ d: d2, e }); cost += viewCost.get(e.kind) as number; }
669
695
  }
@@ -678,12 +704,13 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
678
704
  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
705
  const found: Ent[] = [];
680
706
  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; }
707
+ const centre={x:(min.x+max.x)/2,y:(min.y+max.y)/2,z:(min.z+max.z)/2};
708
+ 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
709
  charge(cost);
683
710
  return Object.freeze(found.map(viewOf));
684
711
  },
685
712
  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];
713
+ if (options !== undefined || currentMap.wrap) return rayQuery(currentMap, rayTargets(options), dims, from, direction, max, options, cx.ent?.id)[0];
687
714
  // Charged before the cast, for every shape it may test: the map's, and every entity in the room.
688
715
  charge(20 + 4 * ents.size);
689
716
  const p = V(from); const d = dir(direction, dims); const far = reach(max, 'world.ray');
@@ -692,7 +719,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
692
719
  const hit = cast3(p, delta, { shape: 'sphere', radius: 0, height: 0 }, new Set(cx.ent ? [cx.ent.id] : []));
693
720
  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
721
  }
695
- const cast = castMap(c.map, p.x, p.y, d.x * far, d.y * far, 0);
722
+ const cast = castMap(currentMap, p.x, p.y, d.x * far, d.y * far, 0);
696
723
  let best: Hit | null = cast.hit;
697
724
  for (const e of ents.values()) {
698
725
  if (!e.kind.body || e === cx.ent) continue;
@@ -711,12 +738,19 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
711
738
  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
739
  },
713
740
  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),
741
+ rays: (options?: unknown): unknown => raySnapshot(currentMap,rayTargets(options),dims,options,cx.ent?.id),
742
+ rayAll: (from: unknown, direction: unknown, max: unknown, options?: unknown): unknown => rayQuery(currentMap, rayTargets(options), dims, from, direction, max, options, cx.ent?.id, true),
716
743
  sweep: (self: unknown, delta: unknown, o?: unknown): unknown => {
717
744
  const e = mine(self, 'world.sweep');
718
745
  charge(20 + 4 * ents.size);
719
746
  const d = V(delta);
747
+ if(currentMap.wrap){
748
+ const list=own(o,'ignore'),ignore:string[]=[];
749
+ 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);}
750
+ 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);
751
+ const body={pos:e.pos,grounded:e.grounded};const hit=scene.sweep(body,d);
752
+ e.pos=V(wrapPoint(body.pos,currentMap.wrap));e.grounded=body.grounded;return hit;
753
+ }
720
754
  const radius = e.kind.body?.radius ?? 0;
721
755
  // 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
756
  const list = own(o, 'ignore');
@@ -733,7 +767,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
733
767
  e.grounded = Boolean(support && support.nz > 0.5);
734
768
  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
769
  }
736
- const cast = castMap(c.map, e.pos.x, e.pos.y, d.x, d.y, radius);
770
+ const cast = castMap(currentMap, e.pos.x, e.pos.y, d.x, d.y, radius);
737
771
  let best: Hit | null = cast.hit;
738
772
  for (const other of ents.values()) {
739
773
  if (other === e || !other.kind.body || ignore.has(other.id)) continue;
@@ -778,14 +812,15 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
778
812
  const row = colliderRow(e.id,e.pos,e.kind.body,e.kind.collider,e.f,e.kind.name,e.kind.query);
779
813
  return row ? [row] : [];
780
814
  });
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];}
815
+ const metadata={...(currentMap.wrap?{wrap:currentMap.wrap}:{}),...((pendingMap??mapRevision)?{map:pendingMap??mapRevision,...(pendingMap&&mapRevision?{previousMap:mapRevision}:{})}:{})};
816
+ const text = JSON.stringify(Object.keys(metadata).length ? [rows,metadata] : rows);
817
+ 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
818
  return collisionPrevious;
784
819
  };
785
- let moveGeometry = c.map as import('./math.ts').MapShapes;
820
+ let moveGeometry = currentMap as import('./math.ts').MapShapes;
786
821
  const queries = collisionQueries(() => moveGeometry, () => moveShape, dims, () => cx.ent?.id);
787
822
  const moveWorld = brand(Object.freeze(queries));
788
- const moveMap = brand(Object.freeze({ name: c.map.name, spot: mapApi.spot, spots: mapApi.spots, ...queries }));
823
+ const moveMap = brand(Object.freeze({ get name(){return currentMap.name;}, spot: mapApi.spot, spots: mapApi.spots, ...tileApi(()=>currentMap), ...queries }));
789
824
  let moveShape = { shape: 'sphere', radius: 0, height: 0 };
790
825
  const moveCtx = brand({} as Record<string, unknown>);
791
826
  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 });
@@ -1037,6 +1072,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1037
1072
  /* ---------------------------------------------------------------- one tick */
1038
1073
 
1039
1074
  function inArea(a: any, p: Vec3): boolean {
1075
+ 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
1076
  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
1077
  const dx = p.x - a.at.x; const dy = p.y - a.at.y;
1042
1078
  const dz = dims === 3 ? p.z - a.at.z : 0;
@@ -1049,6 +1085,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1049
1085
  }
1050
1086
  function step(inputs: ReadonlyMap<number, StepInput> = new Map(), guides?: () => void): void {
1051
1087
  tick = (tick + 1) >>> 0;
1088
+ if(pendingMap&&pendingMap[0]<=tick){currentMap=pendingGeometry!;mapRevision=pendingMap;currentMapCells=pendingMapCells;mapCells=currentMapCells;pendingMapCells=0;pendingMap=null;pendingGeometry=null;moveGeometry=currentMap;}
1052
1089
  // 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
1090
  left = c.settings.budget.tick - SNAP * snapCells;
1054
1091
  cut = false; stopped = false; threw = null;
@@ -1107,7 +1144,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1107
1144
  }
1108
1145
  guides?.();
1109
1146
  // All movers see the same world, independent of entity iteration order.
1110
- if (hasColliders) { left -= 64 * ents.size; moveGeometry = collisionMap(c.map, collisionState()[2]); }
1147
+ if (hasColliders) { left -= 64 * ents.size; moveGeometry = collisionMap(currentMap, collisionState()[2]); }
1111
1148
  // Phase 1: one input step and `move` for every body.
1112
1149
  for (const e of [...ents.values()]) {
1113
1150
  if (e.dead || !e.kind.body) continue;
@@ -1140,7 +1177,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1140
1177
  // Owner movement: the browser says where its body is, and the server holds the claim to maxSpeed.
1141
1178
  e.allow = Math.min(e.allow + body.maxSpeed * dt * 1.02, body.maxSpeed * 0.25 + 0.05);
1142
1179
  if (claim && claim.r === e.r) {
1143
- const dx = claim.pos.x - e.pos.x; const dy = claim.pos.y - e.pos.y;
1180
+ 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
1181
  const dist = Math.sqrt(dx * dx + dy * dy);
1145
1182
  const go = Math.min(dist, e.allow);
1146
1183
  // The browser ran the game's own `move` and came further than `maxSpeed` allows: the body is held back.
@@ -1153,7 +1190,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1153
1190
  e.grounded = dims === 2;
1154
1191
  // The support cast is map work even when a browser supplies the pose.
1155
1192
  // 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');
1193
+ if (dims === 3) run(k.name, 'move', e, 'move', () => { e.grounded = restsOnMap(currentMap, e.pos, body); }, 'body');
1157
1194
  }
1158
1195
  continue;
1159
1196
  }
@@ -1245,7 +1282,8 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1245
1282
  function save(): SavedCore {
1246
1283
  return {
1247
1284
  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]}:{}),
1285
+ ...((hasColliders||mapRevision||pendingMap)?{collision:collisionState().slice(0,2) as [number,number]}:{}),
1286
+ ...((mapRevision||pendingMap)?{maps:{current:mapRevision,pending:pendingMap}}:{}),
1249
1287
  ...(historyTicks?{history:poseHistory.map(([at,rows])=>[at,rows.map(row=>[...row])] as SavedCore['history'])}:{}),
1250
1288
  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
1289
  ents: [...ents.values()].map(saveEnt), spawns: spawns.map(saveEnt),
@@ -1264,8 +1302,12 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1264
1302
  check(Array.isArray(value) && JSON.stringify(value) === JSON.stringify(packFields(list, unpackFields(list, value, dims), dims)));
1265
1303
  };
1266
1304
  check(r.v === SAVE_REVISION);
1305
+ if(r.maps!==undefined){
1306
+ 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'));
1307
+ 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);}
1308
+ }
1267
1309
  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)));
1310
+ 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'].includes(k)));
1269
1311
  const askNames = new Set();
1270
1312
  check(Array.isArray(r.asks));
1271
1313
  for (const a of r.asks) {
@@ -1311,7 +1353,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1311
1353
  for (const i of [7, 8, 9]) check(JSON.stringify(w[i]) === JSON.stringify(packVec(unpackVec(w[i], 3), 3)));
1312
1354
  const goal = w[15] as any;
1313
1355
  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;
1356
+ const pos = unpackVec(w[7] as number[], 3), bounds = currentMap.bounds;
1315
1357
  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
1358
  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
1359
  if (k.player) { check(!bodySeats.has(w[11])); bodySeats.add(w[11]); }
@@ -1398,6 +1440,12 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1398
1440
  }
1399
1441
  pendingAsks = r.asks.map(a => ({ ...a, state: deepFreeze(a.state) }));
1400
1442
  if ([opts.restoreEpoch].some((e) => e !== undefined && (!Number.isSafeInteger(e) || e <= 0))) throw new Error('restored epoch is invalid');
1443
+ if(r.maps){
1444
+ mapRevision=r.maps.current;pendingMap=r.maps.pending;
1445
+ 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;}
1446
+ 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;}
1447
+ moveGeometry=currentMap;
1448
+ }
1401
1449
  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
1450
  endAsked = r.intent?.[0] === 1; finishing = r.intent?.[1] === 1; restartAsked = r.intent?.[2] === 1;
1403
1451
  round = { n: r.round[0], phase: r.round[1] === 1 ? 'live' : 'over', endsAt: r.round[2], startedAt: r.round[3] };
@@ -1416,7 +1464,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1416
1464
  });
1417
1465
  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
1466
  ops = r.ops as typeof ops;
1419
- if(hasColliders)collisionState();
1467
+ if(hasColliders||mapRevision||pendingMap)collisionState();
1420
1468
  } else startMatch();
1421
1469
 
1422
1470
  function decisionResult(value: unknown): Record<string, unknown> {
@@ -1501,8 +1549,8 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1501
1549
  const list = [...ents.values()].map((e) => packEntity(e.kind, e, dims));
1502
1550
  // Include all three components of position, velocity and heading in a 3D snapshot.
1503
1551
  snapCells = packed.n + (dims === 3 ? 13 : 10) * list.length;
1504
- const collision = hasColliders ? collisionState() : null;
1505
- if (collision) snapCells += 3 + collision[2].length * 8;
1552
+ const collision = hasColliders||mapRevision||pendingMap ? collisionState() : currentMap.wrap ? [0,0,[],{wrap:currentMap.wrap}] : null;
1553
+ if (collision) snapCells += 3 + collision[2].length * 8 + mapCells;
1506
1554
  return [[round.n, round.phase === 'live' ? 1 : 0, round.endsAt], list, ...(collision ? [collision] : [])];
1507
1555
  },
1508
1556
  shared: () => packFields(c.shared, shared, dims),
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.
@@ -80,6 +81,8 @@ export interface HostOptions {
80
81
  clock: HostClock;
81
82
  /** Synchronous save: the caller's storage output gate holds this tick's outgoing frames until confirmed. */
82
83
  store?: { save(bytes: Uint8Array, commitment?: { force: boolean; seconds: number; tickHz: number; fields: { kinds: number[][]; shared: number[] } }): void };
84
+ /** Whether a snapshot is also handed over as text (the default). A room that sends the whole world to nobody answers false. */
85
+ snapshotText?: () => boolean;
83
86
  /** Table recovery reserves this epoch in storage before starting this host. Ordinary deterministic restores omit it. */
84
87
  restoreEpoch?: number;
85
88
  startDelayMs?: number;
@@ -673,7 +676,7 @@ export function createHost(o: HostOptions): Host {
673
676
  const watching = all.filter((_, n) => { const a = x.list[n][3]; return !a || a[0] >= 0; });
674
677
  for (const seat of present.keys()) {
675
678
  const l = bodies.get(seat);
676
- 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]); });
677
680
  if (mine.length) o.send({ t: 'ev', from: null, k: 'fx', d: [x.tick, mine], to: seat });
678
681
  }
679
682
  if (watching.length) o.send({ t: 'ev', from: null, k: 'fx', d: [x.tick, watching], to: -1 });
@@ -764,7 +767,8 @@ export function createHost(o: HostOptions): Host {
764
767
  // apart to whoever interpolates between them. The stamp always rises: a replica drops one that does not.
765
768
  lastSt = Math.max(lastSt + 1, Math.round(Math.min(now, dueOf(core.tick))));
766
769
  const snap = { t: 'snap', from: null, e: epoch, k: core.tick, st: lastSt, d: data, c: rows };
767
- const encoded = JSON.stringify(snap);
770
+ // The whole world as text, for whoever is sent it whole. A room that sends it to nobody says so and is spared it.
771
+ const encoded = o.snapshotText?.() === false ? undefined : JSON.stringify(snap);
768
772
  timing.record('encode', encodeAt);
769
773
  const sendAt = timing.now();
770
774
  o.send(snap, encoded);
@@ -1,9 +1,22 @@
1
+ import { periodicDelta, wrapValue } from './topology.mjs';
1
2
  /** Per-connection state delivery. Pure data, shared by the relay and the view.
2
3
  * Deltas may name a periodic keyframe or the previous ordered frame. A missing
3
4
  * chained predecessor is ignored until the next keyframe. Periodic encoders
4
5
  * repair the chain; ordered transports reset it on connection or epoch change.
5
6
  * No history grows with time: one keyframe per connection, no per-frame queue.
6
7
  */
8
+ const wrapOf = snap => snap.d?.[2]?.[3]?.wrap;
9
+ const axisNames=['x','y','z'];
10
+ const distanceSq=(p,q,wrap)=>{if(!wrap){const dx=p[0]-q[0],dy=p[1]-q[1],dz=(p[2]??0)-(q[2]??0);return dx*dx+dy*dy+dz*dz;}let d=0;for(let i=0;i<3;i++)d+=periodicDelta(q[i]??0,p[i]??0,wrap?.[axisNames[i]])**2;return d;};
11
+ function periodicCells(cells,p,size,wrap) {
12
+ const shifts=axisNames.map(k=>wrap?.[k]?[0,wrap[k][1]-wrap[k][0],wrap[k][0]-wrap[k][1]]:[0]);
13
+ const found=new Set();
14
+ for(const sx of shifts[0])for(const sy of shifts[1])for(const sz of shifts[2]){
15
+ const x=Math.floor((p[0]+sx)/size),y=Math.floor((p[1]+sy)/size),z=Math.floor(((p[2]??0)+sz)/size);
16
+ for(let dx=-1;dx<=1;dx++)for(let dy=-1;dy<=1;dy++)for(let dz=-1;dz<=1;dz++)for(const e of cells.get(`${x+dx},${y+dy},${z+dz}`)??[])found.add(e);
17
+ }
18
+ return [...found];
19
+ }
7
20
  const spatial = new WeakMap(), controls = new WeakMap();
8
21
  function indexFor(snap, radius) {
9
22
  let byRadius = spatial.get(snap.d[1]);
@@ -29,10 +42,11 @@ export function interestSnapshot(snap, seat, radiusM) {
29
42
  else if (own) {
30
43
  const q=own[3], x=Math.floor(q[0]/index.size), y=Math.floor(q[1]/index.size), z=Math.floor((q[2]??0)/index.size);
31
44
  const cell = `${x},${y},${z}`;
32
- let candidates = index.candidates.get(cell);
45
+ let candidates = wrapOf(snap) ? null : index.candidates.get(cell);
33
46
  if (!candidates) {
34
47
  candidates = [];
35
- for(let dx=-1;dx<=1;dx++)for(let dy=-1;dy<=1;dy++)for(let dz=-1;dz<=1;dz++)
48
+ if(wrapOf(snap)) candidates=periodicCells(index.cells,q,index.size,wrapOf(snap));
49
+ else for(let dx=-1;dx<=1;dx++)for(let dy=-1;dy<=1;dy++)for(let dz=-1;dz<=1;dz++)
36
50
  candidates.push(...(index.cells.get(`${x+dx},${y+dy},${z+dz}`)??[]));
37
51
  // Every player in this cell shares the ordered candidates. The final
38
52
  // radius check remains exact for that player's position.
@@ -41,7 +55,7 @@ export function interestSnapshot(snap, seat, radiusM) {
41
55
  }
42
56
  for (const e of candidates) {
43
57
  const p=e[3];
44
- if(e===own||(p[0]-q[0])**2+(p[1]-q[1])**2+((p[2]??0)-(q[2]??0))**2<=radiusM**2)visible.push(e);
58
+ if(e===own||distanceSq(p,q,wrapOf(snap))<=radiusM**2)visible.push(e);
45
59
  }
46
60
  }
47
61
  }
@@ -97,9 +111,9 @@ export function snapshotText(snap) {
97
111
 
98
112
  /** Whether a body that left `sent` at tick `sentTick` and kept its velocity is within `tolerance` of `entity` now. */
99
113
  const POSITION = 1 << 3, VELOCITY = 1 << 4, HEADING = 1 << 5;
100
- function coasts(entity, sent, ticks, hz, tolerance) {
114
+ function coasts(entity, sent, ticks, hz, tolerance, wrap) {
101
115
  const p = entity[3], q = sent[3], v = sent[4], t = ticks / hz;
102
- for (let i = 0; i < p.length; i++) if (Math.abs(p[i] - (q[i] + v[i] * t)) > tolerance) return false;
116
+ for (let i = 0; i < p.length; i++) if (Math.abs(periodicDelta(q[i] + v[i] * t,p[i],wrap?.[axisNames[i]])) > tolerance) return false;
103
117
  // A heading is a unit vector: the same tolerance, as a turn of that many metres at one metre's reach.
104
118
  const h = entity[5], g = sent[5];
105
119
  if (Array.isArray(h) && Array.isArray(g)) for (let i = 0; i < h.length; i++) if (Math.abs(h[i] - g[i]) > COAST_HEADING) return false;
@@ -136,7 +150,7 @@ export function snapshotEncoder(keyframeTicks, chained = false, coast = null) {
136
150
  if (!before) { changed.push(entity); continue; }
137
151
  const change = changedRow(entity, before);
138
152
  // Only where it is, how fast and which way it faces: anything else a body changed is always sent.
139
- if (change && coasting && !(change[1] & ~(POSITION | VELOCITY | HEADING)) && coasts(entity, before, snap.k - held.k, coasting.hz, coasting.toleranceM)) continue;
153
+ if (change && coasting && !(change[1] & ~(POSITION | VELOCITY | HEADING)) && coasts(entity, before, snap.k - held.k, coasting.hz, coasting.toleranceM, wrapOf(snap))) continue;
140
154
  if (held && chained) { held.entity = entity; held.k = snap.k; }
141
155
  if (change) changed.push(change);
142
156
  }
@@ -154,13 +168,13 @@ export function snapshotDecoder() {
154
168
  // The tick each row was last SENT at: a frame that says `coast` leaves a body out while it keeps its course,
155
169
  // and this end moves it along its last velocity. `rows` keeps what was sent; what is returned is where it is now.
156
170
  let sentAt = new Map();
157
- const coasted = (list, snap) => {
171
+ const coasted = (list, snap, collision) => {
158
172
  const hz = snap.coast;
159
173
  if (!(hz > 0)) return list;
160
174
  return list.map(row => {
161
175
  const ticks = snap.k - (sentAt.get(row[0]) ?? snap.k), v = row[4];
162
176
  if (ticks <= 0 || !Array.isArray(v) || !v.some(n => n !== 0)) return row;
163
- const moved = row.slice(); moved[3] = row[3].map((n, i) => n + v[i] * ticks / hz);
177
+ const moved = row.slice(); moved[3] = row[3].map((n, i) => wrapValue(n + v[i] * ticks / hz, collision?.[3]?.wrap?.[axisNames[i]]));
164
178
  return moved;
165
179
  });
166
180
  };
@@ -212,7 +226,7 @@ export function snapshotDecoder() {
212
226
  for (const id of d.removed) { next.delete(id); if (d.chain) sentAt.delete(id); }
213
227
  for (const entity of updates) { next.set(entity[0], entity); if (d.chain) sentAt.set(entity[0], snap.k); }
214
228
  for (const e of d.own ?? []) { next.set(e[0], e); if (d.chain) sentAt.set(e[0], snap.k); }
215
- const decoded = { ...snap, d: [d.round, coasted([...next.values()], snap), ...(d.collision === undefined ? base.d.slice(2) : [d.collision])] };
229
+ const decoded = { ...snap, d: [d.round, coasted([...next.values()], snap, d.collision ?? base.d[2]), ...(d.collision === undefined ? base.d.slice(2) : [d.collision])] };
216
230
  if (d.chain) { base = decoded; rows = next; }
217
231
  return decoded;
218
232
  },
@@ -238,7 +252,7 @@ export function scheduledView({ radiusM = null, precisionM = 0, nearM = null, fa
238
252
  let row = entity, sampledTick = snap.k;
239
253
  if (entity !== own) {
240
254
  const p = entity[3], q = own?.[3];
241
- const distanceSquared = q ? (p[0]-q[0])**2 + (p[1]-q[1])**2 + ((p[2]??0)-(q[2]??0))**2 : 0;
255
+ const distanceSquared = q ? distanceSq(p,q,wrapOf(snap)) : 0;
242
256
  const old = held?.row;
243
257
  const interval = farHz && nearM !== null && distanceSquared > nearM**2 ? Math.max(1, Math.round(tickHz / farHz)) : 1;
244
258
  // Spread far-view refreshes across players instead of sending the
@@ -355,10 +369,11 @@ export function crowdWorlds({ radiusM = null, precisionM = 0 } = {}) {
355
369
  near(p) {
356
370
  if (radiusM === null) return this.everything();
357
371
  const x = Math.floor(p[0] / size), y = Math.floor(p[1] / size), z = Math.floor((p[2] ?? 0) / size), key = `${x},${y},${z}`;
358
- let list = around.get(key);
372
+ let list = wrapOf(snap) ? null : around.get(key);
359
373
  if (!list) {
360
374
  list = [];
361
- for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) for (let dz = -1; dz <= 1; dz++) { const cell = cells.get(`${x + dx},${y + dy},${z + dz}`); if (cell) for (const i of cell) list.push(i); }
375
+ if(wrapOf(snap)) list=periodicCells(cells,p,size,wrapOf(snap));
376
+ else for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) for (let dz = -1; dz <= 1; dz++) { const cell = cells.get(`${x + dx},${y + dy},${z + dz}`); if (cell) for (const i of cell) list.push(i); }
362
377
  list.sort((a, b) => a - b); around.set(key, list);
363
378
  }
364
379
  return list;
@@ -390,7 +405,7 @@ export function crowdView(worlds, { nearM = null, farHz = null } = {}, tickHz =
390
405
  let far = false;
391
406
  if (e !== mine && q) {
392
407
  const p = e[3]; let d = 0;
393
- for (let a = 0; a < p.length; a++) d += (p[a] - q[a]) ** 2;
408
+ for (let a = 0; a < p.length; a++) d += periodicDelta(q[a],p[a],wrapOf(snap)?.[axisNames[a]]) ** 2;
394
409
  if (d > reach) continue;
395
410
  far = d > nearSq;
396
411
  }
@@ -407,7 +422,7 @@ export function crowdView(worlds, { nearM = null, farHz = null } = {}, tickHz =
407
422
  let tolerance = precisionM;
408
423
  if (far) { let speed = 0; for (let a = 0; a < v.length; a++) speed += v[a] * v[a]; tolerance = Math.max(precisionM, Math.sqrt(speed) * farShare); }
409
424
  let there = true;
410
- for (let a = 0; a < p.length; a++) if (Math.abs(p[a] - (b[a] + v[a] * t)) > tolerance) { there = false; break; }
425
+ for (let a = 0; a < p.length; a++) if (Math.abs(periodicDelta(b[a]+v[a]*t,p[a],wrapOf(snap)?.[axisNames[a]])) > tolerance) { there = false; break; }
411
426
  if (there) {
412
427
  const h = row[5], g = before[5];
413
428
  if (Array.isArray(h) && Array.isArray(g)) for (let a = 0; a < h.length; a++) if (Math.abs(h[a] - g[a]) > COAST_HEADING) { there = false; break; }
package/rules/live.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import {wrapPoint} from './topology.mjs';
1
2
  import { rayQuery, raySnapshot, type QueryTarget } from './query.ts';
2
3
  import { overlapsTerrain } from './terrain.ts';
3
4
  import { nearbyMap } from './map-index.ts';
@@ -10,7 +11,7 @@ import { castMap3, solidAt, type BodyShape } from './collision.ts';
10
11
  import { sweepMap, rayBox, rayCircle } from './math.ts';
11
12
 
12
13
  export type ColliderRow = [string, string, number, number, number, number, number, number, {kind: string; query?: QueryDef; fields: Record<string, number | string | boolean>}?];
13
- export type CollisionRevision = [number, number, ColliderRow[]];
14
+ export type CollisionRevision = [number, number, ColliderRow[], {wrap?: import('./rules.ts').MapWrap; map?: import('./map-state.ts').MapRevision | null; previousMap?: import('./map-state.ts').MapRevision | null}?];
14
15
  export function colliderRow(id: string, at: Vec3, body: BodyShape, def: ColliderDef, fields: Record<string, unknown>, kind?: string, query?: QueryDef): ColliderRow | null {
15
16
  const opts = def === true ? {} : def;
16
17
  if (opts.enabled && fields[opts.enabled] !== true) return null;
@@ -44,7 +45,7 @@ export function collisionMap(map: MapShapes, rows: readonly ColliderRow[]): MapS
44
45
  else if (shape === 'sphere') spheres.push({ at: { x, y, z: z + w / 2 }, r: w / 2, id } as any);
45
46
  else capsules.push({ at, r: w / 2, height: h, id } as any);
46
47
  }
47
- const combined = { bounds: map.bounds, staticMap: map, boxes, circles, spheres, capsules };
48
+ const combined = { ...(map.wrap?{wrap:map.wrap}:{}), bounds: map.bounds, staticMap: map, boxes, circles, spheres, capsules };
48
49
  rowsOf.set(combined, rows);
49
50
  return combined;
50
51
  }
@@ -71,7 +72,7 @@ export function collisionQueries(map: () => MapShapes, shape: () => BodyShape, d
71
72
  const d = typeof distance === 'number' && Number.isFinite(distance) ? Math.max(0, Math.min(10000, distance)) : 0;
72
73
  if (dims === 2) return Object.freeze({ at: Object.freeze(p), normal: Object.freeze({ x: 0, y: 0, z: 1 }), dist: 0 });
73
74
  const h = castMap3(m, p, { x: 0, y: 0, z: -d }, shape());
74
- 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;
75
+ return h && h.nz > 0.5 ? Object.freeze({ at: Object.freeze(wrapPoint({ ...p, z: p.z - d * h.t },m.wrap)), normal: Object.freeze({ x: h.nx, y: h.ny, z: h.nz }), dist: d * h.t, ...(h.id ? { entity: h.id } : {}) }) : undefined;
75
76
  },
76
77
  overlaps: (body: unknown) => {
77
78
  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);