@homie-rocks/studio 0.32.1 → 0.33.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +67 -1
  2. package/README.md +24 -6
  3. package/agents/agents.ts +57 -24
  4. package/bin/homie-studio.mjs +3 -3
  5. package/lib/build.mjs +64 -9
  6. package/lib/cf-usage.mjs +76 -0
  7. package/lib/cloudflare.mjs +25 -2
  8. package/lib/dev.mjs +45 -4
  9. package/lib/lab-check.mjs +3 -2
  10. package/lib/mcp-tools.mjs +1 -1
  11. package/lib/mcp.mjs +1 -1
  12. package/lib/office.mjs +7 -0
  13. package/lib/perf.mjs +8 -3
  14. package/lib/player-image.mjs +40 -0
  15. package/lib/rules-build.mjs +184 -0
  16. package/lib/rules-guard.mjs +770 -0
  17. package/lib/scaffold.mjs +43 -16
  18. package/lib/shop-catalog.mjs +10 -9
  19. package/lib/shop.mjs +32 -15
  20. package/lib/studio.mjs +22 -1
  21. package/lib/template-history.json +90 -0
  22. package/lib/upgrade.mjs +19 -15
  23. package/lib/version.mjs +2 -7
  24. package/netplay/NETPLAY.md +249 -4
  25. package/netplay/netplay.ts +55 -13
  26. package/package.json +23 -1
  27. package/rules/core.ts +1250 -0
  28. package/rules/guard.ts +419 -0
  29. package/rules/host.ts +707 -0
  30. package/rules/math.ts +266 -0
  31. package/rules/pack.ts +292 -0
  32. package/rules/rules.ts +544 -0
  33. package/rules/view.ts +447 -0
  34. package/shop/SELLING.md +14 -16
  35. package/shop/SHOP.md +119 -30
  36. package/shop/shop.ts +6 -8
  37. package/site/SITE.md +119 -2
  38. package/starters/coin-dash/game.json +11 -0
  39. package/starters/coin-dash/index.html +17 -0
  40. package/starters/coin-dash/map/main.json +16 -0
  41. package/starters/coin-dash/src/move.ts +27 -0
  42. package/starters/coin-dash/src/rules.ts +86 -0
  43. package/starters/coin-dash/src/view.ts +106 -0
  44. package/starters/coin-dash/tunables.json +5 -0
  45. package/starters/ember-vale/index.html +11 -0
  46. package/worker/brain.mjs +8 -7
  47. package/worker/chat-page.mjs +1 -1
  48. package/worker/embed.mjs +135 -0
  49. package/worker/hosted.mjs +56 -0
  50. package/worker/index.mjs +413 -22
  51. package/worker/office-page.mjs +1 -0
  52. package/worker/office.mjs +24 -7
  53. package/worker/pages.mjs +189 -43
  54. package/worker/players.mjs +10 -6
  55. package/worker/referrals.mjs +192 -57
  56. package/worker/room-store.mjs +54 -0
  57. package/worker/room.mjs +294 -119
  58. package/worker/saves-shell.mjs +7 -1
  59. package/worker/schema.mjs +4 -3
  60. package/worker/shop-page.mjs +103 -37
  61. package/worker/shop-rules.mjs +156 -117
  62. package/worker/shop-store.mjs +70 -9
  63. package/worker/shop.mjs +275 -143
  64. package/worker/site.mjs +26 -5
  65. package/worker/stripe.mjs +9 -6
  66. package/worker/version.mjs +1 -1
package/rules/math.ts ADDED
@@ -0,0 +1,266 @@
1
+ /*
2
+ * math.ts — `world.math` and `ctx.math`: the maths rules may not take from `Math`, in plain arithmetic.
3
+ * =============================================================================
4
+ *
5
+ * `Math.sin`, `cos`, `atan2`, `pow`, `exp`, `log` and `hypot` may differ in their last bits between JavaScript engines,
6
+ * so rules that used them would step differently on the server, in a browser and in the build check. Everything here is
7
+ * made of `+ - * /`, `Math.sqrt`, `Math.floor`, `Math.abs` and comparisons, each of which has one correct result
8
+ * (rooms-milestone-1-design.md, section 3.3). They are accurate to about 1e-12, which is far below what a game sees;
9
+ * what matters is that every engine gets the same bits.
10
+ *
11
+ * Vectors are `{ x, y, z }` in metres, `z` up. A helper returns a new frozen vector and never changes its arguments.
12
+ * Every call is charged 4 units to the running handler's budget (guard.ts), beside what any guarded call costs.
13
+ * =============================================================================
14
+ */
15
+ import { brand, charge, own, put } from './guard.ts';
16
+
17
+ export interface Vec3 { readonly x: number; readonly y: number; readonly z: number }
18
+
19
+ const PI = 3.141592653589793;
20
+ const TAU = 6.283185307179586;
21
+ const HALF_PI = 1.5707963267948966;
22
+ const LN2 = 0.6931471805599453;
23
+
24
+ const v = (x: number, y: number, z: number): Vec3 => Object.freeze({ x, y, z });
25
+ const zOf = (a: { z?: number }): number => a.z ?? 0;
26
+
27
+ /** sin on [-pi/4, pi/4] and cos on the same range, by their Taylor series (terms to x^15 and x^16). */
28
+ function sinK(x: number): number {
29
+ const x2 = x * x;
30
+ return x * (1 + x2 * (-1 / 6 + x2 * (1 / 120 + x2 * (-1 / 5040 + x2 * (1 / 362880 + x2 * (-1 / 39916800 + x2 * (1 / 6227020800 + x2 * (-1 / 1307674368000))))))));
31
+ }
32
+ function cosK(x: number): number {
33
+ const x2 = x * x;
34
+ return 1 + x2 * (-1 / 2 + x2 * (1 / 24 + x2 * (-1 / 720 + x2 * (1 / 40320 + x2 * (-1 / 3628800 + x2 * (1 / 479001600 + x2 * (-1 / 87178291200 + x2 * (1 / 20922789888000))))))));
35
+ }
36
+ /** x as k quarter turns plus a remainder in [-pi/4, pi/4]. */
37
+ function reduce(x: number): [number, number] {
38
+ const q = Math.floor(x / HALF_PI + 0.5);
39
+ return [((q % 4) + 4) % 4, x - q * HALF_PI];
40
+ }
41
+ function sin(x: number): number {
42
+ if (!Number.isFinite(x)) return NaN;
43
+ const [q, r] = reduce(x);
44
+ return q === 0 ? sinK(r) : q === 1 ? cosK(r) : q === 2 ? -sinK(r) : -cosK(r);
45
+ }
46
+ function cos(x: number): number {
47
+ if (!Number.isFinite(x)) return NaN;
48
+ const [q, r] = reduce(x);
49
+ return q === 0 ? cosK(r) : q === 1 ? -sinK(r) : q === 2 ? -cosK(r) : sinK(r);
50
+ }
51
+ /** atan by halving the argument until it is small (atan x = 2 atan(x / (1 + sqrt(1 + x^2)))), then its series. */
52
+ function atan(x: number): number {
53
+ if (x !== x) return NaN;
54
+ if (x === Infinity) return HALF_PI;
55
+ if (x === -Infinity) return -HALF_PI;
56
+ let a = x;
57
+ let scale = 1;
58
+ for (let i = 0; i < 4; i += 1) { a = a / (1 + Math.sqrt(1 + a * a)); scale *= 2; }
59
+ // The series is a - a^3/3 + a^5/5 - ...: built from the last term in, so each step multiplies by a^2.
60
+ const a2 = a * a;
61
+ let s = 0;
62
+ for (let n = 12; n >= 0; n -= 1) s = (n % 2 === 0 ? 1 : -1) / (2 * n + 1) + a2 * s;
63
+ return scale * a * s;
64
+ }
65
+ function atan2(y: number, x: number): number {
66
+ if (x !== x || y !== y) return NaN;
67
+ if (x > 0) return atan(y / x);
68
+ if (x < 0) return y >= 0 ? atan(y / x) + PI : atan(y / x) - PI;
69
+ return y > 0 ? HALF_PI : y < 0 ? -HALF_PI : 0;
70
+ }
71
+ /** exp by taking out whole powers of two (x = k ln 2 + r), then the series of e^r. */
72
+ function exp(x: number): number {
73
+ if (x !== x) return NaN;
74
+ if (x > 709.78) return Infinity;
75
+ if (x < -745.2) return 0;
76
+ const kq = Math.floor(x / LN2 + 0.5);
77
+ const r = x - kq * LN2;
78
+ let sum = 1;
79
+ for (let n = 18; n >= 1; n -= 1) sum = 1 + (r / n) * sum;
80
+ let out = sum;
81
+ let p = kq;
82
+ while (p > 0) { out *= 2; p -= 1; }
83
+ while (p < 0) { out /= 2; p += 1; }
84
+ return out;
85
+ }
86
+ /** log by bringing x into [sqrt(1/2), sqrt 2) with powers of two, then 2 atanh((m - 1) / (m + 1)). */
87
+ function log(x: number): number {
88
+ if (x !== x || x < 0) return NaN;
89
+ if (x === 0) return -Infinity;
90
+ if (x === Infinity) return Infinity;
91
+ let m = x;
92
+ let e = 0;
93
+ while (m >= 1.4142135623730951) { m /= 2; e += 1; }
94
+ while (m < 0.7071067811865476) { m *= 2; e -= 1; }
95
+ const y = (m - 1) / (m + 1);
96
+ const y2 = y * y;
97
+ let sum = 0;
98
+ for (let n = 14; n >= 0; n -= 1) sum = 1 / (2 * n + 1) + y2 * sum;
99
+ return 2 * y * sum + e * LN2;
100
+ }
101
+ /** x to the y. A whole y is repeated multiplication (exact for small powers); otherwise exp(y log x). */
102
+ function pow(x: number, y: number): number {
103
+ if (y === 0) return 1;
104
+ if (x !== x || y !== y) return NaN;
105
+ if (Math.floor(y) === y && Math.abs(y) <= 1024) {
106
+ let base = x;
107
+ let n = Math.abs(y);
108
+ let out = 1;
109
+ while (n > 0) { if (n % 2 === 1) out *= base; base *= base; n = Math.floor(n / 2); }
110
+ return y < 0 ? 1 / out : out;
111
+ }
112
+ if (x < 0) return NaN;
113
+ if (x === 0) return y > 0 ? 0 : Infinity;
114
+ return exp(y * log(x));
115
+ }
116
+
117
+ const clamp = (x: number, lo: number, hi: number): number => (x < lo ? lo : x > hi ? hi : x);
118
+ const len = (a: Vec3): number => Math.sqrt(a.x * a.x + a.y * a.y + zOf(a) * zOf(a));
119
+
120
+ /**
121
+ * One function of the table, charged 4 units a call. `sig` names what each argument is: `n` a number, `o` a number
122
+ * that may be left out, `v` a vector. Anything else is refused before the function runs: arithmetic on a text would
123
+ * join texts (and grow one without limit), and arithmetic on a list or an object would have JavaScript turn it into a
124
+ * text first, at a cost the budget never sees.
125
+ */
126
+ function paid<A extends unknown[], R>(name: string, sig: string, fn: (...a: A) => R): (...a: A) => R {
127
+ const wrong = (): never => { throw new TypeError(`math.${name} takes ${sig.split('').map((s) => (s === 'v' ? 'a vector' : 'a number')).join(', ')}: a vector is { x, y, z } of numbers`); };
128
+ return (...a: A): R => {
129
+ charge(4);
130
+ for (let i = 0; i < sig.length; i += 1) {
131
+ const arg = a[i] as any;
132
+ if (sig[i] !== 'v') { if (typeof arg !== 'number' && !(sig[i] === 'o' && arg === undefined)) wrong(); continue; }
133
+ if (arg === null || typeof arg !== 'object') wrong();
134
+ // Each part is read once and checked, and the function is handed a vector of those very numbers: nothing it reads can be anything else.
135
+ const x = arg.x; const y = arg.y; const z = arg.z;
136
+ if (typeof x !== 'number' || typeof y !== 'number' || (z !== undefined && typeof z !== 'number')) wrong();
137
+ a[i] = { x, y, z: z ?? 0 };
138
+ }
139
+ return fn(...a);
140
+ };
141
+ }
142
+
143
+ /** `world.math` and `ctx.math`. Frozen; the same object for every room. */
144
+ export const math = brand(Object.freeze({
145
+ PI, TAU,
146
+ sin: paid('sin', 'n', sin), cos: paid('cos', 'n', cos), tan: paid('tan', 'n', (x: number) => sin(x) / cos(x)),
147
+ atan: paid('atan', 'n', atan), atan2: paid('atan2', 'nn', atan2),
148
+ asin: paid('asin', 'n', (x: number) => (x < -1 || x > 1 ? NaN : atan2(x, Math.sqrt(1 - x * x)))),
149
+ acos: paid('acos', 'n', (x: number) => (x < -1 || x > 1 ? NaN : atan2(Math.sqrt(1 - x * x), x))),
150
+ exp: paid('exp', 'n', exp), log: paid('log', 'n', log), pow: paid('pow', 'nn', pow),
151
+ hypot: paid('hypot', 'nno', (x: number, y: number, z: number = 0) => Math.sqrt(x * x + y * y + z * z)),
152
+ /** Degrees to radians, and back. */
153
+ rad: paid('rad', 'n', (deg: number) => (deg * PI) / 180),
154
+ deg: paid('deg', 'n', (rad: number) => (rad * 180) / PI),
155
+ clamp: paid('clamp', 'nnn', clamp),
156
+ lerp: paid('lerp', 'nnn', (a: number, b: number, t: number) => a + (b - a) * t),
157
+ vec: paid('vec', 'ooo', (x: number = 0, y: number = 0, z: number = 0) => v(x, y, z)),
158
+ add: paid('add', 'vv', (a: Vec3, b: Vec3) => v(a.x + b.x, a.y + b.y, zOf(a) + zOf(b))),
159
+ sub: paid('sub', 'vv', (a: Vec3, b: Vec3) => v(a.x - b.x, a.y - b.y, zOf(a) - zOf(b))),
160
+ scale: paid('scale', 'vn', (a: Vec3, k: number) => v(a.x * k, a.y * k, zOf(a) * k)),
161
+ dot: paid('dot', 'vv', (a: Vec3, b: Vec3) => a.x * b.x + a.y * b.y + zOf(a) * zOf(b)),
162
+ cross: paid('cross', 'vv', (a: Vec3, b: Vec3) => v(a.y * zOf(b) - zOf(a) * b.y, zOf(a) * b.x - a.x * zOf(b), a.x * b.y - a.y * b.x)),
163
+ len: paid('len', 'v', len),
164
+ dist: paid('dist', 'vv', (a: Vec3, b: Vec3) => len(v(a.x - b.x, a.y - b.y, zOf(a) - zOf(b)))),
165
+ /** A unit vector the same way; a zero vector stays zero. */
166
+ norm: paid('norm', 'v', (a: Vec3) => { const l = len(a); return l > 0 ? v(a.x / l, a.y / l, zOf(a) / l) : v(0, 0, 0); }),
167
+ /** The same direction, no longer than `max`. */
168
+ clampLen: paid('clampLen', 'vn', (a: Vec3, max: number) => { const l = len(a); return l > max && l > 0 ? v((a.x / l) * max, (a.y / l) * max, (zOf(a) / l) * max) : v(a.x, a.y, zOf(a)); }),
169
+ lerpVec: paid('lerpVec', 'vvn', (a: Vec3, b: Vec3, t: number) => v(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, zOf(a) + (zOf(b) - zOf(a)) * t)),
170
+ /** A unit vector on the ground plane at this angle (radians, from +x towards +y). */
171
+ dir: paid('dir', 'n', (angle: number) => v(cos(angle), sin(angle), 0)),
172
+ /** The angle of a vector on the ground plane. */
173
+ angle: paid('angle', 'v', (a: Vec3) => atan2(a.y, a.x)),
174
+ }));
175
+ export type RulesMath = typeof math;
176
+
177
+ /** The unpaid functions, for the host runtime's own use (and the tests that hold them to `Math`'s). */
178
+ export const exact = Object.freeze({ sin, cos, atan, atan2, exp, log, pow });
179
+
180
+ /* ------------------------------------------------------------------ the ground plane: a point or a circle against the map */
181
+
182
+ /** The shapes of a map the sweep reads (rules.ts `GameMap` has them). */
183
+ export interface MapShapes { bounds: { min: Vec3; max: Vec3 }; boxes: readonly { min: Vec3; max: Vec3 }[]; circles: readonly { at: Vec3; r: number }[] }
184
+ /** How close a swept body stops to what it hit, in metres. */
185
+ export const SKIN = 0.001;
186
+
187
+ export interface Hit { t: number; nx: number; ny: number; id?: string }
188
+ /** A point moving from p along d (t in 0..1) against a circle of radius R: the first touch, or null. Starting inside is no touch. */
189
+ export function rayCircle(px: number, py: number, dx: number, dy: number, cx: number, cy: number, R: number): Hit | null {
190
+ const ox = px - cx; const oy = py - cy;
191
+ const a = dx * dx + dy * dy;
192
+ if (a < 1e-18) return null;
193
+ const b = ox * dx + oy * dy;
194
+ const cc = ox * ox + oy * oy - R * R;
195
+ if (cc <= 0 || b >= 0) return null;
196
+ const disc = b * b - a * cc;
197
+ if (disc < 0) return null;
198
+ const t = (-b - Math.sqrt(disc)) / a;
199
+ if (t < 0 || t > 1) return null;
200
+ const hx = ox + dx * t; const hy = oy + dy * t;
201
+ const l = Math.sqrt(hx * hx + hy * hy) || 1;
202
+ return { t, nx: hx / l, ny: hy / l };
203
+ }
204
+ /** The same point against a box grown by r with round corners (a circle of radius r against the box). */
205
+ function rayBox(px: number, py: number, dx: number, dy: number, min: Vec3, max: Vec3, r: number): Hit | null {
206
+ let t0 = 0; let t1 = 1; let nx = 0; let ny = 0;
207
+ const lo = [min.x - r, min.y - r]; const hi = [max.x + r, max.y + r];
208
+ const p = [px, py]; const d = [dx, dy];
209
+ for (let i = 0; i < 2; i += 1) {
210
+ if (Math.abs(d[i]) < 1e-12) { if (p[i] <= lo[i] || p[i] >= hi[i]) return null; continue; }
211
+ let ta = (lo[i] - p[i]) / d[i]; let tb = (hi[i] - p[i]) / d[i];
212
+ if (ta > tb) { const s = ta; ta = tb; tb = s; }
213
+ if (ta > t0) { t0 = ta; nx = i === 0 ? (d[i] > 0 ? -1 : 1) : 0; ny = i === 1 ? (d[i] > 0 ? -1 : 1) : 0; }
214
+ if (tb < t1) t1 = tb;
215
+ if (t0 > t1) return null;
216
+ }
217
+ // No entering face: the point started inside the grown box. A body already overlapping is let out, never held.
218
+ if (nx === 0 && ny === 0) return null;
219
+ const hx = px + dx * t0; const hy = py + dy * t0;
220
+ const cornerX = hx < min.x ? min.x : hx > max.x ? max.x : null;
221
+ const cornerY = hy < min.y ? min.y : hy > max.y ? max.y : null;
222
+ if (r > 0 && cornerX !== null && cornerY !== null) return rayCircle(px, py, dx, dy, cornerX, cornerY, r);
223
+ return { t: t0, nx, ny };
224
+ }
225
+ /** The world's edges, as walls facing in. */
226
+ function rayBounds(px: number, py: number, dx: number, dy: number, b: { min: Vec3; max: Vec3 }, r: number): Hit | null {
227
+ let best: Hit | null = null;
228
+ const tryWall = (t: number, nx: number, ny: number): void => { if (t >= 0 && t <= 1 && (!best || t < best.t)) best = { t, nx, ny }; };
229
+ if (dx > 0) tryWall((b.max.x - r - px) / dx, -1, 0);
230
+ if (dx < 0) tryWall((b.min.x + r - px) / dx, 1, 0);
231
+ if (dy > 0) tryWall((b.max.y - r - py) / dy, 0, -1);
232
+ if (dy < 0) tryWall((b.min.y + r - py) / dy, 0, 1);
233
+ return best;
234
+ }
235
+ /** A circle (or a point, r 0) moved along d against the static map: the first thing in the way, and how many shapes were tested. */
236
+ export function castMap(map: MapShapes, px: number, py: number, dx: number, dy: number, r: number): { hit: Hit | null; tested: number } {
237
+ let best: Hit | null = rayBounds(Math.max(map.bounds.min.x + r, Math.min(map.bounds.max.x - r, px)), Math.max(map.bounds.min.y + r, Math.min(map.bounds.max.y - r, py)), dx, dy, map.bounds, r);
238
+ for (const b of map.boxes) { const h = rayBox(px, py, dx, dy, b.min, b.max, r); if (h && (!best || h.t < best.t)) best = h; }
239
+ for (const c of map.circles) { const h = rayCircle(px, py, dx, dy, c.at.x, c.at.y, c.r + r); if (h && (!best || h.t < best.t)) best = h; }
240
+ return { hit: best, tested: 4 + map.boxes.length + map.circles.length };
241
+ }
242
+
243
+ /**
244
+ * `ctx.map.sweep(body, delta)`: move `body.pos` along `delta` and stop at the first static shape in the way. The server
245
+ * and a browser both move a body with this, so they agree to the last bit. Returns nothing, or `{ at, normal }`.
246
+ * Charged 20 units and 4 for each shape of the map. With `dims` 2 a body always rests on the ground.
247
+ */
248
+ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius: number, _dims: number): unknown {
249
+ // Charged before the cast, for every shape of the map it may test.
250
+ charge(20 + 4 * (4 + map.boxes.length + map.circles.length));
251
+ const fr = Math.fround;
252
+ // Both are whatever `move` handed over: each is read as a plain number or as nothing, and nothing is called to convert it.
253
+ const num = (a: unknown): number => { const x = typeof a === 'number' ? fr(a) : 0; return Number.isFinite(x) ? x : 0; };
254
+ if (body === null || typeof body !== 'object') throw new TypeError('ctx.map.sweep(body, delta) takes the body move was handed');
255
+ const pos = own(body, 'pos');
256
+ const dx = num(own(delta, 'x')); const dy = num(own(delta, 'y'));
257
+ const px = num(own(pos, 'x')); const py = num(own(pos, 'y'));
258
+ const { hit: h } = castMap(map, px, py, dx, dy, radius);
259
+ put(body, 'grounded', true);
260
+ if (!h) { put(body, 'pos', v(fr(px + dx), fr(py + dy), 0)); return undefined; }
261
+ const l = Math.sqrt(dx * dx + dy * dy);
262
+ const t = l > 0 ? Math.max(0, h.t - SKIN / l) : 0;
263
+ const at = v(fr(px + dx * t), fr(py + dy * t), 0);
264
+ put(body, 'pos', at);
265
+ return Object.freeze({ at, normal: v(h.nx, h.ny, 0) });
266
+ }
package/rules/pack.ts ADDED
@@ -0,0 +1,292 @@
1
+ /*
2
+ * pack.ts — declared state in and out: holding a value to its declared type, and packing it for the wire and the save.
3
+ * =============================================================================
4
+ *
5
+ * All of a rules game's state is in declared fields (rules.ts `f`). Three things follow from the declarations alone,
6
+ * and all three are here, so neither rules nor a view ever packs or unpacks anything:
7
+ *
8
+ * coerce a value held to its type as it is written: a whole number to its range, a vector to 32-bit floats, a
9
+ * text to its largest length, a list to its largest size. Server, browser and build check all write
10
+ * through it, so they hold the same numbers.
11
+ * pack a value as the frames carry it: positional, with no field names (the two ends share one build, so
12
+ * they share the declarations). The frames are today's JSON text frames (NETPLAY.md section 5), so the
13
+ * packed form is JSON arrays and numbers; a vector is `[x, y]` when `dims` is 2.
14
+ * unpack the reverse.
15
+ *
16
+ * The whole room packed into bytes (the save of a later slice, and the rebuilt-every-tick run of the build check) is
17
+ * `core.save()` through `toBytes` below: the same packing, with the runtime's own bookkeeping beside it.
18
+ * =============================================================================
19
+ */
20
+ import { cellsOf } from './rules.ts';
21
+ import type { Field, FieldList, Vec3 } from './rules.ts';
22
+ import { charge, keyCount, own, refusedName } from './guard.ts';
23
+
24
+ const RANGE: Record<string, [number, number]> = { u8: [0, 255], u16: [0, 65535], u32: [0, 4294967295], i8: [-128, 127], i16: [-32768, 32767], i32: [-2147483648, 2147483647], tick: [0, 4294967295], ticks: [0, 4294967295] };
25
+ /** `f.fix`: steps of 1/4096, to plus or minus 2^31 (a tick count with a fraction fits for the life of any room). */
26
+ export const FIX_STEP = 4096;
27
+ const FIX_MAX = 2147483648;
28
+ const fr = Math.fround;
29
+
30
+ /*
31
+ * THE BOUNDARY. Every value that leaves rules code (what a handler returns, what it passes to a `world` call, what it
32
+ * writes to a field, a list, `motion`, an event or an effect) comes through the functions below and nowhere else. They
33
+ * hold three promises, and the runtime leans on all three:
34
+ *
35
+ * no code of the rules runs. A value is read by `typeof`, `Array.isArray` and its own data properties (`own`). A
36
+ * number is never made with `Number(x)` nor a text with `String(x)` from an object, so
37
+ * no `valueOf`, `toString`, `toJSON` or `Symbol.toPrimitive` is called; a property that
38
+ * is a getter reads as absent; nothing is iterated, so no iterator runs. (A `Proxy`
39
+ * would still run its traps here. Rules cannot make one: the build refuses the name.)
40
+ * nothing throws. What is not a plain number, text, true or false, list or object of the declared shape
41
+ * becomes the zero of that shape. The one exception is the budget's own stop, while a
42
+ * handler is running (`charge`, below, for a map's keys).
43
+ * the work is bounded. A list is read to its declared size and no further; a struct by its declared keys. So
44
+ * the cost of holding a value to its shape is bounded by the declaration (`cellsOf`),
45
+ * whatever was handed in, and `est` says in a few steps how much that will be, so the
46
+ * caller charges the running handler before the work is done.
47
+ *
48
+ * What comes out is plain data, frozen: a list, a map and a struct are new frozen objects, a vector is a frozen
49
+ * `{ x, y, z }`. Stored state is therefore read-only everywhere, and a query result or an event can hand it out as it
50
+ * is, with no copy. A handler that changes a list in place is handed a copy to change (`thaw`), which is held to its
51
+ * shape again when the handler ends.
52
+ */
53
+
54
+ export { own };
55
+ /** A number from a plain number, true or false, or a short text. Anything else, an object above all, is NaN: nothing is called to convert it. */
56
+ export function num(v: unknown): number {
57
+ return typeof v === 'number' ? v : typeof v === 'boolean' ? (v ? 1 : 0) : typeof v === 'string' && v.length <= 40 ? Number(v) : NaN;
58
+ }
59
+ /** A value for a message, with nothing called to make it: a text as it is, anything else by its kind. */
60
+ export function said(v: unknown): string {
61
+ return typeof v === 'string' ? v.slice(0, 40) : typeof v === 'number' || typeof v === 'boolean' ? String(v) : v === null ? 'null' : Array.isArray(v) ? 'a list' : `a value of type ${typeof v}`;
62
+ }
63
+
64
+ /** Vectors and directions this file made: frozen and already 32-bit floats, so they pass through again untouched. */
65
+ const VECS = new WeakSet<object>();
66
+ const DIRS = new WeakSet<object>();
67
+ /** Lists, maps and structs this file made, each with the declaration it was held to: the same value under the same declaration is not read twice. */
68
+ const MADE = new WeakMap<object, Field>();
69
+ const INIT = new WeakMap<Field, { v: unknown }>();
70
+ const mk = (x: number, y: number, z: number): Vec3 => { const p = Object.freeze({ x, y, z }); VECS.add(p); return p; };
71
+ const f32 = (a: unknown): number => { const x = fr(num(a)); return Number.isFinite(x) ? x : 0; };
72
+
73
+ export const ZERO: Vec3 = mk(0, 0, 0);
74
+ export const AHEAD: Vec3 = mk(1, 0, 0);
75
+ DIRS.add(AHEAD);
76
+
77
+ /** A position or a velocity, as 32-bit floats; `z` is 0 when `dims` is 2. Anything that is not a number is 0. */
78
+ export function vec3(v: unknown, dims: number): Vec3 {
79
+ if (v === null || typeof v !== 'object') return ZERO;
80
+ if (VECS.has(v)) { const p = v as Vec3; return dims === 2 && p.z !== 0 ? mk(p.x, p.y, 0) : p; }
81
+ return mk(f32(own(v, 'x')), f32(own(v, 'y')), dims === 2 ? 0 : f32(own(v, 'z')));
82
+ }
83
+ /** A unit vector on the ground plane, as 32-bit floats. Too short to have a direction: +x. One that is already a unit vector is kept as it is, so a direction packs and unpacks to itself. */
84
+ export function dir(v: unknown, dims: number): Vec3 {
85
+ if (v !== null && typeof v === 'object' && DIRS.has(v) && (dims !== 2 || (v as Vec3).z === 0)) return v as Vec3;
86
+ const p = vec3(v, dims);
87
+ const l2 = p.x * p.x + p.y * p.y + p.z * p.z;
88
+ if (!(l2 > 1e-12)) return AHEAD;
89
+ if (Math.abs(l2 - 1) <= 1e-6) { DIRS.add(p); return p; }
90
+ const l = Math.sqrt(l2);
91
+ const d = mk(fr(p.x / l), fr(p.y / l), fr(p.z / l));
92
+ DIRS.add(d);
93
+ return d;
94
+ }
95
+
96
+ const made = <T extends object>(fd: Field, o: T): T => { Object.freeze(o); MADE.set(o, fd); return o; };
97
+
98
+ /** A value held to its declared type: plain frozen data, made without running any code of the rules. What does not fit becomes the nearest value that does. */
99
+ export function coerce(fd: Field, v: unknown, dims: number): unknown {
100
+ switch (fd.t) {
101
+ case 'bit': case 'press': return v === true || v === 1;
102
+ case 'fix': { const n = Math.round(num(v) * FIX_STEP) / FIX_STEP; return Number.isFinite(n) ? Math.max(-FIX_MAX, Math.min(FIX_MAX, n)) : 0; }
103
+ case 'vec3': return vec3(v, dims);
104
+ case 'dir': return dir(v, dims);
105
+ case 'ref': return typeof v === 'string' ? v.slice(0, 24) : '';
106
+ case 'text': return (typeof v === 'string' ? v : typeof v === 'number' || typeof v === 'boolean' ? String(v) : '').slice(0, fd.max);
107
+ case 'list': {
108
+ if (!Array.isArray(v)) return made(fd, []);
109
+ if (MADE.get(v) === fd) return v;
110
+ // An array's length is its own data property. No more than the declared size is read, however long the list is.
111
+ const n = Math.min(v.length, fd.max as number);
112
+ const out = new Array(n);
113
+ for (let i = 0; i < n; i += 1) out[i] = coerce(fd.of as Field, own(v, i), dims);
114
+ return made(fd, out);
115
+ }
116
+ case 'map': {
117
+ const out: Record<string, unknown> = Object.create(null);
118
+ if (v === null || typeof v !== 'object') return made(fd, out);
119
+ if (MADE.get(v) === fd) return v;
120
+ // The one cost here that the declaration does not bound: how many keys the value handed in has. The running handler
121
+ // pays for each: before they are listed when the count is known (a module's constant), after when the handler made
122
+ // the value itself (and so has already paid a unit and more for every key).
123
+ const known = Array.isArray(v) ? undefined : keyCount(v);
124
+ if (known !== undefined) charge(16 * known);
125
+ const keys = Object.keys(v);
126
+ if (known === undefined) charge(16 * keys.length);
127
+ let n = 0;
128
+ for (const key of keys) {
129
+ if (n >= (fd.max as number)) break;
130
+ if (key.length > 32 || refusedName(key)) continue;
131
+ out[key] = coerce(fd.of as Field, own(v, key), dims); n += 1;
132
+ }
133
+ return made(fd, out);
134
+ }
135
+ case 'struct': {
136
+ if (v !== null && typeof v === 'object' && MADE.get(v) === fd) return v;
137
+ const out: Record<string, unknown> = {};
138
+ for (const [key, sub] of Object.entries(fd.fields ?? {})) out[key] = coerce(sub, own(v, key), dims);
139
+ return made(fd, out);
140
+ }
141
+ default: {
142
+ const [lo, hi] = RANGE[fd.t];
143
+ const x = num(v);
144
+ const n = Math.round(x);
145
+ return Number.isFinite(n) ? Math.max(lo, Math.min(hi, n)) : x === Infinity ? hi : x === -Infinity ? lo : 0;
146
+ }
147
+ }
148
+ }
149
+ /**
150
+ * How many values holding `v` to its declaration will read, at most, found in a few steps and with no code of the
151
+ * rules run: a list by its length (never past its declared size) times its entries' declared size, a struct field by
152
+ * field, a map by its declaration. Never less than the work `coerce` then does, so a caller charges it first.
153
+ */
154
+ export function est(fd: Field, v: unknown): number {
155
+ switch (fd.t) {
156
+ case 'list': return Array.isArray(v) ? (MADE.get(v) === fd ? 1 : 1 + Math.min(v.length, fd.max as number) * cellsOf(fd.of as Field)) : 1;
157
+ case 'struct': {
158
+ if (v === null || typeof v !== 'object' || MADE.get(v) === fd) return 1;
159
+ let n = 1;
160
+ for (const [key, sub] of Object.entries(fd.fields ?? {})) n += est(sub, own(v, key));
161
+ return n;
162
+ }
163
+ case 'map': return v !== null && typeof v === 'object' && MADE.get(v) !== fd ? cellsOf(fd) : 1;
164
+ default: return 1;
165
+ }
166
+ }
167
+ /** `est` for a record of declared fields. */
168
+ export function estFields(list: FieldList, values: unknown): number {
169
+ if (values === null || typeof values !== 'object') return list.length;
170
+ let n = 0;
171
+ for (const [name, fd] of list) n += est(fd, own(values, name));
172
+ return n;
173
+ }
174
+ /** A copy a handler may change in place, of a value `coerce` made: lists, maps and structs are new and open, all the way down; everything else is shared. */
175
+ export function thaw(fd: Field, v: unknown): unknown {
176
+ switch (fd.t) {
177
+ case 'list': { const a = v as unknown[]; const out = new Array(a.length); for (let i = 0; i < a.length; i += 1) out[i] = thaw(fd.of as Field, a[i]); return out; }
178
+ case 'map': { const out: Record<string, unknown> = Object.create(null); for (const key of Object.keys(v as object)) out[key] = thaw(fd.of as Field, (v as Record<string, unknown>)[key]); return out; }
179
+ case 'struct': { const out: Record<string, unknown> = {}; for (const [key, sub] of Object.entries(fd.fields ?? {})) out[key] = thaw(sub, (v as Record<string, unknown>)[key]); return out; }
180
+ default: return v;
181
+ }
182
+ }
183
+ /** A record of declared fields as `coerce` made them, with every list, map and struct a copy that may be changed in place (a browser's own `motion`). */
184
+ export function thawFields(list: FieldList, values: Record<string, unknown>): Record<string, unknown> {
185
+ const out: Record<string, unknown> = {};
186
+ for (const [name, fd] of list) out[name] = thaw(fd, values[name]);
187
+ return out;
188
+ }
189
+ /** The value a field starts with: its `init`, else the zero of its type. Made once for each declaration, and shared: it is frozen. */
190
+ export function initOf(fd: Field, dims: number): unknown {
191
+ let hit = INIT.get(fd);
192
+ if (!hit) { hit = { v: coerce(fd, fd.init, dims) }; INIT.set(fd, hit); }
193
+ return hit.v;
194
+ }
195
+ /** Whether a field's value is changed in place (a list, a map, a struct), so a handler is handed a copy of it and the runtime holds the copy to its type again when the handler ends. */
196
+ export const mutable = (fd: Field): boolean => fd.t === 'list' || fd.t === 'map' || fd.t === 'struct';
197
+
198
+ /** How many values `pack` has packed since this was last set to 0: the core reads it to charge a tick for the state it sends. */
199
+ export const packed = { n: 0 };
200
+
201
+ export function pack(fd: Field, v: unknown, dims: number): unknown {
202
+ // One for the value, and for a text one more for every 64 characters: the same count `cellsOf` makes of a declaration.
203
+ packed.n += fd.t === 'text' ? 1 + ((v as string).length >> 6) : 1;
204
+ switch (fd.t) {
205
+ case 'bit': case 'press': return v ? 1 : 0;
206
+ case 'vec3': case 'dir': { const p = v as Vec3; return dims === 2 ? [p.x, p.y] : [p.x, p.y, p.z]; }
207
+ case 'list': return (v as unknown[]).map((x) => pack(fd.of as Field, x, dims));
208
+ case 'map': return Object.keys(v as object).sort().map((key) => [key, pack(fd.of as Field, (v as Record<string, unknown>)[key], dims)]);
209
+ case 'struct': return Object.entries(fd.fields ?? {}).map(([key, sub]) => pack(sub, (v as Record<string, unknown>)[key], dims));
210
+ default: return v;
211
+ }
212
+ }
213
+ export function unpack(fd: Field, w: unknown, dims: number): unknown {
214
+ switch (fd.t) {
215
+ case 'vec3': case 'dir': { const a = Array.isArray(w) ? w : []; return coerce(fd, { x: a[0], y: a[1], z: a[2] ?? 0 }, dims); }
216
+ case 'list': return coerce(fd, Array.isArray(w) ? w.map((x) => unpack(fd.of as Field, x, dims)) : [], dims);
217
+ case 'map': { const o: Record<string, unknown> = {}; for (const e of Array.isArray(w) ? w : []) if (Array.isArray(e) && typeof e[0] === 'string') o[e[0]] = unpack(fd.of as Field, e[1], dims); return coerce(fd, o, dims); }
218
+ case 'struct': { const a = Array.isArray(w) ? w : []; const o: Record<string, unknown> = {}; Object.entries(fd.fields ?? {}).forEach(([key, sub], i) => { o[key] = unpack(sub, a[i], dims); }); return coerce(fd, o, dims); }
219
+ default: return coerce(fd, w, dims);
220
+ }
221
+ }
222
+
223
+ /** A record of declared fields, packed in declaration order. */
224
+ export function packFields(list: FieldList, values: Record<string, unknown>, dims: number): unknown[] {
225
+ const out = new Array(list.length);
226
+ for (let i = 0; i < list.length; i += 1) out[i] = pack(list[i][1], values[list[i][0]], dims);
227
+ return out;
228
+ }
229
+ export function unpackFields(list: FieldList, w: unknown, dims: number): Record<string, unknown> {
230
+ const a = Array.isArray(w) ? w : [];
231
+ const out: Record<string, unknown> = {};
232
+ for (let i = 0; i < list.length; i += 1) out[list[i][0]] = unpack(list[i][1], a[i], dims);
233
+ return out;
234
+ }
235
+ /** A record of declared fields with every value at its `init`. */
236
+ export function initFields(list: FieldList, dims: number): Record<string, unknown> {
237
+ const out: Record<string, unknown> = {};
238
+ for (const [name, fd] of list) out[name] = initOf(fd, dims);
239
+ return out;
240
+ }
241
+ /** A record held to its declarations: every declared field coerced, anything undeclared dropped. The record handed in is read by its own data properties only. */
242
+ export function coerceFields(list: FieldList, values: unknown, dims: number): Record<string, unknown> {
243
+ const v = values !== null && typeof values === 'object' ? values : null;
244
+ const out: Record<string, unknown> = {};
245
+ for (const [name, fd] of list) { const x = v ? own(v, name) : undefined; out[name] = x === undefined ? initOf(fd, dims) : coerce(fd, x, dims); }
246
+ return out;
247
+ }
248
+
249
+ export const packVec = (p: Vec3, dims: number): number[] => (dims === 2 ? [p.x, p.y] : [p.x, p.y, p.z]);
250
+ export const unpackVec = (a: unknown, dims: number): Vec3 => { const w = Array.isArray(a) ? a : []; return vec3({ x: w[0], y: w[1], z: w[2] ?? 0 }, dims); };
251
+
252
+ /** A saved room as bytes, and back. The save is JSON text: every number in it is one a JSON number holds exactly. */
253
+ export function toBytes(state: unknown): Uint8Array { return new TextEncoder().encode(JSON.stringify(state)); }
254
+ export function fromBytes(bytes: Uint8Array): unknown { return JSON.parse(new TextDecoder().decode(bytes)); }
255
+
256
+ /** A digest of a packed state (FNV-1a over its JSON text), for comparing two runs tick by tick. */
257
+ export function digest(state: unknown): string {
258
+ const text = typeof state === 'string' ? state : JSON.stringify(state);
259
+ let h = 2166136261;
260
+ for (let i = 0; i < text.length; i += 1) h = Math.imul(h ^ text.charCodeAt(i), 16777619) >>> 0;
261
+ return h.toString(16).padStart(8, '0');
262
+ }
263
+
264
+ /* ------------------------------------------------------------------ one entity on the wire */
265
+
266
+ /** What packing an entity needs of its kind: the core's table and the view's schema both have it. */
267
+ export interface KindShape { name: string; player: unknown; fields: FieldList; motion: FieldList }
268
+ export const DRIVERS = Object.freeze(['person', 'bot', 'ai']);
269
+ /** An entity as every side holds it once unpacked. A player's body also has `seat`, `owner`, `driver` and `away`. */
270
+ export interface Unpacked {
271
+ id: string; kind: string; r: number; pos: Vec3; vel: Vec3; heading: Vec3; grounded: boolean;
272
+ seat?: number; owner?: string; driver?: string; away?: boolean; goal?: unknown;
273
+ fields: Record<string, unknown>; motion: Record<string, unknown>;
274
+ }
275
+
276
+ /** One entity as a snapshot carries it: `[id, kind, r, pos, vel, heading, grounded, fields, motion]`, and for a player's body `seat, driver, away, owner` after. */
277
+ export function packEntity(kind: KindShape & { index: number }, e: { id: string; r: number; pos: Vec3; vel: Vec3; heading: Vec3; grounded: boolean; seat: number; driver: string; away: boolean; owner: string; goal?: unknown; f: Record<string, unknown>; m: Record<string, unknown> }, dims: number): unknown[] {
278
+ const out: unknown[] = [e.id, kind.index, e.r, packVec(e.pos, dims), packVec(e.vel, dims), packVec(e.heading, dims), e.grounded ? 1 : 0, packFields(kind.fields, e.f, dims), packFields(kind.motion, e.m, dims)];
279
+ if (kind.player) out.push(e.seat, DRIVERS.indexOf(e.driver), e.away ? 1 : 0, e.owner, e.goal ?? null);
280
+ return out;
281
+ }
282
+ export function unpackEntity(kinds: readonly KindShape[], w: unknown, dims: number): Unpacked | null {
283
+ if (!Array.isArray(w)) return null;
284
+ const kind = kinds[Number(w[1])];
285
+ if (!kind || typeof w[0] !== 'string') return null;
286
+ const out: Unpacked = {
287
+ id: w[0], kind: kind.name, r: Number(w[2]) || 0, pos: unpackVec(w[3], dims), vel: unpackVec(w[4], dims), heading: unpackVec(w[5], dims), grounded: w[6] === 1,
288
+ fields: unpackFields(kind.fields, w[7], dims), motion: unpackFields(kind.motion, w[8], dims),
289
+ };
290
+ if (kind.player) { out.seat = Number(w[9]); out.driver = DRIVERS[Number(w[10])] ?? 'bot'; out.away = w[11] === 1; out.owner = typeof w[12] === 'string' ? w[12] : ''; out.goal = w[13] ?? null; }
291
+ return out;
292
+ }