@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.
- package/CHANGELOG.md +68 -0
- package/README.md +12 -1
- package/guides/game/REWRITE.md +2 -1
- package/guides/game/RULES.md +67 -0
- package/guides/game/SKILL.md +6 -0
- package/lib/crowd-cloud.mjs +75 -17
- package/lib/crowd-diagnostics.mjs +1 -0
- package/lib/crowd-load.mjs +15 -3
- package/lib/crowd-probe/README.md +58 -0
- package/lib/crowd-probe/client.mjs +3 -0
- package/lib/crowd-probe/reference-work.mjs +45 -0
- package/lib/crowd-probe/run-trial.sh +207 -0
- package/lib/crowd-probe/worker.mjs +37 -10
- package/lib/crowd-summary.mjs +74 -7
- package/lib/crowd-worker.mjs +12 -4
- package/lib/rules-check-worker.mjs +2 -1
- package/lib/rules-guard.mjs +2 -2
- package/lib/template-history.json +84 -0
- package/package.json +1 -1
- package/rules/collision.ts +1 -0
- package/rules/core.ts +81 -33
- package/rules/host.ts +6 -2
- package/rules/interest.mjs +29 -14
- package/rules/live.ts +4 -3
- package/rules/map-index.ts +24 -0
- package/rules/map-state.ts +54 -0
- package/rules/math.ts +8 -5
- package/rules/nav/Flow.ts +53 -0
- package/rules/query.ts +19 -4
- package/rules/rules.ts +22 -4
- package/rules/tiles.ts +58 -0
- package/rules/topology.mjs +22 -0
- package/rules/types.ts +2 -2
- package/rules/view.ts +45 -19
- package/worker/build-id.mjs +1 -1
- package/worker/gate.mjs +72 -25
- package/worker/index.mjs +26 -8
- package/worker/office.mjs +2 -2
- package/worker/room-store.mjs +71 -11
- package/worker/room.mjs +16 -3
- package/worker/version.mjs +1 -1
|
@@ -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.
|
|
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",
|
package/rules/collision.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
|
474
|
-
|
|
475
|
-
|
|
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]
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
666
|
-
const dz = dims === 3 ? e.pos.z
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
715
|
-
rayAll: (from: unknown, direction: unknown, max: unknown, options?: unknown): unknown => rayQuery(
|
|
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(
|
|
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
|
|
782
|
-
|
|
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 =
|
|
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
|
|
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(
|
|
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 =
|
|
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(
|
|
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 =
|
|
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]
|
|
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
|
-
|
|
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);
|
package/rules/interest.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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]
|
|
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(
|
|
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);
|