@homie-rocks/studio 0.35.0 → 0.36.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 +37 -1
- package/agents/agents.ts +8 -6
- package/bin/homie-studio.mjs +9 -6
- package/lab/lab.ts +15 -0
- package/lib/build.mjs +20 -15
- package/lib/dev.mjs +16 -1
- package/lib/lab-check.mjs +18 -3
- package/lib/lab.mjs +21 -7
- package/lib/mcp-tools.mjs +2 -1
- package/lib/rules-build.mjs +97 -49
- package/lib/rules-check-worker.mjs +405 -0
- package/lib/rules-check.mjs +69 -0
- package/lib/rules-compiler.mjs +30 -0
- package/lib/rules-guard.mjs +26 -7
- package/lib/rules-types.mjs +167 -0
- package/lib/shop-catalog.mjs +20 -1
- package/lib/shop-links.mjs +3 -1
- package/lib/shop.mjs +14 -8
- package/lib/standalone-files.mjs +2 -2
- package/lib/standalone.mjs +6 -2
- package/lib/stripe-connect.mjs +18 -7
- package/lib/template-history.json +83 -0
- package/lib/typecheck.mjs +52 -3
- package/netplay/NETPLAY.md +123 -6
- package/netplay/netplay.ts +289 -53
- package/netplay/rules-output.mjs +85 -0
- package/package.json +3 -2
- package/rules/browser.ts +100 -0
- package/rules/core.ts +159 -58
- package/rules/guard.ts +11 -4
- package/rules/host.ts +90 -15
- package/rules/math.ts +16 -0
- package/rules/pack.ts +94 -17
- package/rules/rules.ts +21 -13
- package/rules/types.ts +63 -0
- package/rules/view.ts +36 -33
- package/standalone/shell/shell.js +8 -6
- package/worker/brain.mjs +2 -1
- package/worker/hosted.mjs +1 -1
- package/worker/index.mjs +7 -11
- package/worker/limits.mjs +41 -0
- package/worker/pages.mjs +4 -2
- package/worker/room.mjs +136 -63
- package/worker/stripe.mjs +2 -2
- package/worker/version.mjs +1 -1
package/rules/core.ts
CHANGED
|
@@ -42,8 +42,8 @@ import { argsWhy, type Vocabulary } from '../agents/agents.ts';
|
|
|
42
42
|
import { BudgetError, G, brand, charge, deepFreeze, plainData } from './guard.ts';
|
|
43
43
|
import { SKIN, castMap, exact, math, rayCircle, sweepMap } from './math.ts';
|
|
44
44
|
import type { Hit } from './math.ts';
|
|
45
|
-
import { AHEAD, ZERO, coerce, coerceFields, dir, est, estFields, initFields, mutable, num, own, packEntity, packFields, packVec, packed, said, thaw, unpackFields, unpackVec, vec3 } from './pack.ts';
|
|
46
|
-
import { ENTITY_MAX, QUEUE_MAX, REACH_M, cellsOf } from './rules.ts';
|
|
45
|
+
import { AHEAD, ZERO, coerce, coerceFields, dir, est, estFields, initFields, mutable, num, own, packEntity, packFields, packVec, packed, said, thaw, naming, unpackFields, unpackVec, vec3 } from './pack.ts';
|
|
46
|
+
import { ENTITY_MAX, QUEUE_MAX, REACH_M, SAVE_REVISION, cellsOf } from './rules.ts';
|
|
47
47
|
import type { Compiled, Field, FieldList, KindTable, Vec3 } from './rules.ts';
|
|
48
48
|
|
|
49
49
|
/*
|
|
@@ -63,6 +63,8 @@ export const SNAP = 4;
|
|
|
63
63
|
export const SEND = 16;
|
|
64
64
|
/** A handler that threw: what catching it and writing it down costs the tick. */
|
|
65
65
|
export const THROWN = 512;
|
|
66
|
+
/** The most effects (`world.emit`) one tick takes. */
|
|
67
|
+
export const FX_MAX = 256;
|
|
66
68
|
/** The most area events (`world.sendArea`) one tick takes. */
|
|
67
69
|
export const AREAS_MAX = 64;
|
|
68
70
|
/** What the log names when a room ends because of what it holds, not because of a handler. */
|
|
@@ -107,7 +109,7 @@ interface Seat { seat: number; driver: Driver; owner: string; id: string | null;
|
|
|
107
109
|
interface Ctx { scope: 'none' | 'ent' | 'room' | 'join' | 'move'; ent: Ent | null }
|
|
108
110
|
const NO_SPOTS: readonly Vec3[] = Object.freeze([]);
|
|
109
111
|
/** A list, map or struct a handler was handed a copy of: where it is stored, and the copy to hold to its shape when the handler ends. */
|
|
110
|
-
interface Touched { bag: Record<string, unknown>; name: string; fd: Field; work: Record<string, unknown>; shared: boolean }
|
|
112
|
+
interface Touched { bag: Record<string, unknown>; name: string; fd: Field; work: Record<string, unknown>; shared: boolean; held: (fd: Field, v: unknown, name: string) => unknown }
|
|
111
113
|
|
|
112
114
|
/** How many values a stored list, map or struct holds now: what copying it for a handler costs. */
|
|
113
115
|
function sizeOf(fd: Field, v: unknown): number {
|
|
@@ -127,11 +129,11 @@ function errorText(error: unknown): string {
|
|
|
127
129
|
|
|
128
130
|
/** Everything a room is, as plain JSON (pack.ts `toBytes` makes it bytes). The shape is this runtime's own and may change between releases. */
|
|
129
131
|
export interface SavedCore {
|
|
130
|
-
v:
|
|
132
|
+
v: number; tick: number; epoch: number; rng: number; nextId: number; seq: number;
|
|
131
133
|
round: [number, number, number, number]; overAt: number; match: [number, number]; trips: number;
|
|
132
|
-
shared: unknown[]; policy: CorePolicy;
|
|
133
|
-
asks
|
|
134
|
-
guideViews
|
|
134
|
+
shared: unknown[]; policy: CorePolicy; intent?: [number, number];
|
|
135
|
+
asks: PendingAsk[];
|
|
136
|
+
guideViews: [number, Record<string, unknown>][];
|
|
135
137
|
ents: unknown[][]; spawns: unknown[][]; seats: [number, string, string, string | null, number][]; queue: unknown[][]; areas: unknown[][]; ops: unknown[];
|
|
136
138
|
}
|
|
137
139
|
|
|
@@ -151,6 +153,8 @@ export interface Core {
|
|
|
151
153
|
/** The room's public state, packed: `[[n, phase, endsAt], entities]`. */
|
|
152
154
|
snapshot(): unknown[];
|
|
153
155
|
shared(): unknown[];
|
|
156
|
+
/** The current round, for a host rebasing wall-clock deadlines after a pause or restore. */
|
|
157
|
+
round(): Extract<CoreOut, { t: 'round' }>;
|
|
154
158
|
bodies(): Body[];
|
|
155
159
|
/** The body in a seat, as the host runtime needs it for the input protocol. */
|
|
156
160
|
bodyOf(seat: number): { id: string; kind: KindTable; r: number; away: boolean; driver: Driver } | null;
|
|
@@ -161,12 +165,19 @@ export interface Core {
|
|
|
161
165
|
readonly world: object;
|
|
162
166
|
/**
|
|
163
167
|
* `failing`: the handler the budget last stopped. `lost`: area events dropped because the room's queue was full.
|
|
168
|
+
* `held`: owner-moved claims cut short because they came further than `body.maxSpeed` allows.
|
|
164
169
|
* `tickUnits`: what the last tick used, the state it sends included.
|
|
165
170
|
*/
|
|
166
|
-
stats: { handlers: number; errors: number; budgetStops: number; skipped: number; ticksCut: number; maxUnits: number; worst: string; lastError: string; failing: string; lost: number; tickUnits: number; maxTickUnits: number };
|
|
171
|
+
stats: { handlers: number; errors: number; budgetStops: number; skipped: number; ticksCut: number; maxUnits: number; worst: string; lastError: string; failing: string; lost: number; held: number; tickUnits: number; maxTickUnits: number };
|
|
167
172
|
}
|
|
168
173
|
|
|
169
|
-
|
|
174
|
+
/**
|
|
175
|
+
* `observe` and `noted` are the build check's; a room passes neither, and then nothing here costs or changes anything.
|
|
176
|
+
* `observe`: told of every handler as it ends, and what it threw if it threw.
|
|
177
|
+
* `noted`: told, as it happens, of a value the rules wrote that the runtime changed to make it fit, or dropped (pack.ts
|
|
178
|
+
* `Adjusted`; also `effect`, `think` and `decision`): the handler, what was done, the field's name and what was written.
|
|
179
|
+
*/
|
|
180
|
+
export function createCore(c: Compiled, opts: { 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 {
|
|
170
181
|
if (c.dims !== 2) throw new Error('this release runs rules with space.dims: 2; bodies with height (dims: 3) arrive in a later one');
|
|
171
182
|
const dims = c.dims;
|
|
172
183
|
const tickHz = c.settings.tickHz;
|
|
@@ -176,6 +187,7 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
|
|
|
176
187
|
/** What `move`, `think`, `join` and `start` are given each once the tick's budget is gone: all of them together, in a full room, use half a budget more. */
|
|
177
188
|
const floor = Math.max(1, Math.floor(c.settings.budget.tick / (4 * ENTITY_MAX)));
|
|
178
189
|
const stage = String(opts.stage ?? '');
|
|
190
|
+
const noted = opts.noted ?? null;
|
|
179
191
|
|
|
180
192
|
let tick = 0;
|
|
181
193
|
let pendingAsks: PendingAsk[] = [];
|
|
@@ -203,7 +215,8 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
|
|
|
203
215
|
let policy: CorePolicy = { reserved: 0, bots: 'fill', level: 3, levelMax: 5 };
|
|
204
216
|
function normalizeCorePolicy(p: Partial<CorePolicy>): CorePolicy {
|
|
205
217
|
const levelMax = Math.max(1, Math.min(5, Math.floor(num(own(p, 'levelMax') ?? policy.levelMax)) || 5));
|
|
206
|
-
|
|
218
|
+
// The keys in the order the room starts with them: a save is compared byte for byte with the save of the room rebuilt from it.
|
|
219
|
+
return { reserved: Math.max(0, Math.min(c.seats - 1, Math.floor(num(own(p, 'reserved') ?? policy.reserved)) || 0)), bots: p.bots === 'off' ? 'off' : p.bots === 'fill' ? 'fill' : policy.bots, level: Math.max(1, Math.min(levelMax, Math.floor(num(own(p, 'level') ?? policy.level)) || 3)), levelMax };
|
|
207
220
|
}
|
|
208
221
|
let shared: Record<string, unknown> = initFields(c.shared, dims);
|
|
209
222
|
let sharedRO: Readonly<Record<string, unknown>> = Object.freeze({});
|
|
@@ -219,7 +232,7 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
|
|
|
219
232
|
let trips = 0;
|
|
220
233
|
let cut = false;
|
|
221
234
|
let cx: Ctx = { scope: 'none', ent: null };
|
|
222
|
-
const stats = { handlers: 0, errors: 0, budgetStops: 0, skipped: 0, ticksCut: 0, maxUnits: 0, worst: '', lastError: '', failing: '', lost: 0, tickUnits: 0, maxTickUnits: 0 };
|
|
235
|
+
const stats = { handlers: 0, errors: 0, budgetStops: 0, skipped: 0, ticksCut: 0, maxUnits: 0, worst: '', lastError: '', failing: '', lost: 0, held: 0, tickUnits: 0, maxTickUnits: 0 };
|
|
223
236
|
/** The lists, maps and structs the running handler was handed copies of. */
|
|
224
237
|
let touched: Touched[] = [];
|
|
225
238
|
/** Events set aside while phase 3 delivers, so the queue's cap counts them. */
|
|
@@ -242,12 +255,14 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
|
|
|
242
255
|
* first read in a handler makes a copy the handler may change in place, charged by what the stored value holds, and
|
|
243
256
|
* `settle` holds that copy to its declaration when the handler ends.
|
|
244
257
|
*/
|
|
245
|
-
function typed(bag: Record<string, unknown>, list: FieldList, target: object, isShared = false): void {
|
|
258
|
+
function typed(bag: Record<string, unknown>, list: FieldList, target: object, where: string, isShared = false): void {
|
|
246
259
|
const work: Record<string, unknown> = {};
|
|
260
|
+
/** The value as the field holds it. For a listener the field is named first (`runner.fields`, `score`); a room has none. */
|
|
261
|
+
const held = (fd: Field, v: unknown, name: string): unknown => { if (G.note === null) return coerce(fd, v, dims); naming(where, name); const out = coerce(fd, v, dims); naming(); return out; };
|
|
247
262
|
for (const [name, fd] of list) {
|
|
248
263
|
if (!mutable(fd)) {
|
|
249
264
|
const cost = cellsOf(fd) === 1 ? SET : COPY * cellsOf(fd);
|
|
250
|
-
Object.defineProperty(target, name, { get: () => bag[name], set: (v) => { charge(cost); bag[name] =
|
|
265
|
+
Object.defineProperty(target, name, { get: () => bag[name], set: (v) => { charge(cost); bag[name] = held(fd, v, name); if (isShared) sharedChanged(); }, enumerable: true });
|
|
251
266
|
continue;
|
|
252
267
|
}
|
|
253
268
|
Object.defineProperty(target, name, {
|
|
@@ -256,11 +271,11 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
|
|
|
256
271
|
if (w === undefined) {
|
|
257
272
|
charge(COPY * sizeOf(fd, bag[name]));
|
|
258
273
|
w = work[name] = thaw(fd, bag[name]);
|
|
259
|
-
touched.push({ bag, name, fd, work, shared: isShared });
|
|
274
|
+
touched.push({ bag, name, fd, work, shared: isShared, held });
|
|
260
275
|
}
|
|
261
276
|
return w;
|
|
262
277
|
},
|
|
263
|
-
set: (v) => { charge(COPY * est(fd, v)); bag[name] =
|
|
278
|
+
set: (v) => { charge(COPY * est(fd, v)); bag[name] = held(fd, v, name); work[name] = undefined; if (isShared) sharedChanged(); },
|
|
264
279
|
enumerable: true,
|
|
265
280
|
});
|
|
266
281
|
}
|
|
@@ -281,7 +296,9 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
|
|
|
281
296
|
const w = p.work[p.name];
|
|
282
297
|
if (w === undefined) continue; // assigned whole after it was read: the assignment stored it
|
|
283
298
|
charge(COPY * est(p.fd, w));
|
|
284
|
-
|
|
299
|
+
// Changed in place, so held to its type only now: a listener is told so, since the line is no longer the write's.
|
|
300
|
+
if (G.note === null) p.bag[p.name] = coerce(p.fd, w, dims);
|
|
301
|
+
else { const told = G.note; G.note = (what, at, written) => told(`${what} in place`, at, written); try { p.bag[p.name] = p.held(p.fd, w, p.name); } finally { G.note = told; } }
|
|
285
302
|
p.work[p.name] = undefined;
|
|
286
303
|
if (p.shared) sharedChanged();
|
|
287
304
|
}
|
|
@@ -292,16 +309,16 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
|
|
|
292
309
|
function makeSelf(e: Ent): void {
|
|
293
310
|
const ro = (get: () => unknown): PropertyDescriptor => ({ get, enumerable: true });
|
|
294
311
|
const mself = {};
|
|
295
|
-
typed(e.m, e.kind.motion, mself);
|
|
312
|
+
typed(e.m, e.kind.motion, mself, `${e.kind.name}.motion`);
|
|
296
313
|
Object.preventExtensions(mself);
|
|
297
314
|
const self = {};
|
|
298
315
|
Object.defineProperties(self, {
|
|
299
316
|
id: ro(() => e.id), kind: ro(() => e.kind.name), pos: ro(() => e.pos), grounded: ro(() => e.grounded), motion: ro(() => mself), input: ro(() => e.input),
|
|
300
|
-
vel: { get: () => e.vel, set: (v) => { charge(4 * COPY); e.vel = V(v); }, enumerable: true },
|
|
301
|
-
heading: { get: () => e.heading, set: (v) => { charge(4 * COPY); e.heading = dir(v, dims); }, enumerable: true },
|
|
317
|
+
vel: { get: () => e.vel, set: (v) => { charge(4 * COPY); naming(e.kind.name, 'vel'); e.vel = V(v); naming(); }, enumerable: true },
|
|
318
|
+
heading: { get: () => e.heading, set: (v) => { charge(4 * COPY); naming(e.kind.name, 'heading'); e.heading = dir(v, dims); naming(); }, enumerable: true },
|
|
302
319
|
});
|
|
303
320
|
if (e.kind.player) Object.defineProperties(self, { seat: ro(() => e.seat), owner: ro(() => e.owner), driver: ro(() => e.driver), away: ro(() => e.away), goal: ro(() => e.goal) });
|
|
304
|
-
typed(e.f, e.kind.fields, self);
|
|
321
|
+
typed(e.f, e.kind.fields, self, `${e.kind.name}.fields`);
|
|
305
322
|
Object.preventExtensions(self);
|
|
306
323
|
e.self = self; e.mself = mself;
|
|
307
324
|
}
|
|
@@ -322,10 +339,12 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
|
|
|
322
339
|
function makeEnt(kind: KindTable, at: unknown, why: 'join' | 'spawn', init: { fields?: unknown; motion?: unknown; heading?: unknown } = {}, player: SeatInfo | null = null): Ent {
|
|
323
340
|
if (ents.size + spawns.length >= ENTITY_MAX) throw new Error(`a room holds at most ${ENTITY_MAX} entities`);
|
|
324
341
|
const n = nextId; nextId += 1;
|
|
342
|
+
naming(kind.name, 'fields'); const f = coerceFields(kind.fields, init.fields, dims);
|
|
343
|
+
naming(kind.name, 'motion'); const m = coerceFields(kind.motion, init.motion, dims); naming();
|
|
325
344
|
const e: Ent = {
|
|
326
345
|
id: `e${n.toString(36)}`, n, kind, pos: clampIn(V(at), kind.body?.radius ?? 0), vel: ZERO, heading: init.heading === undefined ? AHEAD : dir(init.heading, dims), grounded: true,
|
|
327
346
|
r: 0, born: tick, arrived: false, why, dead: false,
|
|
328
|
-
f
|
|
347
|
+
f, m,
|
|
329
348
|
seat: player ? player.seat : -1, owner: player ? player.owner : '', driver: player ? player.driver : 'bot', away: false, goal: null,
|
|
330
349
|
input: Object.freeze(initFields(kind.input, dims)), allow: 0, cmds: [], self: {}, mself: {},
|
|
331
350
|
};
|
|
@@ -366,8 +385,10 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
|
|
|
366
385
|
const limit = E.stackTraceLimit;
|
|
367
386
|
E.stackTraceLimit = 0;
|
|
368
387
|
G.left = quota;
|
|
388
|
+
// The build check is told of every written value that had to be changed to fit (pack.ts), with the handler it was in.
|
|
389
|
+
if (noted) G.note = (what, at, written) => noted(kind, handler, what, at, written);
|
|
390
|
+
let bad = false; let thrown: unknown;
|
|
369
391
|
try {
|
|
370
|
-
let bad = false; let thrown: unknown;
|
|
371
392
|
try { fn(); } catch (error) { bad = true; thrown = error; }
|
|
372
393
|
// Settled whether or not the handler threw: what it changed in place before it stopped is kept, when it can still pay for it.
|
|
373
394
|
try { settle(); } catch (error) { if (!bad) { bad = true; thrown = error; } }
|
|
@@ -375,6 +396,7 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
|
|
|
375
396
|
} finally {
|
|
376
397
|
const used = quota - (G.left > 0 ? G.left : 0);
|
|
377
398
|
G.left = Infinity;
|
|
399
|
+
G.note = null;
|
|
378
400
|
E.stackTraceLimit = limit;
|
|
379
401
|
cx = before;
|
|
380
402
|
touched = [];
|
|
@@ -382,6 +404,7 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
|
|
|
382
404
|
stats.handlers += 1;
|
|
383
405
|
if (used > stats.maxUnits) { stats.maxUnits = used; stats.worst = `${kind}.${handler}`; }
|
|
384
406
|
}
|
|
407
|
+
opts.observe?.(kind, handler, bad ? errorText(thrown) : undefined);
|
|
385
408
|
return true;
|
|
386
409
|
}
|
|
387
410
|
let failing: { kind: string; handler: string } = { kind: '', handler: '' };
|
|
@@ -409,7 +432,8 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
|
|
|
409
432
|
const shape = typeof name === 'string' && Object.hasOwn(table, name) ? table[name] : null;
|
|
410
433
|
if (!shape) throw new Error(`${what} "${said(name)}" is not declared in shapes`);
|
|
411
434
|
charge(COPY * estFields(shape, data));
|
|
412
|
-
|
|
435
|
+
naming(name as string); const held = Object.freeze(coerceFields(shape, data, dims)); naming();
|
|
436
|
+
return held;
|
|
413
437
|
};
|
|
414
438
|
const sender = (): { from: number; fromId: string } => (cx.ent ? { from: cx.ent.n, fromId: cx.ent.id } : { from: 0, fromId: '' });
|
|
415
439
|
/** One event or timer onto the queue. A handler's send past the room's cap throws in that handler; the runtime's own (`own`: a round turning, an answer) is always taken. */
|
|
@@ -443,7 +467,7 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
|
|
|
443
467
|
const sharedBag: Record<string, unknown> = {};
|
|
444
468
|
const sharedRW = {};
|
|
445
469
|
for (const [name] of c.shared) Object.defineProperty(sharedBag, name, { get: () => shared[name], set: (v) => { shared[name] = v; }, enumerable: true });
|
|
446
|
-
typed(sharedBag, c.shared, sharedRW, true);
|
|
470
|
+
typed(sharedBag, c.shared, sharedRW, 'shared', true);
|
|
447
471
|
Object.preventExtensions(sharedRW);
|
|
448
472
|
const sharedView = (): Readonly<Record<string, unknown>> => {
|
|
449
473
|
if (cx.scope === 'room') return sharedRW;
|
|
@@ -509,7 +533,7 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
|
|
|
509
533
|
if (cx.scope !== 'ent' && cx.scope !== 'room') throw new Error('world.emit is for handlers');
|
|
510
534
|
const i = typeof effect === 'string' ? c.effectNames.indexOf(effect) : -1;
|
|
511
535
|
if (i < 0) throw new Error(`the effect "${said(effect)}" is not declared in shapes.effects`);
|
|
512
|
-
if (fx.length >=
|
|
536
|
+
if (fx.length >= FX_MAX) { G.note?.('effect', '', said(effect)); return; }
|
|
513
537
|
const shape = c.effects[c.effectNames[i]];
|
|
514
538
|
fx.push([i, typeof at === 'string' ? at.slice(0, 24) : packVec(V(at), dims), packFields(shape, shapeData(c.effects, effect, 'the effect', data), dims)]);
|
|
515
539
|
},
|
|
@@ -611,7 +635,7 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
|
|
|
611
635
|
const who = cx.ent ? cx.ent.id : '';
|
|
612
636
|
if (pendingAsks.some((q) => q.name === name) || queue.some((q) => q.ev === 'answer' && (q.data as { ask?: string })?.ask === name)) return false;
|
|
613
637
|
charge(COPY * estFields(a.stateFields, state));
|
|
614
|
-
const value = deepFreeze(coerceFields(a.stateFields, state, dims));
|
|
638
|
+
naming('asks', name as string, 'state'); const value = deepFreeze(coerceFields(a.stateFields, state, dims)); naming();
|
|
615
639
|
if (JSON.stringify(value).length > 2048) return false;
|
|
616
640
|
const n = `a${epoch.toString(36)}-${(++seq).toString(36)}`;
|
|
617
641
|
pendingAsks.push({ n, name: name as string, who, at: tick, state: value });
|
|
@@ -639,6 +663,14 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
|
|
|
639
663
|
const fn = c.roomOn[ev];
|
|
640
664
|
if (fn) run('room', `on.${ev}`, null, 'room', () => fn(world, data));
|
|
641
665
|
};
|
|
666
|
+
function validPicks(questions: Record<string, unknown>, value: unknown): boolean {
|
|
667
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value) && Object.entries(questions).every(([id, raw]) => {
|
|
668
|
+
const q = raw as any; const v = own(value, id);
|
|
669
|
+
if (q.type === 'noul' || q.type === 'yes-no') return typeof v === 'boolean';
|
|
670
|
+
if (q.type === 'score') return typeof v === 'number' && Number.isInteger(v) && v >= 0 && v < q.criteria.length;
|
|
671
|
+
return typeof v === 'string' && (Array.isArray(q.criteria) ? q.criteria.includes(v) : Object.hasOwn(q.criteria, v));
|
|
672
|
+
}));
|
|
673
|
+
}
|
|
642
674
|
function deliver(item: Item): boolean {
|
|
643
675
|
if (item.kind === 'room') {
|
|
644
676
|
const fn = c.roomOn[item.ev];
|
|
@@ -897,15 +929,19 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
|
|
|
897
929
|
run(e?.kind.name ?? 'room', `asks.${ask.name}.floor`, e ?? null, 'none', () => {
|
|
898
930
|
charge(2 * ANSWER_MAX);
|
|
899
931
|
const r = ask.result;
|
|
900
|
-
const valid = (p:
|
|
901
|
-
const q = raw as any; const v = p[id];
|
|
902
|
-
if (q.type === 'noul' || q.type === 'yes-no') return typeof v === 'boolean';
|
|
903
|
-
if (q.type === 'score') return typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= q.criteria.length - 1;
|
|
904
|
-
return typeof v === 'string' && (Array.isArray(q.criteria) ? q.criteria.includes(v) : Object.hasOwn(q.criteria, v));
|
|
905
|
-
}));
|
|
932
|
+
const valid = (p: unknown): boolean => validPicks(a.questions, p);
|
|
906
933
|
const ai = r?.ok === true && valid(r.picks);
|
|
907
934
|
const picks = plainData(ai ? r.picks : a.floor(ask.state), { n: ANSWER_MAX });
|
|
908
|
-
if (!valid(picks))
|
|
935
|
+
if (!valid(picks)) {
|
|
936
|
+
const details = Object.entries(a.questions).map(([id, raw]) => {
|
|
937
|
+
const q = raw as any; const value = (picks as any)?.[id];
|
|
938
|
+
const allowed = q.type === 'score' ? `an integer from 0 through ${q.criteria.length - 1}`
|
|
939
|
+
: q.type === 'noul' || q.type === 'yes-no' ? 'true or false'
|
|
940
|
+
: `one of ${JSON.stringify(Array.isArray(q.criteria) ? q.criteria : Object.keys(q.criteria))}`;
|
|
941
|
+
return `${id} received ${JSON.stringify(value) ?? 'missing'}; expected ${allowed}`;
|
|
942
|
+
}).join('; ');
|
|
943
|
+
throw new Error(`asks.${ask.name}.floor must answer every declared question: ${details}`);
|
|
944
|
+
}
|
|
909
945
|
push({ due: tick, to: ask.who, kind: e ? 'ev' : 'room', ev: 'answer', data: deepFreeze({ ask: ask.name, by: ai ? (r.by === 'local' ? 'local' : 'ai') : 'floor', picks, ...(!ai ? { why: r?.ok === true ? 'bad' : typeof r?.why === 'string' && /^[a-z][a-z0-9-]{0,31}$/.test(r.why) ? r.why : (opts.decisions ? 'slow' : 'off') } : {}) }), at: tick, builtIn: true }, true);
|
|
910
946
|
answered = true;
|
|
911
947
|
});
|
|
@@ -936,8 +972,13 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
|
|
|
936
972
|
let stepIn: Record<string, unknown> | null = null;
|
|
937
973
|
run(k.name, 'think', e, 'ent', () => {
|
|
938
974
|
const back: unknown = (k.think as NonNullable<KindTable['think']>)(world, e.self);
|
|
975
|
+
// What is not an input of this kind is not read, and the input is neutral: a room says nothing of it, a listener is told.
|
|
976
|
+
if (G.note !== null) {
|
|
977
|
+
if (!back || typeof back !== 'object' || Array.isArray(back)) G.note('think', '', said(back));
|
|
978
|
+
else for (const key of Object.keys(back)) if (!k.input.some(([name]) => name === key)) G.note('think', key, said((back as Record<string, unknown>)[key]));
|
|
979
|
+
}
|
|
939
980
|
charge(COPY * k.input.length);
|
|
940
|
-
stepIn = coerceFields(k.input, back, dims);
|
|
981
|
+
naming('input'); stepIn = coerceFields(k.input, back, dims); naming();
|
|
941
982
|
}, 'body');
|
|
942
983
|
e.input = Object.freeze(stepIn ?? initFields(k.input, dims));
|
|
943
984
|
} else e.input = Object.freeze(initFields(k.input, dims));
|
|
@@ -948,6 +989,8 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
|
|
|
948
989
|
const dx = claim.pos.x - e.pos.x; const dy = claim.pos.y - e.pos.y;
|
|
949
990
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
950
991
|
const go = Math.min(dist, e.allow);
|
|
992
|
+
// The browser ran the game's own `move` and came further than `maxSpeed` allows: the body is held back.
|
|
993
|
+
if (go < dist) stats.held += 1;
|
|
951
994
|
e.allow -= go;
|
|
952
995
|
if (dist > 0) e.pos = clampIn(V({ x: e.pos.x + (dx / dist) * go, y: e.pos.y + (dy / dist) * go, z: 0 }), body.radius);
|
|
953
996
|
const sp = Math.sqrt(claim.vel.x * claim.vel.x + claim.vel.y * claim.vel.y);
|
|
@@ -963,7 +1006,11 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
|
|
|
963
1006
|
run(k.name, 'move', e, 'move', () => (k.move as NonNullable<KindTable['move']>)(b, e.input, moveCtx), 'body');
|
|
964
1007
|
// The runtime rounds to 32-bit floats, here and in the browser, so both step from exactly the same numbers.
|
|
965
1008
|
// `b` is the runtime's own object: what `move` left in it is read by its own data properties and cannot throw.
|
|
966
|
-
|
|
1009
|
+
// (A listener hears of a position that was not a number here too, as `move`'s.)
|
|
1010
|
+
if (noted) G.note = (what, at, written) => noted(k.name, 'move', what, at, written);
|
|
1011
|
+
naming('body', 'pos'); e.pos = clampIn(V(own(b, 'pos')), body.radius); naming('body', 'vel'); e.vel = V(own(b, 'vel')); naming('body', 'heading'); e.heading = dir(own(b, 'heading'), dims); naming();
|
|
1012
|
+
G.note = null;
|
|
1013
|
+
e.grounded = own(b, 'grounded') === true;
|
|
967
1014
|
}
|
|
968
1015
|
// Phase 2: for every entity, its commands and then its tick, starting from a different entity each tick.
|
|
969
1016
|
const list = [...ents.values()];
|
|
@@ -1028,7 +1075,7 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
|
|
|
1028
1075
|
const e: Ent = {
|
|
1029
1076
|
id: w[0], n: w[1], kind, r: w[3], born: w[4], arrived: w[5] === 1, why: w[6], dead: false, pos: unpackVec(w[7], 3), vel: unpackVec(w[8], 3), heading: unpackVec(w[9], 3), grounded: w[10] === 1,
|
|
1030
1077
|
seat: w[11], driver: w[12], owner: w[13], away: w[14] === 1, goal: null, allow: w[16],
|
|
1031
|
-
f: unpackFields(kind.fields, w[17], dims), m: unpackFields(kind.motion, w[18], dims), input: Object.freeze(unpackFields(kind.input, w[19], dims)), cmds: (w[20] as { name: string; data: unknown }[]).map((x) => ({ name: x.name, data: deepFreeze(x.data) })), self: {}, mself: {},
|
|
1078
|
+
f: unpackFields(kind.fields, w[17], dims), m: unpackFields(kind.motion, w[18], dims), input: Object.freeze(unpackFields(kind.input, w[19], dims)), cmds: (w[20] as { name: string; data: unknown }[]).map((x) => ({ name: x.name, data: deepFreeze(coerceFields(c.commands[x.name] ?? [], x.data, dims)) })), self: {}, mself: {},
|
|
1032
1079
|
};
|
|
1033
1080
|
if (kind.player) restoredGoals.set(e.seat, w[15]);
|
|
1034
1081
|
makeSelf(e);
|
|
@@ -1036,8 +1083,8 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
|
|
|
1036
1083
|
}
|
|
1037
1084
|
function save(): SavedCore {
|
|
1038
1085
|
return {
|
|
1039
|
-
v:
|
|
1040
|
-
shared: packFields(c.shared, shared, dims), policy: { ...policy }, asks: pendingAsks, guideViews: [...guideViews],
|
|
1086
|
+
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,
|
|
1087
|
+
shared: packFields(c.shared, shared, dims), policy: { ...policy }, intent: [endAsked ? 1 : 0, finishing ? 1 : 0], asks: pendingAsks, guideViews: [...guideViews],
|
|
1041
1088
|
ents: [...ents.values()].map(saveEnt), spawns: spawns.map(saveEnt),
|
|
1042
1089
|
seats: [...seats.values()].map((s) => [s.seat, s.driver, s.owner, s.id, s.away ? 1 : 0]),
|
|
1043
1090
|
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]),
|
|
@@ -1048,22 +1095,43 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
|
|
|
1048
1095
|
// A persisted value is untrusted too. Reject structural damage instead of silently making a different match.
|
|
1049
1096
|
function validateSave(r: SavedCore): void {
|
|
1050
1097
|
const check = (ok: unknown): void => { if (!ok) throw new Error('saved rules state is invalid'); };
|
|
1051
|
-
const uint = (v: unknown): boolean => Number.isSafeInteger(v) && (v as number) >= 0;
|
|
1098
|
+
const uint = (v: unknown): boolean => Number.isSafeInteger(v) && (v as number) >= 0 && (v as number) < 2 ** 40;
|
|
1052
1099
|
const bit = (v: unknown): boolean => v === 0 || v === 1;
|
|
1053
1100
|
const fields = (list: FieldList, value: unknown): void => {
|
|
1054
1101
|
check(Array.isArray(value) && JSON.stringify(value) === JSON.stringify(packFields(list, unpackFields(list, value, dims), dims)));
|
|
1055
1102
|
};
|
|
1056
|
-
check(r.v ===
|
|
1103
|
+
check(r.v === SAVE_REVISION);
|
|
1104
|
+
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'].includes(k)));
|
|
1105
|
+
const askNames = new Set();
|
|
1106
|
+
check(Array.isArray(r.asks));
|
|
1107
|
+
for (const a of r.asks) {
|
|
1108
|
+
check(a && typeof a.name === 'string' && Object.hasOwn(c.asks, a.name) && !askNames.has(a.name)); askNames.add(a.name);
|
|
1109
|
+
check(Object.keys(a).every(k => ['n', 'name', 'who', 'at', 'state', 'result'].includes(k)));
|
|
1110
|
+
check(typeof a.n === 'string' && a.n.length <= 128 && typeof a.who === 'string' && a.who.length <= 128 && uint(a.at) && a.at <= r.tick);
|
|
1111
|
+
check(JSON.stringify(a.state) === JSON.stringify(coerceFields(c.asks[a.name].stateFields, a.state, dims)));
|
|
1112
|
+
if (a.result !== undefined) check(JSON.stringify(a.result) === JSON.stringify(decisionResult(a.result)));
|
|
1113
|
+
}
|
|
1114
|
+
check(Array.isArray(r.guideViews) && r.guideViews.length <= c.seats);
|
|
1115
|
+
const views = new Set();
|
|
1116
|
+
for (const row of r.guideViews) {
|
|
1117
|
+
check(Array.isArray(row) && row.length === 2 && uint(row[0]) && row[0] < c.seats && !views.has(row[0])); views.add(row[0]);
|
|
1118
|
+
check(JSON.stringify(row[1]) === JSON.stringify(coerceFields(c.view, row[1], dims)));
|
|
1119
|
+
}
|
|
1120
|
+
check(r.intent === undefined || Array.isArray(r.intent) && r.intent.length === 2 && r.intent.every(bit));
|
|
1057
1121
|
for (const v of [r.tick, r.epoch, r.rng, r.nextId, r.seq, r.overAt, r.trips]) check(uint(v));
|
|
1122
|
+
check(r.tick < 0xffffffff);
|
|
1058
1123
|
check(r.rng <= 4294967295 && r.epoch > 0 && r.nextId > 0 && r.seq > 0);
|
|
1059
1124
|
check(Array.isArray(r.round) && r.round.length === 4 && r.round.every(uint) && bit(r.round[1]));
|
|
1060
|
-
check(Array.isArray(r.match) && r.match.length === 2 && r.match.every(uint));
|
|
1125
|
+
check(Array.isArray(r.match) && r.match.length === 2 && r.match.every(uint) && bit(r.match[0]));
|
|
1126
|
+
const duration = r.round[1] === 1 ? ticks(c.rounds?.seconds ?? 0) : Math.max(3, ticks(c.rounds?.breakSeconds ?? 0));
|
|
1127
|
+
check(r.round[3] <= r.tick && r.round[2] <= r.tick + duration && !(r.round[1] === 1 && !duration && r.round[2] !== 0));
|
|
1061
1128
|
check(r.policy && ['fill', 'off'].includes(r.policy.bots) && Number.isInteger(r.policy.level) && Number.isInteger(r.policy.levelMax) && r.policy.level >= 1 && r.policy.level <= r.policy.levelMax && r.policy.levelMax <= 5);
|
|
1062
1129
|
check(Object.keys(r.policy).every((key) => ['bots', 'level', 'levelMax', 'reserved'].includes(key)));
|
|
1063
1130
|
check(r.policy.reserved === undefined || uint(r.policy.reserved) && r.policy.reserved < c.seats);
|
|
1064
1131
|
fields(c.shared, r.shared);
|
|
1065
1132
|
check(Array.isArray(r.ents) && Array.isArray(r.spawns) && r.ents.length + r.spawns.length <= ENTITY_MAX);
|
|
1066
1133
|
const ids = new Set();
|
|
1134
|
+
const bodySeats = new Set();
|
|
1067
1135
|
for (const w of [...r.ents, ...r.spawns]) {
|
|
1068
1136
|
check(Array.isArray(w) && w.length === 21);
|
|
1069
1137
|
const k = c.kinds[w[2] as number];
|
|
@@ -1073,21 +1141,26 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
|
|
|
1073
1141
|
check(typeof w[16] === 'number' && Number.isFinite(w[16]) && w[16] >= 0);
|
|
1074
1142
|
for (const i of [5, 10, 14]) check(bit(w[i]));
|
|
1075
1143
|
for (const i of [7, 8, 9]) check(Array.isArray(w[i]) && (w[i] as unknown[]).length === 3 && (w[i] as unknown[]).every((n) => typeof n === 'number' && Number.isFinite(n)));
|
|
1144
|
+
for (const i of [7, 8, 9]) check(JSON.stringify(w[i]) === JSON.stringify(packVec(unpackVec(w[i], 3), 3)));
|
|
1145
|
+
const goal = w[15] as any;
|
|
1146
|
+
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');
|
|
1076
1147
|
const pos = unpackVec(w[7] as number[], 3), bounds = c.map.bounds;
|
|
1077
1148
|
check(pos.x >= bounds.min.x && pos.x <= bounds.max.x && pos.y >= bounds.min.y && pos.y <= bounds.max.y && pos.z === 0);
|
|
1078
1149
|
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);
|
|
1150
|
+
if (k.player) { check(!bodySeats.has(w[11])); bodySeats.add(w[11]); }
|
|
1079
1151
|
fields(k.fields, w[17]); fields(k.motion, w[18]); fields(k.input, w[19]);
|
|
1080
1152
|
check(w[6] === 'join' || w[6] === 'spawn');
|
|
1081
1153
|
check(JSON.stringify(w[15]) === JSON.stringify(plainData(w[15], { n: ANSWER_MAX })));
|
|
1082
|
-
check(Array.isArray(w[20]));
|
|
1154
|
+
check(Array.isArray(w[20]) && w[20].length <= 16);
|
|
1083
1155
|
for (const cmd of w[20] as { name: string; data: unknown }[]) {
|
|
1084
|
-
check(cmd && typeof cmd.name === 'string' && Object.hasOwn(c.commands, cmd.name)); const shape = c.commands[cmd.name];
|
|
1156
|
+
check(cmd && typeof cmd.name === 'string' && Object.hasOwn(c.commands, cmd.name) && Object.hasOwn(k.commands, cmd.name)); const shape = c.commands[cmd.name];
|
|
1085
1157
|
check(JSON.stringify(cmd.data) === JSON.stringify(coerceFields(shape, cmd.data, dims)));
|
|
1086
1158
|
}
|
|
1087
1159
|
}
|
|
1088
1160
|
check(Array.isArray(r.seats) && r.seats.length <= c.seats);
|
|
1089
1161
|
const seatIds = new Set();
|
|
1090
1162
|
const heldIds = new Set();
|
|
1163
|
+
check(r.seats.every((row) => Array.isArray(row) && row.length === 5));
|
|
1091
1164
|
for (const [seat, driver, owner, id, away] of r.seats) {
|
|
1092
1165
|
check(uint(seat) && seat < c.seats && !seatIds.has(seat)); seatIds.add(seat);
|
|
1093
1166
|
check(['person', 'bot', 'ai'].includes(driver) && typeof owner === 'string' && owner.length <= 128 && (id === null || ids.has(id)) && bit(away));
|
|
@@ -1098,9 +1171,9 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
|
|
|
1098
1171
|
}
|
|
1099
1172
|
}
|
|
1100
1173
|
for (const body of r.ents) if (c.kinds[body[2] as number].player) check(heldIds.has(body[0]));
|
|
1101
|
-
check(Array.isArray(r.queue) && r.queue.length <= QUEUE_MAX && Array.isArray(r.areas) && r.areas.length <=
|
|
1174
|
+
check(Array.isArray(r.queue) && r.queue.length <= QUEUE_MAX && Array.isArray(r.areas) && r.areas.length <= AREAS_MAX && Array.isArray(r.ops));
|
|
1102
1175
|
for (const q of r.queue) {
|
|
1103
|
-
check(q.length === 10 && uint(q[0]) && uint(q[1]) && typeof q[2] === 'string' && uint(q[3]) && typeof q[4] === 'string' && ['ev', 'room', 'all'].includes(q[5] as string) && typeof q[6] === 'string' && uint(q[8]) && bit(q[9]));
|
|
1176
|
+
check(q.length === 10 && uint(q[0]) && uint(q[1]) && typeof q[2] === 'string' && q[2].length <= 128 && uint(q[3]) && typeof q[4] === 'string' && q[4].length <= 128 && ['ev', 'room', 'all'].includes(q[5] as string) && typeof q[6] === 'string' && uint(q[8]) && bit(q[9]));
|
|
1104
1177
|
check((q[3] as number) < r.seq);
|
|
1105
1178
|
if (!q[9]) { check(Object.hasOwn(c.events, q[6] as string)); const shape = c.events[q[6] as string]; check(JSON.stringify(q[7]) === JSON.stringify(coerceFields(shape, q[7], dims))); }
|
|
1106
1179
|
else {
|
|
@@ -1109,6 +1182,7 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
|
|
|
1109
1182
|
if (q[6] === 'answer') {
|
|
1110
1183
|
check(typeof d.ask === 'string' && Object.hasOwn(c.asks, d.ask) && ['ai', 'local', 'floor'].includes(d.by as string) && (d.why === undefined || typeof d.why === 'string' && d.why.length <= 256));
|
|
1111
1184
|
check(JSON.stringify(d.picks) === JSON.stringify(plainData(d.picks, { n: ANSWER_MAX })));
|
|
1185
|
+
if (!validPicks(c.asks[d.ask as string].questions, d.picks)) throw new Error('saved rules state is invalid: invalid saved answer');
|
|
1112
1186
|
} else {
|
|
1113
1187
|
check(uint(d.n));
|
|
1114
1188
|
if (q[6] === 'roundOver') {
|
|
@@ -1147,25 +1221,27 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
|
|
|
1147
1221
|
}
|
|
1148
1222
|
const r = opts.restore;
|
|
1149
1223
|
if (r) {
|
|
1224
|
+
if (r.v !== SAVE_REVISION) throw new Error('this save was written by another version of the runtime');
|
|
1150
1225
|
validateSave(r);
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
pendingAsks = (Array.isArray(r.asks) ? r.asks : []).filter((a) => {
|
|
1154
|
-
if (!a || typeof a.name !== 'string' || !Object.hasOwn(c.asks, a.name) || askNames.has(a.name) || typeof a.n !== 'string' || a.n.length > 128 || typeof a.who !== 'string' || a.who.length > 128 || !Number.isSafeInteger(a.at) || a.at < 0 || a.at > r.tick) return false;
|
|
1155
|
-
askNames.add(a.name); return true;
|
|
1156
|
-
}).slice(0, Object.keys(c.asks).length).map((a) => ({ n: a.n, name: a.name, who: a.who, at: a.at, state: deepFreeze(coerceFields(c.asks[a.name].stateFields, a.state, dims)), ...(a.result ? { result: decisionResult(a.result) } : {}) }));
|
|
1157
|
-
if (opts.restoreEpoch !== undefined && (!Number.isSafeInteger(opts.restoreEpoch) || opts.restoreEpoch <= 0)) throw new Error('restored epoch is invalid');
|
|
1226
|
+
pendingAsks = r.asks.map(a => ({ ...a, state: deepFreeze(a.state) }));
|
|
1227
|
+
if ([opts.restoreEpoch].some((e) => e !== undefined && (!Number.isSafeInteger(e) || e <= 0))) throw new Error('restored epoch is invalid');
|
|
1158
1228
|
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;
|
|
1229
|
+
endAsked = r.intent?.[0] === 1; finishing = r.intent?.[1] === 1;
|
|
1159
1230
|
round = { n: r.round[0], phase: r.round[1] === 1 ? 'live' : 'over', endsAt: r.round[2], startedAt: r.round[3] };
|
|
1160
1231
|
refreshRound();
|
|
1161
1232
|
shared = unpackFields(c.shared, r.shared, dims); sharedChanged();
|
|
1162
1233
|
policy = normalizeCorePolicy(r.policy ?? {});
|
|
1163
|
-
for (const [seat, value] of
|
|
1234
|
+
for (const [seat, value] of r.guideViews) guideViews.set(seat, deepFreeze(value));
|
|
1164
1235
|
for (const w of r.ents) { const e = loadEnt(w as any[]); ents.set(e.id, e); }
|
|
1165
1236
|
spawns = r.spawns.map((w) => loadEnt(w as any[]));
|
|
1166
1237
|
for (const [seat, driver, owner, id, away] of r.seats) seats.set(seat, { seat, driver: driver as Driver, owner, id, away: away === 1 });
|
|
1167
|
-
|
|
1168
|
-
|
|
1238
|
+
// Checked above. Each payload is held to its shape again, so a restored one is the frozen value a live one is. A saved
|
|
1239
|
+
// flag cannot take a declared event past its payload's shape or make it a room announcement.
|
|
1240
|
+
queue = r.queue.map((q: any[]) => {
|
|
1241
|
+
const builtIn = q[9] === 1 && !Object.hasOwn(c.events, q[6]);
|
|
1242
|
+
return { due: q[0], from: q[1], fromId: q[2], seq: q[3], to: q[4], kind: q[5], ev: q[6], data: deepFreeze(builtIn ? plainData(q[7], { n: ANSWER_MAX }) : coerceFields(c.events[q[6]] ?? [], q[7], dims)), at: q[8], ...(builtIn ? { builtIn: true } : {}) };
|
|
1243
|
+
});
|
|
1244
|
+
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)) }));
|
|
1169
1245
|
ops = r.ops as typeof ops;
|
|
1170
1246
|
} else startMatch();
|
|
1171
1247
|
|
|
@@ -1178,14 +1254,15 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
|
|
|
1178
1254
|
const def = v && typeof v.goal === 'string' && vocabulary && Object.hasOwn(vocabulary.goals, v.goal) ? vocabulary.goals[v.goal] : null;
|
|
1179
1255
|
const players = playerBodies().filter((p) => p.driver === 'person' && !p.away).map((p) => p.seat);
|
|
1180
1256
|
return def && !argsWhy(def.args, v.args, guideViews.get(seat) ?? null, players)
|
|
1181
|
-
? deepFreeze({ goal: v.goal, args: v.args ?? {}, from: v.from === 'brain' ? 'brain' : 'floor', at: Math.max(0, Math.min(tick, Math.floor(num(v.at)) || 0)), asked: v.asked === true }) : null;
|
|
1257
|
+
? deepFreeze({ goal: v.goal, args: v.args ?? {}, from: v.from === 'brain' ? 'brain' : 'floor', at: Math.max(0, Math.min(tick, Math.floor(num(v.at)) || 0)), asked: v.asked === true, state: 'active' }) : null;
|
|
1182
1258
|
}
|
|
1183
1259
|
return {
|
|
1260
|
+
round: () => ({ t: 'round', ...round, ...(round.phase === 'over' && !overAt ? { results: results() } : {}) }),
|
|
1184
1261
|
get tick() { return tick; },
|
|
1185
1262
|
get epoch() { return epoch; },
|
|
1186
1263
|
step,
|
|
1187
1264
|
answer: (n, result) => { const a = pendingAsks.find((x) => x.n === n); if (a && !a.result) a.result = decisionResult(result); },
|
|
1188
|
-
vocabulary: (value) => { vocabulary = value; for (const e of playerBodies()) e.goal = e.driver === 'ai' ? validGoal(e.seat,
|
|
1265
|
+
vocabulary: (value) => { vocabulary = value; for (const e of playerBodies()) { const savedGoal = restoredGoals.get(e.seat); const goal = e.driver === 'ai' ? validGoal(e.seat, savedGoal ?? e.goal) : null; if (savedGoal != null && vocabulary && !Object.hasOwn(vocabulary.goals, String(own(savedGoal, 'goal')))) throw new Error('saved goal is invalid'); e.goal = goal; } restoredGoals.clear(); },
|
|
1189
1266
|
goal: (seat, value) => {
|
|
1190
1267
|
const e = playerBodies().find((x) => x.seat === seat);
|
|
1191
1268
|
if (!e) return null;
|
|
@@ -1211,11 +1288,24 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
|
|
|
1211
1288
|
return def && players.includes(a.from) && !argsWhy(def.args, a.args, offered, players);
|
|
1212
1289
|
}).map((a: any) => ({ k: a.k, args: plainData(a.args, { n: ANSWER_MAX }), from: a.from, at: Number.isFinite(a.at) ? a.at : 0 }));
|
|
1213
1290
|
const goal = validGoal(seat, fv.goal);
|
|
1214
|
-
safeView = { ...coerceFields(c.view, floorView, dims), goal: goal ? { goal
|
|
1291
|
+
safeView = { ...coerceFields(c.view, floorView, dims), goal: goal ? { ...goal, state: ['active', 'done', 'failed'].includes((fv.goal as any)?.state) ? (fv.goal as any).state : 'active' } : null, asks };
|
|
1215
1292
|
}
|
|
1216
1293
|
const raw = floorCall ? g.floor?.(world, e.self, deepFreeze(safeView)) : g.view(world, viewOf(e));
|
|
1217
1294
|
charge(COPY * (floorCall ? ANSWER_MAX : estFields(c.view, raw)));
|
|
1218
1295
|
const v = floorCall ? plainData(raw, { n: ANSWER_MAX }) : coerceFields(c.view, raw, dims);
|
|
1296
|
+
if (floorCall && vocabulary && v && typeof v === 'object') {
|
|
1297
|
+
const decision = v as Record<string, unknown>;
|
|
1298
|
+
const players = playerBodies().filter(b => b.driver === 'person').map(b => b.seat);
|
|
1299
|
+
for (const [key, argKey, table] of [['goal', 'args', vocabulary.goals ?? {}], ['say', 'sayArgs', vocabulary.lines ?? {}]] as const) {
|
|
1300
|
+
const name = decision[key];
|
|
1301
|
+
if (name === undefined || name === null) continue;
|
|
1302
|
+
const def = typeof name === 'string' && Object.hasOwn(table, name) ? table[name] : null;
|
|
1303
|
+
const why = !def ? `unknown ${key} ${String(name)}; allowed names: ${Object.keys(table).join(', ') || 'none'}`
|
|
1304
|
+
: argsWhy(def.args, decision[argKey], guideViews.get(seat) ?? null, players);
|
|
1305
|
+
// Discarded whole, here and on every host: no part of a decision outside the vocabulary reaches the rules.
|
|
1306
|
+
if (why) { G.note?.('decision', `${key} ${said(name)}`, `${why}${def ? `; its arguments: ${Object.keys(def.args ?? {}).join(', ') || 'none'}` : ''}`); return; }
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1219
1309
|
if (v && typeof v === 'object' && !Array.isArray(v) && JSON.stringify(v).length <= 1900) value = v as Record<string, unknown>;
|
|
1220
1310
|
});
|
|
1221
1311
|
if (!floorCall && value) guideViews.set(seat, value);
|
|
@@ -1248,3 +1338,14 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
|
|
|
1248
1338
|
stats,
|
|
1249
1339
|
};
|
|
1250
1340
|
}
|
|
1341
|
+
|
|
1342
|
+
/** Decisions from the wire, saved events and rule floors meet the same questions. */
|
|
1343
|
+
function validPicks(questions: Record<string, unknown>, picks: unknown): boolean {
|
|
1344
|
+
if (!picks || typeof picks !== 'object' || Array.isArray(picks)) return false;
|
|
1345
|
+
return Object.entries(questions).every(([id, raw]) => {
|
|
1346
|
+
const q = raw as any; const v = (picks as Record<string, unknown>)[id];
|
|
1347
|
+
if (q.type === 'noul' || q.type === 'yes-no') return typeof v === 'boolean';
|
|
1348
|
+
if (q.type === 'score') return typeof v === 'number' && Number.isInteger(v) && v >= 0 && v < q.criteria.length;
|
|
1349
|
+
return typeof v === 'string' && (Array.isArray(q.criteria) ? q.criteria.includes(v) : Object.hasOwn(q.criteria, v));
|
|
1350
|
+
});
|
|
1351
|
+
}
|
package/rules/guard.ts
CHANGED
|
@@ -32,8 +32,14 @@ export const REFUSED_NAMES: ReadonlySet<string> = new Set(['constructor', 'proto
|
|
|
32
32
|
export const HOOK_NAMES: ReadonlySet<string> = new Set(['valueOf', 'toString', 'toJSON']);
|
|
33
33
|
export const refusedName = (k: string): boolean => REFUSED_NAMES.has(k) || k.startsWith('toLocale');
|
|
34
34
|
|
|
35
|
-
/**
|
|
36
|
-
|
|
35
|
+
/**
|
|
36
|
+
* The running handler's budget. One per isolate: handlers never run at the same time. `file` and `line` are the line
|
|
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
|
+
* `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
|
+
*/
|
|
40
|
+
export const G = { left: Infinity, file: '', line: 0, note: null as null | ((what: string, at: string, written: string) => void) };
|
|
41
|
+
/** The line a guarded call is on, set again after its arguments ran (they may have called into another file). */
|
|
42
|
+
export function l(file: string, line: number): number { G.file = file; G.line = line; return line; }
|
|
37
43
|
|
|
38
44
|
/**
|
|
39
45
|
* An object's own data property, read without running anything: a property that is a getter reads as absent, and so
|
|
@@ -60,7 +66,7 @@ export function put(o: object, key: string, v: unknown): void {
|
|
|
60
66
|
*/
|
|
61
67
|
export function plainData(v: unknown, left: { n: number }, deep = 4, text = 256): unknown {
|
|
62
68
|
left.n -= 1;
|
|
63
|
-
if (typeof v === 'number') return Number.isFinite(v) ? v : 0;
|
|
69
|
+
if (typeof v === 'number') return Number.isFinite(v) ? v + 0 : 0; // `+ 0`: never -0, which the save would not carry (pack.ts, ONE ZERO)
|
|
64
70
|
if (typeof v === 'boolean' || v === null) return v;
|
|
65
71
|
if (typeof v === 'string') return v.slice(0, text);
|
|
66
72
|
if (typeof v !== 'object' || deep <= 0 || left.n <= 0) return null;
|
|
@@ -99,7 +105,8 @@ export function charge(n: number): void {
|
|
|
99
105
|
if (!((G.left -= n) >= 0)) throw STOP;
|
|
100
106
|
}
|
|
101
107
|
/** One loop turn, or one call of a function in rules. */
|
|
102
|
-
export function t(): void {
|
|
108
|
+
export function t(file: string, line: number): void {
|
|
109
|
+
G.file = file; G.line = line;
|
|
103
110
|
if (!((G.left -= 1) >= 0)) throw STOP;
|
|
104
111
|
}
|
|
105
112
|
|