@homie-rocks/studio 0.45.2 → 0.46.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 +87 -0
- package/guides/game/RULES.md +276 -3
- package/guides/game/SKILL.md +46 -2
- package/lib/build.mjs +1 -1
- package/lib/rules-check-worker.mjs +6 -5
- package/lib/rules-check.mjs +2 -2
- package/lib/rules-guard.mjs +4 -3
- package/lib/rules-types.mjs +2 -2
- package/lib/scaffold.mjs +9 -3
- package/lib/template-history.json +84 -0
- package/netplay/NETPLAY.md +3 -1
- package/netplay/netplay.ts +1 -0
- package/package.json +1 -1
- package/rules/collision.ts +51 -11
- package/rules/core.ts +116 -26
- package/rules/guard.ts +1 -1
- package/rules/host.ts +139 -19
- package/rules/interest.mjs +3 -3
- package/rules/live.ts +19 -7
- package/rules/map-index.ts +7 -1
- package/rules/math.ts +20 -8
- package/rules/navigation.ts +69 -0
- package/rules/pack.ts +2 -2
- package/rules/query.ts +103 -0
- package/rules/rules.ts +108 -29
- package/rules/terrain-legacy.ts +93 -0
- package/rules/terrain.ts +96 -11
- package/rules/types.ts +17 -7
- package/rules/view.ts +38 -19
- package/worker/characters.mjs +41 -0
- package/worker/functions-schema.mjs +16 -0
- package/worker/index.mjs +181 -17
- package/worker/players.mjs +3 -1
- package/worker/room-store.mjs +113 -11
- package/worker/room.mjs +12 -3
- package/worker/saves-shell.mjs +5 -1
- package/worker/saves.mjs +19 -1
- package/worker/version.mjs +1 -1
- package/worker/world-journal.mjs +109 -0
|
@@ -2649,6 +2649,90 @@
|
|
|
2649
2649
|
}
|
|
2650
2650
|
}
|
|
2651
2651
|
}
|
|
2652
|
+
},
|
|
2653
|
+
"0.45.3": {
|
|
2654
|
+
"files": {
|
|
2655
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
2656
|
+
"README.md": "684b3cca8001673c",
|
|
2657
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
2658
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
2659
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
2660
|
+
"videos/README.md": "16babc3437d5545b",
|
|
2661
|
+
"posts/README.md": "37841aae476bd540",
|
|
2662
|
+
"site/README.md": "732894273a32dc3f",
|
|
2663
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
2664
|
+
},
|
|
2665
|
+
"sections": {
|
|
2666
|
+
"AGENTS.md": {
|
|
2667
|
+
"(intro)": "214aa46eb3a895d5",
|
|
2668
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
2669
|
+
"## Layout": "f3a280b8c96cd068",
|
|
2670
|
+
"## Apps: one screen, roles and parts": "8596f2b25edda3e2",
|
|
2671
|
+
"## Commands (all through the pinned CLI in node_modules)": "159151b8861fc051",
|
|
2672
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
2673
|
+
"## Making games": "bb715272fd1a615b",
|
|
2674
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
2675
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
2676
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
2677
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
2678
|
+
"## Selling things (the shop)": "9e020d69dd6a954e",
|
|
2679
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
2680
|
+
"## Rules": "c48971c1437414c2",
|
|
2681
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
2682
|
+
"## Beta": "9d02638207a26e73"
|
|
2683
|
+
}
|
|
2684
|
+
},
|
|
2685
|
+
"named": {
|
|
2686
|
+
"Homie Arcade": {
|
|
2687
|
+
"sections": {
|
|
2688
|
+
"AGENTS.md": {
|
|
2689
|
+
"## Commands (all through the pinned CLI in node_modules)": "2cdd530e48170bfd"
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2694
|
+
},
|
|
2695
|
+
"0.46.0": {
|
|
2696
|
+
"files": {
|
|
2697
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
2698
|
+
"README.md": "684b3cca8001673c",
|
|
2699
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
2700
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
2701
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
2702
|
+
"videos/README.md": "16babc3437d5545b",
|
|
2703
|
+
"posts/README.md": "37841aae476bd540",
|
|
2704
|
+
"site/README.md": "732894273a32dc3f",
|
|
2705
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
2706
|
+
},
|
|
2707
|
+
"sections": {
|
|
2708
|
+
"AGENTS.md": {
|
|
2709
|
+
"(intro)": "214aa46eb3a895d5",
|
|
2710
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
2711
|
+
"## Layout": "f3a280b8c96cd068",
|
|
2712
|
+
"## Apps: one screen, roles and parts": "8596f2b25edda3e2",
|
|
2713
|
+
"## Commands (all through the pinned CLI in node_modules)": "159151b8861fc051",
|
|
2714
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
2715
|
+
"## Making games": "bb715272fd1a615b",
|
|
2716
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
2717
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
2718
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
2719
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
2720
|
+
"## Selling things (the shop)": "9e020d69dd6a954e",
|
|
2721
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
2722
|
+
"## Rules": "c48971c1437414c2",
|
|
2723
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
2724
|
+
"## Beta": "9d02638207a26e73"
|
|
2725
|
+
}
|
|
2726
|
+
},
|
|
2727
|
+
"named": {
|
|
2728
|
+
"Homie Arcade": {
|
|
2729
|
+
"sections": {
|
|
2730
|
+
"AGENTS.md": {
|
|
2731
|
+
"## Commands (all through the pinned CLI in node_modules)": "2cdd530e48170bfd"
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2652
2736
|
}
|
|
2653
2737
|
}
|
|
2654
2738
|
}
|
package/netplay/NETPLAY.md
CHANGED
|
@@ -2106,7 +2106,9 @@ from outside the studio run on its server.
|
|
|
2106
2106
|
The Table writes the whole room to its SQLite `save` table in one synchronous transaction:
|
|
2107
2107
|
entities, motion, shared state, rounds, seats and their tokens, pending events, timers and
|
|
2108
2108
|
inputs, RNG state, tick, epoch, state hash and build hash. Saves larger than 2 MB use several
|
|
2109
|
-
rows in that transaction. The default interval is `room.durability.movementSeconds: 1
|
|
2109
|
+
rows in that transaction. The default interval is `room.durability.movementSeconds: 1`
|
|
2110
|
+
(30 for a `lifetime: "persistent"` world, which writes its `durable` fields to a journal the moment they change and
|
|
2111
|
+
saves the rest on this interval);
|
|
2110
2112
|
round end, `world.finish()` and pause save immediately. Both elapsed ticks and elapsed wall
|
|
2111
2113
|
time bound the interval, including ticks that catch up after a delay. Cloudflare's output
|
|
2112
2114
|
gate holds outgoing frames until preceding writes are confirmed.
|
package/netplay/netplay.ts
CHANGED
|
@@ -2395,6 +2395,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2395
2395
|
return;
|
|
2396
2396
|
}
|
|
2397
2397
|
case 'state': if (typeof m['k'] === 'string') applyState(m['k'], m['d']); return;
|
|
2398
|
+
case 'probe': if (typeof m.n === 'string') raw({t:'probeAck',n:m.n}); return;
|
|
2398
2399
|
case 'pong': return onPong(m);
|
|
2399
2400
|
case 'announce': {
|
|
2400
2401
|
if (typeof m['id'] !== 'string') return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.46.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
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { castTerrain } from './terrain.ts';
|
|
2
|
+
import { castTerrain as legacyTerrain } from './terrain-legacy.ts';
|
|
2
3
|
import { nearbyMap } from './map-index.ts';
|
|
3
4
|
/** Upright 3D collision. Positions are feet; a rounded box is a box plus a sphere.
|
|
4
5
|
* Capsules are a vertical segment plus a sphere. No engine-specific maths or time.
|
|
5
6
|
*/
|
|
6
|
-
import { charge } from './guard.ts';
|
|
7
|
+
import { charge, G } from './guard.ts';
|
|
7
8
|
import type { Vec3, MapBox, MapCircle, MapHeightTile } from './rules.ts';
|
|
8
9
|
|
|
9
|
-
export interface BodyShape { shape: string; radius: number; height: number }
|
|
10
|
+
export interface BodyShape { shape: string; radius: number; height: number; terrain?: 'exact' }
|
|
10
11
|
export interface Solid { min: Vec3; max: Vec3; r: number }
|
|
11
12
|
export interface Hit3 { t: number; nx: number; ny: number; nz: number; id?: string }
|
|
12
13
|
export interface Map3 {
|
|
@@ -14,7 +15,7 @@ export interface Map3 {
|
|
|
14
15
|
bounds: MapBox; boxes: readonly MapBox[]; circles: readonly MapCircle[];
|
|
15
16
|
heightTiles?: readonly MapHeightTile[];
|
|
16
17
|
spheres?: readonly MapCircle[];
|
|
17
|
-
capsules?: readonly (MapCircle & { height: number })[];
|
|
18
|
+
capsules?: readonly (MapCircle & { height: number; terrain?: 'exact' })[];
|
|
18
19
|
}
|
|
19
20
|
const EPS = 1e-7;
|
|
20
21
|
const point = (x: number, y: number, z: number): {x: number; y: number; z: number} => ({ x, y, z });
|
|
@@ -33,8 +34,15 @@ function separation(a: Solid, b: Solid, d: Vec3, t: number): Vec3 {
|
|
|
33
34
|
/** Continuous convex cast: each separating plane gives a lower bound on impact.
|
|
34
35
|
* Every iteration is charged, and a fixed cap bounds grazing contacts as well.
|
|
35
36
|
*/
|
|
36
|
-
export function castSolid(a: Solid, d: Vec3, b: Solid): Hit3 | null {
|
|
37
|
+
export function castSolid(a: Solid, d: Vec3, b: Solid, includeInside = false): Hit3 | null {
|
|
37
38
|
charge(30);
|
|
39
|
+
if (includeInside) {
|
|
40
|
+
const gap = separation(a, b, d, 0);
|
|
41
|
+
const sq = gap.x*gap.x + gap.y*gap.y + gap.z*gap.z;
|
|
42
|
+
const radius=a.r+b.r;
|
|
43
|
+
const inside = radius > 0 ? sq < radius*radius : a.max.x > b.min.x && a.min.x < b.max.x && a.max.y > b.min.y && a.min.y < b.max.y && a.max.z > b.min.z && a.min.z < b.max.z;
|
|
44
|
+
if (inside) {const length = Math.sqrt(d.x*d.x+d.y*d.y+d.z*d.z)||1; return {t:0,nx:-d.x/length,ny:-d.y/length,nz:-d.z/length};}
|
|
45
|
+
}
|
|
38
46
|
let t = 0;
|
|
39
47
|
const r = a.r + b.r;
|
|
40
48
|
let normal = point(0, 0, 0);
|
|
@@ -94,12 +102,27 @@ function castHeightTile(tile: MapHeightTile, p: Vec3, d: Vec3): Hit3 | null {
|
|
|
94
102
|
return best;
|
|
95
103
|
}
|
|
96
104
|
|
|
97
|
-
|
|
105
|
+
let memoEpoch = -1;
|
|
106
|
+
let castMemo = new WeakMap<object, Map<string, Hit3 | null>>();
|
|
107
|
+
export function castMap3(map: Map3, p: Vec3, d: Vec3, body: BodyShape, hits?: Hit3[], includeInside = false): Hit3 | null {
|
|
108
|
+
// Exact casts can reuse identical immutable-scene queries within one handler.
|
|
109
|
+
// The epoch resets at every authority handler, including after restore.
|
|
110
|
+
if(body.terrain !== 'exact' || hits || !Number.isFinite(G.left)) return castMap3Uncached(map,p,d,body,hits,includeInside);
|
|
111
|
+
if(memoEpoch!==G.queryEpoch){memoEpoch=G.queryEpoch;castMemo=new WeakMap();}
|
|
112
|
+
charge(16);
|
|
113
|
+
let rows=castMemo.get(map);if(!rows){rows=new Map();castMemo.set(map,rows);}
|
|
114
|
+
const key=[p.x,p.y,p.z,d.x,d.y,d.z,body.shape,body.radius,body.height,includeInside].join(',');
|
|
115
|
+
if(rows.has(key))return rows.get(key)!;
|
|
116
|
+
const hit=castMap3Uncached(map,p,d,body,hits,includeInside);
|
|
117
|
+
if(rows.size<512)rows.set(key,hit);
|
|
118
|
+
return hit;
|
|
119
|
+
}
|
|
120
|
+
function castMap3Uncached(map: Map3, p: Vec3, d: Vec3, body: BodyShape, hits?: Hit3[], includeInside = false): Hit3 | null {
|
|
98
121
|
map = nearbyMap(map, p, d, body.radius, body.height || body.radius * 2);
|
|
99
122
|
charge(24);
|
|
100
123
|
const a = solidAt(p, body), r = body.radius, height = body.height || r * 2;
|
|
101
124
|
let best: Hit3 | null = null;
|
|
102
|
-
const take = (h: Hit3 | null, id?: string): void => { if (h && h.t >= 0 && h.t <= 1
|
|
125
|
+
const take = (h: Hit3 | null, id?: string): void => { if (h && h.t >= 0 && h.t <= 1) { const hit = id ? {...h, id} : h; if (hits) {charge(8); hits.push(hit);} if (!best || h.t < best.t) best = hit; } };
|
|
103
126
|
for (const axis of ['x', 'y', 'z'] as const) {
|
|
104
127
|
const low = map.bounds.min[axis] + (axis === 'z' ? 0 : r);
|
|
105
128
|
const high = map.bounds.max[axis] - (axis === 'z' ? height : r);
|
|
@@ -107,12 +130,29 @@ export function castMap3(map: Map3, p: Vec3, d: Vec3, body: BodyShape): Hit3 | n
|
|
|
107
130
|
const n = point(0, 0, 0); n[axis] = d[axis] > 0 ? -1 : 1;
|
|
108
131
|
take({ t: ((d[axis] > 0 ? high : low) - p[axis]) / d[axis], nx: n.x, ny: n.y, nz: n.z });
|
|
109
132
|
}
|
|
110
|
-
|
|
111
|
-
|
|
133
|
+
const exactTerrain = body.terrain === 'exact';
|
|
134
|
+
const terrain = [...(map.heightTiles ?? [])];
|
|
135
|
+
if(exactTerrain && d.z<0){charge(terrain.length*Math.ceil(Math.log2(terrain.length+1)));terrain.sort((a,b)=>(b.at.z+Math.max(...b.heights))-(a.at.z+Math.max(...a.heights)));}
|
|
136
|
+
// Visit the nearest possible tile contacts first so later casts are shortened.
|
|
137
|
+
// All-hit queries retain authored ordering; legacy casts retain cost and behavior.
|
|
138
|
+
if(exactTerrain && (d.x||d.y) && !hits){
|
|
139
|
+
const first=(t:MapHeightTile)=>{let near=0;for(const k of ['x','y'] as const){if(d[k])near=Math.max(near,((d[k]>0?t.at[k]-r:t.at[k]+t.size[k]+r)-p[k])/d[k]);}return near;};
|
|
140
|
+
charge(terrain.length*(16+Math.ceil(Math.log2(terrain.length+1))));const order=new Map(terrain.map(t=>[t,first(t)]));terrain.sort((a,b)=>order.get(a)!-order.get(b)!);
|
|
141
|
+
}
|
|
142
|
+
for (const tile of terrain) {
|
|
143
|
+
if(exactTerrain && !hits && best?.t===0)break;
|
|
144
|
+
// Once a nearer surface is known, later solids only need to beat that time.
|
|
145
|
+
// This also bounds work on densely tessellated terrain behind the first hit.
|
|
146
|
+
const limit = exactTerrain && !hits && best ? best.t : 1;
|
|
147
|
+
const delta = limit < 1 ? point(d.x * limit, d.y * limit, d.z * limit) : d;
|
|
148
|
+
const hit = tile.base === undefined ? castHeightTile(tile, p, delta) : (exactTerrain ? castTerrain : legacyTerrain)(tile, a, delta);
|
|
149
|
+
if (hit) take({...hit, t: hit.t * limit});
|
|
150
|
+
}
|
|
151
|
+
for (const b of map.boxes) take(castSolid(a, d, { ...b, r: 0 }, includeInside), (b as any).id);
|
|
112
152
|
// Legacy map circles are vertical columns in a 3D map.
|
|
113
|
-
for (const c of map.circles) take(castSolid(a, d, { min: point(c.at.x, c.at.y, map.bounds.min.z - r), max: point(c.at.x, c.at.y, map.bounds.max.z + r), r: c.r }), (c as any).id);
|
|
114
|
-
for (const s of map.spheres ?? []) take(castSolid(a, d, { min: s.at, max: s.at, r: s.r }), (s as any).id);
|
|
115
|
-
for (const c of map.capsules ?? []) take(castSolid(a, d, solidAt(c.at, { shape: 'capsule', radius: c.r, height: c.height })), (c as any).id);
|
|
153
|
+
for (const c of map.circles) take(castSolid(a, d, { min: point(c.at.x, c.at.y, map.bounds.min.z - r), max: point(c.at.x, c.at.y, map.bounds.max.z + r), r: c.r }, includeInside), (c as any).id);
|
|
154
|
+
for (const s of map.spheres ?? []) take(castSolid(a, d, { min: s.at, max: s.at, r: s.r }, includeInside), (s as any).id);
|
|
155
|
+
for (const c of map.capsules ?? []) take(castSolid(a, d, solidAt(c.at, { shape: 'capsule', radius: c.r, height: c.height }), includeInside), (c as any).id);
|
|
116
156
|
return best;
|
|
117
157
|
}
|
|
118
158
|
export function restsOnMap(map: Map3, p: Vec3, body: BodyShape): boolean {
|
package/rules/core.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {routeGraph} from './navigation.ts';
|
|
2
|
+
import { rayQuery, raySnapshot, type QueryTarget } from './query.ts';
|
|
1
3
|
import { colliderSolid, castCollider2, colliderRow, collisionMap, collisionQueries, type CollisionRevision } from './live.ts';
|
|
2
4
|
/*
|
|
3
5
|
* core.ts — one room's world, stepped a tick at a time from a compiled rules module.
|
|
@@ -77,7 +79,7 @@ export const IGNORE_MAX = 16;
|
|
|
77
79
|
export const ANSWER_MAX = 256;
|
|
78
80
|
|
|
79
81
|
export type Driver = 'person' | 'bot' | 'ai';
|
|
80
|
-
export interface SeatInfo { seat: number; driver: Driver; owner: string }
|
|
82
|
+
export interface SeatInfo { seat: number; driver: Driver; owner: string; character?: import('./rules.ts').Character | null }
|
|
81
83
|
/** One seat's step for a tick: its input values (already held to their types) and, for an owner-moved body, its claim. */
|
|
82
84
|
export interface StepInput { values: Record<string, unknown>; claim?: { pos: Vec3; vel: Vec3; heading: Vec3; r: number } | null }
|
|
83
85
|
export interface ResultRow { seat: number; id: string; driver: Driver; score: number; place: number }
|
|
@@ -133,7 +135,9 @@ function errorText(error: unknown): string {
|
|
|
133
135
|
export interface SavedCore {
|
|
134
136
|
v: number; tick: number; epoch: number; rng: number; nextId: number; seq: number;
|
|
135
137
|
round: [number, number, number, number]; overAt: number; match: [number, number]; trips: number;
|
|
136
|
-
shared: unknown[]; policy: CorePolicy; intent?: [number, number];
|
|
138
|
+
shared: unknown[]; policy: CorePolicy; intent?: [number, number, number?];
|
|
139
|
+
collision?: [number,number];
|
|
140
|
+
history?: [number, [string, number, number, number, number][]][];
|
|
137
141
|
asks: PendingAsk[];
|
|
138
142
|
guideViews: [number, Record<string, unknown>][];
|
|
139
143
|
ents: unknown[][]; spawns: unknown[][]; seats: [number, string, string, string | null, number][]; queue: unknown[][]; areas: unknown[][]; ops: unknown[];
|
|
@@ -159,6 +163,7 @@ export interface Core {
|
|
|
159
163
|
round(): Extract<CoreOut, { t: 'round' }>;
|
|
160
164
|
bodies(): Body[];
|
|
161
165
|
/** The body in a seat, as the host runtime needs it for the input protocol. */
|
|
166
|
+
character(seat: number): import('./rules.ts').Character | null;
|
|
162
167
|
bodyOf(seat: number): { id: string; kind: KindTable; r: number; away: boolean; driver: Driver } | null;
|
|
163
168
|
/** What happened since the last call: rounds, effects, a changed roster, changed shared state, a new epoch, a failure. */
|
|
164
169
|
drain(): CoreOut[];
|
|
@@ -179,7 +184,7 @@ export interface Core {
|
|
|
179
184
|
* `noted`: told, as it happens, of a value the rules wrote that the runtime changed to make it fit, or dropped (pack.ts
|
|
180
185
|
* `Adjusted`; also `effect`, `think` and `decision`): the handler, what was done, the field's name and what was written.
|
|
181
186
|
*/
|
|
182
|
-
export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: number, input: Readonly<Record<string, unknown>>, before: import('./pack.ts').MoveBody, after: import('./pack.ts').MoveBody, geometry: import('./math.ts').MapShapes) => void; observe?: (kind: string, handler: string, error?: string) => void; noted?: (kind: string, handler: string, what: string, at: string, written: string) => void; seed?: number; epoch?: number; restore?: SavedCore | null; restoreEpoch?: number; stage?: string; decisions?: boolean } = {}): Core {
|
|
187
|
+
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 {
|
|
183
188
|
const dims = c.dims;
|
|
184
189
|
const tickHz = c.settings.tickHz;
|
|
185
190
|
const dt = 1 / tickHz;
|
|
@@ -210,6 +215,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
210
215
|
let restartAt = 0;
|
|
211
216
|
let finishing = false;
|
|
212
217
|
let endAsked = false;
|
|
218
|
+
let restartAsked = false;
|
|
213
219
|
let vocabulary: Vocabulary | null = null;
|
|
214
220
|
const restoredGoals = new Map<number, unknown>();
|
|
215
221
|
const guideViews = new Map<number, Record<string, unknown>>();
|
|
@@ -369,6 +375,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
369
375
|
* budget; and the counter and the scope are put back whatever happens. Nothing of the rules' is touched after this
|
|
370
376
|
* returns.
|
|
371
377
|
*/
|
|
378
|
+
let dispatchDepth = 0;
|
|
372
379
|
function run(kind: string, handler: string, ent: Ent | null, scope: Ctx['scope'], fn: () => void, always: boolean | 'body' = false): boolean {
|
|
373
380
|
if (left <= 0) {
|
|
374
381
|
cut = true;
|
|
@@ -387,7 +394,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
387
394
|
const E = Error as unknown as { stackTraceLimit?: number };
|
|
388
395
|
const limit = E.stackTraceLimit;
|
|
389
396
|
E.stackTraceLimit = 0;
|
|
390
|
-
G.left = quota;
|
|
397
|
+
G.queryEpoch++; G.left = quota;
|
|
391
398
|
// The build check is told of every written value that had to be changed to fit (pack.ts), with the handler it was in.
|
|
392
399
|
if (noted) G.note = (what, at, written) => noted(kind, handler, what, at, written);
|
|
393
400
|
let bad = false; let thrown: unknown;
|
|
@@ -463,7 +470,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
463
470
|
}));
|
|
464
471
|
let roundApi: Readonly<Record<string, unknown>> = Object.freeze({});
|
|
465
472
|
const refreshRound = (): void => {
|
|
466
|
-
roundApi = brand(Object.freeze({ n: round.n, phase: round.phase, endsAt: round.endsAt, end: (): void => { need('room', 'world.round.end()'); endAsked = true; } }));
|
|
473
|
+
roundApi = brand(Object.freeze({ n: round.n, phase: round.phase, endsAt: round.endsAt, end: (): void => { need('room', 'world.round.end()'); endAsked = true; }, restart: (): void => { need('room', 'world.round.restart()'); restartAsked = true; } }));
|
|
467
474
|
};
|
|
468
475
|
refreshRound();
|
|
469
476
|
/** `world.shared`: in room scope the writable face of the stored record, held to its types as it is written; everywhere else a frozen record of the same stored values. */
|
|
@@ -478,6 +485,32 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
478
485
|
return sharedRO;
|
|
479
486
|
};
|
|
480
487
|
|
|
488
|
+
const historyTicks = Math.ceil((c.historySeconds ?? 0) * tickHz);
|
|
489
|
+
let poseHistory: [number, [string, number, number, number, number][]][] = [];
|
|
490
|
+
function rayTargets(options?: unknown): QueryTarget[] {
|
|
491
|
+
charge(8 * ents.size);
|
|
492
|
+
const targets: QueryTarget[] = [];
|
|
493
|
+
const filterKind=own(options,'kind'),filterLayer=own(options,'layer'),filterTag=own(options,'tag'),geometryOnly=own(options,'geometryOnly')===true;
|
|
494
|
+
|
|
495
|
+
const requested = own(options,'atTick');
|
|
496
|
+
if (requested !== undefined && (typeof requested !== 'number' || !Number.isFinite(requested))) throw new Error('ray.atTick is a finite simulation tick');
|
|
497
|
+
if (requested !== undefined && !historyTicks) throw new Error('ray.atTick needs room.historySeconds');
|
|
498
|
+
const allowance = cx.ent?.driver === 'person' ? Math.min(historyTicks,Math.max(0,opts.rewindTicks?.(cx.ent.seat) ?? 0)) : historyTicks;
|
|
499
|
+
const wanted = typeof requested === 'number' ? Math.max(tick-allowance,Math.min(tick,requested)) : tick;
|
|
500
|
+
let before: typeof poseHistory[number] | undefined, after: typeof poseHistory[number] | undefined;
|
|
501
|
+
if(wanted<tick)for(const frame of poseHistory){charge(2);if(frame[0]<=wanted)before=frame;if(frame[0]>=wanted){after=frame;break;}}
|
|
502
|
+
before ??= poseHistory[0];after ??= poseHistory[poseHistory.length-1];
|
|
503
|
+
for(const e of ents.values()) {
|
|
504
|
+
if(e.dead || !e.kind.body || typeof filterKind==='string'&&e.kind.name!==filterKind || geometryOnly&&!e.kind.collider || typeof filterLayer==='string'&&filterLayer!==(e.kind.query?.layer??(e.kind.collider?'geometry':'body')) || typeof filterTag==='string'&&!e.kind.query?.tags?.includes(filterTag))continue;
|
|
505
|
+
charge(64);
|
|
506
|
+
const row=e.kind.collider?colliderRow(e.id,e.pos,e.kind.body,e.kind.collider,e.f):null;
|
|
507
|
+
if(e.kind.collider&&!row)continue;
|
|
508
|
+
let at=e.pos;
|
|
509
|
+
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};}}
|
|
510
|
+
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});
|
|
511
|
+
}
|
|
512
|
+
return targets;
|
|
513
|
+
}
|
|
481
514
|
function cast3(p: Vec3, d: Vec3, shape: BodyShape, ignored: Set<string>): Hit3 | null {
|
|
482
515
|
let best = castMap3(c.map, p, d, shape);
|
|
483
516
|
const solid = solidAt(p, shape);
|
|
@@ -514,6 +547,23 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
514
547
|
if (cx.scope !== 'ent' && cx.scope !== 'room') throw new Error('world.send is for handlers');
|
|
515
548
|
push({ due: tick + 1, to: typeof target === 'string' ? target.slice(0, 24) : '', kind: 'ev', ev: ev as string, data: shapeData(c.events, ev, 'the event', data), at: tick });
|
|
516
549
|
},
|
|
550
|
+
dispatch: (target: unknown, ev: unknown, data?: unknown): boolean => {
|
|
551
|
+
charge(SEND);
|
|
552
|
+
if (cx.scope !== 'ent' && cx.scope !== 'room') throw new Error('world.dispatch is for handlers');
|
|
553
|
+
if (dispatchDepth >= 8) throw new Error('world.dispatch permits at most eight nested deliveries');
|
|
554
|
+
const payload = shapeData(c.events, ev, 'the event', data);
|
|
555
|
+
const e = typeof target === 'string' ? ents.get(target) : undefined;
|
|
556
|
+
const fn = e && typeof ev === 'string' ? e.kind.on[ev] : undefined;
|
|
557
|
+
if (!e || e.dead || !fn) return false;
|
|
558
|
+
// Synchronous delivery shares the caller's remaining quota. Each handler
|
|
559
|
+
// still writes only its own entity, and collection settlement stays scoped.
|
|
560
|
+
const parent = cx, parentTouched = touched, note = G.note;
|
|
561
|
+
cx = {scope:'ent',ent:e}; touched = []; dispatchDepth++;
|
|
562
|
+
if (noted) G.note = (what, at, written) => noted(e.kind.name, `on.${String(ev)}`, what, at, written);
|
|
563
|
+
try { fn(world,e.self,payload); }
|
|
564
|
+
finally { try { settle(); } finally { cx=parent; touched=parentTouched; G.note=note; dispatchDepth--; } }
|
|
565
|
+
return true;
|
|
566
|
+
},
|
|
517
567
|
sendRoom: (ev: unknown, data?: unknown): void => {
|
|
518
568
|
charge(SEND);
|
|
519
569
|
if (cx.scope !== 'ent' && cx.scope !== 'room') throw new Error('world.sendRoom is for handlers');
|
|
@@ -608,7 +658,8 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
608
658
|
charge(cost);
|
|
609
659
|
return Object.freeze(found.map(viewOf));
|
|
610
660
|
},
|
|
611
|
-
ray: (from: unknown, direction: unknown, max: unknown): unknown => {
|
|
661
|
+
ray: (from: unknown, direction: unknown, max: unknown, options?: unknown): unknown => {
|
|
662
|
+
if (options !== undefined) return rayQuery(c.map, rayTargets(options), dims, from, direction, max, options, cx.ent?.id)[0];
|
|
612
663
|
// Charged before the cast, for every shape it may test: the map's, and every entity in the room.
|
|
613
664
|
charge(20 + 4 * ents.size);
|
|
614
665
|
const p = V(from); const d = dir(direction, dims); const far = reach(max, 'world.ray');
|
|
@@ -630,6 +681,14 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
630
681
|
if (!best) return undefined;
|
|
631
682
|
return Object.freeze({ ...(best.id ? { entity: best.id } : {}), at: V({ x: p.x + d.x * far * best.t, y: p.y + d.y * far * best.t, z: 0 }), normal: V({ x: best.nx, y: best.ny, z: 0 }), dist: far * best.t });
|
|
632
683
|
},
|
|
684
|
+
route: (graph: unknown, from: unknown, to: unknown, options?: unknown) => {
|
|
685
|
+
const radius=own(options,'radius')??.5,height=own(options,'height')??1.8;
|
|
686
|
+
if(typeof radius!=='number'||!Number.isFinite(radius)||radius<0||radius>100||typeof height!=='number'||!Number.isFinite(height)||height<0||height>200)throw new Error('route radius/height are finite body dimensions');
|
|
687
|
+
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);
|
|
688
|
+
},
|
|
689
|
+
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);},
|
|
690
|
+
rays: (options?: unknown): unknown => raySnapshot(c.map,rayTargets(options),dims,options,cx.ent?.id),
|
|
691
|
+
rayAll: (from: unknown, direction: unknown, max: unknown, options?: unknown): unknown => rayQuery(c.map, rayTargets(options), dims, from, direction, max, options, cx.ent?.id, true),
|
|
633
692
|
sweep: (self: unknown, delta: unknown, o?: unknown): unknown => {
|
|
634
693
|
const e = mine(self, 'world.sweep');
|
|
635
694
|
charge(20 + 4 * ents.size);
|
|
@@ -688,13 +747,19 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
688
747
|
Object.freeze(world);
|
|
689
748
|
|
|
690
749
|
const hasColliders = c.kinds.some(k => k.collider);
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
const
|
|
694
|
-
|
|
695
|
-
|
|
750
|
+
let collisionPrevious: CollisionRevision | null = null, collisionText = '';
|
|
751
|
+
const collisionState = (): CollisionRevision => {
|
|
752
|
+
const rows = [...ents.values()].flatMap(e => {
|
|
753
|
+
if (e.dead || !e.kind.collider || !e.kind.body) return [];
|
|
754
|
+
const row = colliderRow(e.id,e.pos,e.kind.body,e.kind.collider,e.f,e.kind.name,e.kind.query);
|
|
755
|
+
return row ? [row] : [];
|
|
756
|
+
});
|
|
757
|
+
const text = JSON.stringify(rows);
|
|
758
|
+
if (!collisionPrevious || text !== collisionText) {collisionText=text;collisionPrevious=[...(collisionPrevious ? [tick,tick+1] : opts.restore?.collision ?? [tick,tick+1]) as [number,number],rows];}
|
|
759
|
+
return collisionPrevious;
|
|
760
|
+
};
|
|
696
761
|
let moveGeometry = c.map as import('./math.ts').MapShapes;
|
|
697
|
-
const queries = collisionQueries(() => moveGeometry, () => moveShape, dims);
|
|
762
|
+
const queries = collisionQueries(() => moveGeometry, () => moveShape, dims, () => cx.ent?.id);
|
|
698
763
|
const moveWorld = brand(Object.freeze(queries));
|
|
699
764
|
const moveMap = brand(Object.freeze({ name: c.map.name, spot: mapApi.spot, spots: mapApi.spots, ...queries }));
|
|
700
765
|
let moveShape = { shape: 'sphere', radius: 0, height: 0 };
|
|
@@ -774,7 +839,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
774
839
|
return rows;
|
|
775
840
|
}
|
|
776
841
|
function joinBody(info: SeatInfo): Ent | null {
|
|
777
|
-
const player = Object.freeze({ seat: info.seat, driver: info.driver, owner: info.owner });
|
|
842
|
+
const player = Object.freeze({ seat: info.seat, driver: info.driver, owner: info.owner, ...(info.character !== undefined ? { character: deepFreeze(info.character) } : {}) });
|
|
778
843
|
let made: Ent | null = null;
|
|
779
844
|
run('room', 'join', null, 'join', () => {
|
|
780
845
|
// What room.join returns is read by its own data properties, and its fields are charged before they are held to their shapes.
|
|
@@ -800,6 +865,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
800
865
|
} else {
|
|
801
866
|
if (e) {
|
|
802
867
|
if (e.kind.on.leave) run(e.kind.name, 'on.leave', e, 'ent', () => e.kind.on.leave(world, e.self, Object.freeze({})));
|
|
868
|
+
opts.characterSaved?.(e.seat, JSON.parse(JSON.stringify({ kind: e.kind.name, at: e.pos, heading: e.heading, fields: e.f, motion: e.m })));
|
|
803
869
|
e.dead = true; ents.delete(e.id);
|
|
804
870
|
}
|
|
805
871
|
seats.delete(s.seat);
|
|
@@ -833,13 +899,19 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
833
899
|
// The seat changed hands without a goodbye: its old holder has left.
|
|
834
900
|
if (s && s.driver !== 'bot') { leaveBody(s); s = seats.get(info.seat); }
|
|
835
901
|
let bot: Ent | null = s && s.id ? ents.get(s.id) ?? null : null;
|
|
836
|
-
|
|
902
|
+
const previousBot = bot;
|
|
903
|
+
if (!bot && playerBodies().length >= c.bots || bot?.kind.player?.takeover) {
|
|
837
904
|
// A person who joins a full room takes over a bot's body: the bot in the highest seat.
|
|
838
|
-
|
|
905
|
+
const priority = (e: Ent): number => e.kind.player?.takeover ? Number(e.f[e.kind.player.takeover]) : 0;
|
|
906
|
+
for (const e of playerBodies()) if (e.driver === 'bot' && (!bot || priority(e) > priority(bot) || (priority(e) === priority(bot) && e.seat > bot.seat))) bot = e;
|
|
839
907
|
}
|
|
840
908
|
if (bot) {
|
|
841
909
|
const e = bot;
|
|
842
910
|
seats.delete(e.seat);
|
|
911
|
+
if (previousBot && previousBot !== e) {
|
|
912
|
+
previousBot.seat = e.seat;
|
|
913
|
+
seats.set(previousBot.seat, {seat:previousBot.seat, driver:'bot', owner:'', id:previousBot.id, away:false});
|
|
914
|
+
}
|
|
843
915
|
e.goal = null; e.seat = info.seat; e.driver = info.driver; e.owner = info.owner; e.away = false; e.r = (e.r + 1) & 0xffff; e.allow = 0;
|
|
844
916
|
seats.set(info.seat, { seat: info.seat, driver: info.driver, owner: info.owner, id: e.id, away: false });
|
|
845
917
|
out.push({ t: 'seats' });
|
|
@@ -882,6 +954,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
882
954
|
const seat = seats.get(e.seat);
|
|
883
955
|
if (seat) leaveBody(seat);
|
|
884
956
|
}
|
|
957
|
+
poseHistory = [];
|
|
885
958
|
round = { n: round.n + 1, phase: 'live', endsAt: c.rounds && c.rounds.seconds > 0 ? tick + ticks(c.rounds.seconds) : 0, startedAt: tick };
|
|
886
959
|
overAt = 0;
|
|
887
960
|
refreshRound();
|
|
@@ -904,9 +977,10 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
904
977
|
if (tick < restartAt) return;
|
|
905
978
|
startMatch();
|
|
906
979
|
// Seats stay through a finished match: every holder's body is made again, as if it had just joined.
|
|
907
|
-
for (const s of [...seats.values()]) { seats.delete(s.seat); if (s.driver !== 'bot') ops.push({ op: 'join', info: { seat: s.seat, driver: s.driver, owner: s.owner } }, ...(s.away ? [{ op: 'away' as const, seat: s.seat, away: true }] : [])); }
|
|
980
|
+
for (const s of [...seats.values()]) { seats.delete(s.seat); if (s.driver !== 'bot') ops.push({ op: 'join', info: { seat: s.seat, driver: s.driver, owner: s.owner, ...(opts.characterForSeat ? { character: opts.characterForSeat(s.seat) } : {}) } }, ...(s.away ? [{ op: 'away' as const, seat: s.seat, away: true }] : [])); }
|
|
908
981
|
return;
|
|
909
982
|
}
|
|
983
|
+
if (restartAsked) { restartAsked = false; endAsked = false; startRound(); return; }
|
|
910
984
|
if (round.phase === 'live' && (endAsked || (round.endsAt > 0 && tick >= round.endsAt))) endRound();
|
|
911
985
|
endAsked = false;
|
|
912
986
|
if (overAt && tick >= overAt + 2) {
|
|
@@ -918,9 +992,12 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
918
992
|
} else if (round.phase === 'over' && !overAt && tick >= round.endsAt) startRound();
|
|
919
993
|
}
|
|
920
994
|
function finishMatch(): void {
|
|
921
|
-
finishing = false;
|
|
995
|
+
finishing = false; restartAsked = false;
|
|
922
996
|
out.push({ t: 'round', n: round.n, phase: 'over', endsAt: tick + Math.max(3, ticks(c.rounds ? c.rounds.breakSeconds : 0)), startedAt: round.startedAt, results: results() });
|
|
923
|
-
for (const e of ents.values())
|
|
997
|
+
for (const e of ents.values()) {
|
|
998
|
+
if (e.kind.player) opts.characterSaved?.(e.seat, JSON.parse(JSON.stringify({ kind: e.kind.name, at: e.pos, heading: e.heading, fields: e.f, motion: e.m })));
|
|
999
|
+
e.dead = true;
|
|
1000
|
+
}
|
|
924
1001
|
ents.clear(); spawns = []; queue = []; pendingAsks = []; areas = []; fx = [];
|
|
925
1002
|
for (const s of seats.values()) s.id = null;
|
|
926
1003
|
shared = initFields(c.shared, dims); sharedChanged();
|
|
@@ -1014,12 +1091,13 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
1014
1091
|
const body = k.body as NonNullable<KindTable['body']>;
|
|
1015
1092
|
let claim: StepInput['claim'] = null;
|
|
1016
1093
|
if (k.player) {
|
|
1017
|
-
const
|
|
1094
|
+
const permitted = !k.player.control || Boolean(e.m[k.player.control]);
|
|
1095
|
+
const driven = permitted && (e.driver === 'person' || (e.driver === 'ai' && inputs.has(e.seat))) && !e.away;
|
|
1018
1096
|
if (driven) {
|
|
1019
1097
|
const s = inputs.get(e.seat);
|
|
1020
1098
|
e.input = Object.freeze(s ? coerceFields(k.input, s.values, dims) : initFields(k.input, dims));
|
|
1021
1099
|
claim = s?.claim ?? null;
|
|
1022
|
-
} else if (k.think && (e.driver !== 'person' || k.player.away === 'think')) {
|
|
1100
|
+
} else if (k.think && (!permitted || e.driver !== 'person' || k.player.away === 'think')) {
|
|
1023
1101
|
// `think` returns the step. It is held to the declared input inside the handler's own try and budget; a `think` that throws leaves the input neutral.
|
|
1024
1102
|
let stepIn: Record<string, unknown> | null = null;
|
|
1025
1103
|
run(k.name, 'think', e, 'ent', () => {
|
|
@@ -1068,7 +1146,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
1068
1146
|
naming('body', 'pos'); e.pos = clampIn(V(own(b, 'pos')), body.radius, body.height || 2 * body.radius); naming('body', 'vel'); e.vel = V(own(b, 'vel')); naming('body', 'heading'); e.heading = dir(own(b, 'heading'), dims); naming();
|
|
1069
1147
|
G.note = null;
|
|
1070
1148
|
e.grounded = own(b, 'grounded') === true;
|
|
1071
|
-
if (beforeMove) opts.moved!(k.name, tick, e.input, beforeMove, { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: { ...e.m } }, moveGeometry);
|
|
1149
|
+
if (beforeMove) opts.moved!(k.name, tick, e.input, beforeMove, { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: { ...e.m } }, moveGeometry, e.id);
|
|
1072
1150
|
}
|
|
1073
1151
|
// Phase 2: for every entity, its commands and then its tick, starting from a different entity each tick.
|
|
1074
1152
|
const list = [...ents.values()];
|
|
@@ -1109,6 +1187,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
1109
1187
|
areas = [];
|
|
1110
1188
|
if (finishing) finishMatch();
|
|
1111
1189
|
}
|
|
1190
|
+
if(historyTicks){const rows:[string,number,number,number,number][]=[];for(const e of ents.values())if(e.kind.body&&!e.kind.collider&&!e.dead)rows.push([e.id,e.pos.x,e.pos.y,e.pos.z,e.r]);left-=16*rows.length;poseHistory.push([tick,rows]);while(poseHistory.length>historyTicks+1)poseHistory.shift();}
|
|
1112
1191
|
if (fx.length) { out.push({ t: 'fx', tick, list: fx }); fx = []; }
|
|
1113
1192
|
if (sharedDirty) { sharedDirty = false; out.push({ t: 'shared' }); }
|
|
1114
1193
|
stats.tickUnits = c.settings.budget.tick - left;
|
|
@@ -1142,7 +1221,9 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
1142
1221
|
function save(): SavedCore {
|
|
1143
1222
|
return {
|
|
1144
1223
|
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,
|
|
1145
|
-
|
|
1224
|
+
...(hasColliders?{collision:collisionState().slice(0,2) as [number,number]}:{}),
|
|
1225
|
+
...(historyTicks?{history:poseHistory.map(([at,rows])=>[at,rows.map(row=>[...row])] as SavedCore['history'])}:{}),
|
|
1226
|
+
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],
|
|
1146
1227
|
ents: [...ents.values()].map(saveEnt), spawns: spawns.map(saveEnt),
|
|
1147
1228
|
seats: [...seats.values()].map((s) => [s.seat, s.driver, s.owner, s.id, s.away ? 1 : 0]),
|
|
1148
1229
|
queue: queue.map((q) => [q.due, q.from, q.fromId, q.seq, q.to, q.kind, q.ev, q.data, q.at, q.builtIn ? 1 : 0]),
|
|
@@ -1159,7 +1240,8 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
1159
1240
|
check(Array.isArray(value) && JSON.stringify(value) === JSON.stringify(packFields(list, unpackFields(list, value, dims), dims)));
|
|
1160
1241
|
};
|
|
1161
1242
|
check(r.v === SAVE_REVISION);
|
|
1162
|
-
check(
|
|
1243
|
+
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);
|
|
1244
|
+
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)));
|
|
1163
1245
|
const askNames = new Set();
|
|
1164
1246
|
check(Array.isArray(r.asks));
|
|
1165
1247
|
for (const a of r.asks) {
|
|
@@ -1175,7 +1257,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
1175
1257
|
check(Array.isArray(row) && row.length === 2 && uint(row[0]) && row[0] < c.seats && !views.has(row[0])); views.add(row[0]);
|
|
1176
1258
|
check(JSON.stringify(row[1]) === JSON.stringify(coerceFields(c.view, row[1], dims)));
|
|
1177
1259
|
}
|
|
1178
|
-
check(r.intent === undefined || Array.isArray(r.intent) && r.intent.length === 2 && r.intent.every(bit));
|
|
1260
|
+
check(r.intent === undefined || Array.isArray(r.intent) && (r.intent.length === 2 || r.intent.length === 3) && r.intent.every(bit));
|
|
1179
1261
|
for (const v of [r.tick, r.epoch, r.rng, r.nextId, r.seq, r.overAt, r.trips]) check(uint(v));
|
|
1180
1262
|
check(r.tick < 0xffffffff);
|
|
1181
1263
|
check(r.rng <= 4294967295 && r.epoch > 0 && r.nextId > 0 && r.seq > 0);
|
|
@@ -1284,10 +1366,16 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
1284
1366
|
if (r) {
|
|
1285
1367
|
if (r.v !== SAVE_REVISION) throw new Error('this save was written by another version of the runtime');
|
|
1286
1368
|
validateSave(r);
|
|
1369
|
+
if(historyTicks){
|
|
1370
|
+
if(!Array.isArray(r.history)||r.history.length>historyTicks+1)throw new Error('saved query history is invalid');
|
|
1371
|
+
let previous=-1;
|
|
1372
|
+
for(const frame of r.history){if(!Array.isArray(frame)||frame.length!==2||!Number.isInteger(frame[0])||frame[0]<=previous||frame[0]>r.tick||frame[0]<r.tick-historyTicks||!Array.isArray(frame[1])||frame[1].length>ENTITY_MAX)throw new Error('saved query history is invalid');previous=frame[0];const ids=new Set<string>();for(const p of frame[1]){if(!Array.isArray(p)||p.length!==5||typeof p[0]!=='string'||p[0].length>24||ids.has(p[0])||!p.slice(1,4).every(v=>typeof v==='number'&&Number.isFinite(v))||!Number.isInteger(p[4])||p[4]<0||p[4]>65535)throw new Error('saved query history is invalid');ids.add(p[0]);}}
|
|
1373
|
+
poseHistory=r.history.map(([at,rows])=>[at,rows.map(row=>[...row])]);
|
|
1374
|
+
}
|
|
1287
1375
|
pendingAsks = r.asks.map(a => ({ ...a, state: deepFreeze(a.state) }));
|
|
1288
1376
|
if ([opts.restoreEpoch].some((e) => e !== undefined && (!Number.isSafeInteger(e) || e <= 0))) throw new Error('restored epoch is invalid');
|
|
1289
1377
|
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;
|
|
1290
|
-
endAsked = r.intent?.[0] === 1; finishing = r.intent?.[1] === 1;
|
|
1378
|
+
endAsked = r.intent?.[0] === 1; finishing = r.intent?.[1] === 1; restartAsked = r.intent?.[2] === 1;
|
|
1291
1379
|
round = { n: r.round[0], phase: r.round[1] === 1 ? 'live' : 'over', endsAt: r.round[2], startedAt: r.round[3] };
|
|
1292
1380
|
refreshRound();
|
|
1293
1381
|
shared = unpackFields(c.shared, r.shared, dims); sharedChanged();
|
|
@@ -1304,6 +1392,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
1304
1392
|
});
|
|
1305
1393
|
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)) }));
|
|
1306
1394
|
ops = r.ops as typeof ops;
|
|
1395
|
+
if(hasColliders)collisionState();
|
|
1307
1396
|
} else startMatch();
|
|
1308
1397
|
|
|
1309
1398
|
function decisionResult(value: unknown): Record<string, unknown> {
|
|
@@ -1372,7 +1461,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
1372
1461
|
if (!floorCall && value) guideViews.set(seat, value);
|
|
1373
1462
|
return value;
|
|
1374
1463
|
},
|
|
1375
|
-
seatJoin: (info) => { ops.push({ op: 'join', info: { seat: info.seat, driver: info.driver, owner: String(info.owner ?? '').slice(0, 128) } }); },
|
|
1464
|
+
seatJoin: (info) => { ops.push({ op: 'join', info: { seat: info.seat, driver: info.driver, owner: String(info.owner ?? '').slice(0, 128), ...(info.character !== undefined ? { character: info.character } : {}) } }); },
|
|
1376
1465
|
seatAway: (seat, away) => { ops.push({ op: 'away', seat, away }); },
|
|
1377
1466
|
seatLeave: (seat) => { ops.push({ op: 'leave', seat }); },
|
|
1378
1467
|
setPolicy: (p) => { policy = normalizeCorePolicy(p); },
|
|
@@ -1393,6 +1482,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
1393
1482
|
return [[round.n, round.phase === 'live' ? 1 : 0, round.endsAt], list, ...(collision ? [collision] : [])];
|
|
1394
1483
|
},
|
|
1395
1484
|
shared: () => packFields(c.shared, shared, dims),
|
|
1485
|
+
character: (seat) => { const e = playerBodies().find(e => e.seat === seat); return e ? JSON.parse(JSON.stringify({ kind: e.kind.name, at: e.pos, heading: e.heading, fields: e.f, motion: e.m })) : null; },
|
|
1396
1486
|
bodies: () => playerBodies().map((e) => ({ seat: e.seat, id: e.id, kind: e.kind.name, driver: e.driver, owner: e.owner, away: e.away, score: e.kind.score ? Number(e.f[e.kind.score]) || 0 : 0, r: e.r })),
|
|
1397
1487
|
bodyOf: (seat) => { const s = seats.get(seat); const e = s?.id ? ents.get(s.id) : null; return e ? { id: e.id, kind: e.kind, r: e.r, away: e.away, driver: e.driver } : null; },
|
|
1398
1488
|
drain: () => { const o = out; out = []; return o; },
|
package/rules/guard.ts
CHANGED
|
@@ -37,7 +37,7 @@ export const refusedName = (k: string): boolean => REFUSED_NAMES.has(k) || k.sta
|
|
|
37
37
|
* of the rules that ran last: the guarded module sets them as it goes, and the runtime reads them when a handler throws.
|
|
38
38
|
* `note` is the build check's listener for a written value that had to be changed to fit (pack.ts); a room has none.
|
|
39
39
|
*/
|
|
40
|
-
export const G = { left: Infinity, file: '', line: 0, note: null as null | ((what: string, at: string, written: string) => void) };
|
|
40
|
+
export const G = { queryEpoch: 0, left: Infinity, file: '', line: 0, note: null as null | ((what: string, at: string, written: string) => void) };
|
|
41
41
|
/** The line a guarded call is on, set again after its arguments ran (they may have called into another file). */
|
|
42
42
|
export function l(file: string, line: number): number { G.file = file; G.line = line; return line; }
|
|
43
43
|
|