@homie-rocks/studio 0.32.0 → 0.33.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 +85 -2
- package/README.md +34 -9
- package/agents/agents.ts +57 -24
- package/bin/homie-studio.mjs +13 -4
- package/lib/build.mjs +64 -9
- package/lib/cf-usage.mjs +76 -0
- package/lib/cloudflare.mjs +25 -2
- package/lib/dev.mjs +45 -4
- package/lib/lab-check.mjs +3 -2
- package/lib/mcp-tools.mjs +1 -1
- package/lib/mcp.mjs +1 -1
- package/lib/office.mjs +7 -0
- package/lib/perf.mjs +8 -3
- package/lib/player-image.mjs +40 -0
- package/lib/rules-build.mjs +184 -0
- package/lib/rules-guard.mjs +770 -0
- package/lib/scaffold.mjs +43 -16
- package/lib/shop-catalog.mjs +10 -9
- package/lib/shop.mjs +32 -15
- package/lib/standalone-cli.mjs +30 -2
- package/lib/standalone-tools.mjs +20 -2
- package/lib/standalone.mjs +215 -9
- package/lib/studio.mjs +21 -1
- package/lib/template-history.json +90 -0
- package/lib/upgrade.mjs +19 -15
- package/lib/version.mjs +2 -7
- package/netplay/NETPLAY.md +249 -4
- package/netplay/netplay.ts +55 -13
- package/package.json +23 -1
- package/rules/core.ts +1250 -0
- package/rules/guard.ts +419 -0
- package/rules/host.ts +707 -0
- package/rules/math.ts +266 -0
- package/rules/pack.ts +292 -0
- package/rules/rules.ts +544 -0
- package/rules/view.ts +447 -0
- package/shop/SELLING.md +14 -16
- package/shop/SHOP.md +119 -30
- package/shop/shop.ts +6 -8
- package/site/SITE.md +119 -2
- package/standalone/STANDALONE.md +61 -6
- package/starters/coin-dash/game.json +11 -0
- package/starters/coin-dash/index.html +17 -0
- package/starters/coin-dash/map/main.json +16 -0
- package/starters/coin-dash/src/move.ts +27 -0
- package/starters/coin-dash/src/rules.ts +86 -0
- package/starters/coin-dash/src/view.ts +106 -0
- package/starters/coin-dash/tunables.json +5 -0
- package/starters/ember-vale/index.html +11 -0
- package/worker/brain.mjs +8 -7
- package/worker/chat-page.mjs +1 -1
- package/worker/embed.mjs +135 -0
- package/worker/hosted.mjs +56 -0
- package/worker/index.mjs +413 -22
- package/worker/office-page.mjs +1 -0
- package/worker/office.mjs +24 -7
- package/worker/pages.mjs +189 -43
- package/worker/players.mjs +10 -6
- package/worker/referrals.mjs +192 -57
- package/worker/room-store.mjs +54 -0
- package/worker/room.mjs +294 -119
- package/worker/saves-shell.mjs +7 -1
- package/worker/schema.mjs +4 -3
- package/worker/shop-page.mjs +103 -37
- package/worker/shop-rules.mjs +156 -117
- package/worker/shop-store.mjs +70 -9
- package/worker/shop.mjs +275 -143
- package/worker/site.mjs +26 -5
- package/worker/stripe.mjs +9 -6
- package/worker/version.mjs +1 -1
package/rules/core.ts
ADDED
|
@@ -0,0 +1,1250 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* core.ts — one room's world, stepped a tick at a time from a compiled rules module.
|
|
3
|
+
* =============================================================================
|
|
4
|
+
*
|
|
5
|
+
* The core is the part of the host runtime that knows the rules contract and nothing else: no socket, no clock, no
|
|
6
|
+
* storage. `createCore(compiled)` makes a room; `step(inputs)` runs one tick; `snapshot()` is the room's public
|
|
7
|
+
* state, packed; `save()` is everything a room is, so `createCore(compiled, { restore })` carries on from it. The host
|
|
8
|
+
* runtime (host.ts) drives it on the room's clock and plays the host's part on the wire. The same core runs inside the
|
|
9
|
+
* `Table`, in Node for the build check, and (from the release that adds browser-hosted rules) in a browser.
|
|
10
|
+
*
|
|
11
|
+
* ONE TICK (rooms-milestone-1-design.md, section 3.5), in four phases:
|
|
12
|
+
* 0 rounds turn, seat changes take effect and room scope hears of them, bots fill, every new entity runs `arrive`
|
|
13
|
+
* 1 one input step and `move` for every body (`think` supplies the step for a body no person is driving)
|
|
14
|
+
* 2 for every entity, its commands and then its `tick`; it starts from a different entity each tick
|
|
15
|
+
* 3 the events and timers that are due, then spawns
|
|
16
|
+
*
|
|
17
|
+
* WHO MAY WRITE WHAT. A handler is handed `self`, an object whose own fields, `motion`, `vel` and `heading` are
|
|
18
|
+
* writable (each write is held to its declared type as it is made) and whose other built-ins are read-only. Query
|
|
19
|
+
* results and event data are frozen copies. `world.shared` is writable in room scope and a frozen copy elsewhere.
|
|
20
|
+
* `world` and everything reachable from it is plain frozen data and functions made here: nothing of the `Table`, its
|
|
21
|
+
* storage, its `env` or the relay is reachable through it.
|
|
22
|
+
*
|
|
23
|
+
* THE BUDGET (section 10, guard.ts). Before each handler the core sets the guard's counter to a quarter of
|
|
24
|
+
* `budget.tick`, or what is left of the tick when that is less. A handler that uses it all is abandoned, as one that
|
|
25
|
+
* throws is. When a tick has used all of `budget.tick`, handlers not yet run are skipped and events not yet run stay
|
|
26
|
+
* queued, in order, for the next tick. `move`, `think`, `room.join` and `room.start` run all the same; once the
|
|
27
|
+
* tick's budget is gone each of them is given a small share and no more (`floor`, below), and a body's `move` and
|
|
28
|
+
* `think` stop being run when the tick has used half its budget again. So a tick's worst case is a budget and a half,
|
|
29
|
+
* however many bodies there are and whatever they do.
|
|
30
|
+
*
|
|
31
|
+
* WHAT COMES BACK FROM RULES (pack.ts, "THE BOUNDARY"). Nothing the rules hand over is used as it is. A handler's
|
|
32
|
+
* return value, every argument of a `world` call and everything written to a field, a list, `motion`, an event or an
|
|
33
|
+
* effect is read through pack.ts, which runs no code of the rules, cannot throw and does a bounded amount of work, and
|
|
34
|
+
* that work is charged to the handler before it is done (`COPY`). All of it happens inside `run()`: inside the try
|
|
35
|
+
* that catches what a handler throws, and inside its budget. Nothing of the rules' is touched after `run()` returns.
|
|
36
|
+
* So stored state is always plain frozen data of its declared shape, a query hands it out with no copy, and a
|
|
37
|
+
* handler that wants to change a list in place is handed a copy of its own (paid for when it first reads the field)
|
|
38
|
+
* that is held to its shape, and paid for, before the handler's budget is closed.
|
|
39
|
+
* =============================================================================
|
|
40
|
+
*/
|
|
41
|
+
import { argsWhy, type Vocabulary } from '../agents/agents.ts';
|
|
42
|
+
import { BudgetError, G, brand, charge, deepFreeze, plainData } from './guard.ts';
|
|
43
|
+
import { SKIN, castMap, exact, math, rayCircle, sweepMap } from './math.ts';
|
|
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';
|
|
47
|
+
import type { Compiled, Field, FieldList, KindTable, Vec3 } from './rules.ts';
|
|
48
|
+
|
|
49
|
+
/*
|
|
50
|
+
* WHAT THE RUNTIME'S OWN WORK COSTS, in budget units, beside the table of section 10. Each was set from a measurement
|
|
51
|
+
* (rooms-slice-1-notes.md, "The budget, measured"): no call does more than about 12 ns of work for a unit it charges,
|
|
52
|
+
* so a tick that uses all of `budget.tick` takes a known time.
|
|
53
|
+
*/
|
|
54
|
+
/** One value read while a value is held to its declared shape, or copied for a handler to change in place. */
|
|
55
|
+
export const COPY = 6;
|
|
56
|
+
/** One entity handed out by a query (`world.near`, `world.inBox`), and one more unit for each field it declares. */
|
|
57
|
+
export const VIEW = 16;
|
|
58
|
+
/** One number, bit, ref or text written to a field. A vector written (a field, `vel`, `heading`) is four values read: `4 * COPY`. */
|
|
59
|
+
export const SET = 3;
|
|
60
|
+
/** One value packed into the tick's snapshot: the whole public state is sent every tick, and the tick that sends it pays. */
|
|
61
|
+
export const SNAP = 4;
|
|
62
|
+
/** One event or timer put on the room's queue (`world.send`, `sendRoom`, `announce`, `after`): it is sorted and delivered on a later tick, and the sender pays for that too. */
|
|
63
|
+
export const SEND = 16;
|
|
64
|
+
/** A handler that threw: what catching it and writing it down costs the tick. */
|
|
65
|
+
export const THROWN = 512;
|
|
66
|
+
/** The most area events (`world.sendArea`) one tick takes. */
|
|
67
|
+
export const AREAS_MAX = 64;
|
|
68
|
+
/** What the log names when a room ends because of what it holds, not because of a handler. */
|
|
69
|
+
const STATE_TOO_LARGE = 'state (the room holds more than a tick can send)';
|
|
70
|
+
/** The most ids `world.sweep`'s `ignore` reads. */
|
|
71
|
+
export const IGNORE_MAX = 16;
|
|
72
|
+
/** The most values a game's own answer to `world.ask` holds. */
|
|
73
|
+
export const ANSWER_MAX = 256;
|
|
74
|
+
|
|
75
|
+
export type Driver = 'person' | 'bot' | 'ai';
|
|
76
|
+
export interface SeatInfo { seat: number; driver: Driver; owner: string }
|
|
77
|
+
/** One seat's step for a tick: its input values (already held to their types) and, for an owner-moved body, its claim. */
|
|
78
|
+
export interface StepInput { values: Record<string, unknown>; claim?: { pos: Vec3; vel: Vec3; heading: Vec3; r: number } | null }
|
|
79
|
+
export interface ResultRow { seat: number; id: string; driver: Driver; score: number; place: number }
|
|
80
|
+
export type CoreOut =
|
|
81
|
+
| { t: 'round'; n: number; phase: 'live' | 'over'; endsAt: number; startedAt: number; results?: ResultRow[] }
|
|
82
|
+
| { t: 'fx'; tick: number; list: [number, unknown, unknown][] }
|
|
83
|
+
| { t: 'ask'; n: string; state: unknown; questions: Record<string, unknown> }
|
|
84
|
+
| { t: 'goalDone'; seat: number; ok: boolean }
|
|
85
|
+
| { t: 'seats' }
|
|
86
|
+
| { t: 'shared' }
|
|
87
|
+
| { t: 'epoch'; epoch: number }
|
|
88
|
+
| { t: 'fail'; why: 'budget'; kind: string; handler: string };
|
|
89
|
+
export interface CorePolicy { reserved?: number; bots: 'fill' | 'off'; level: number; levelMax: number }
|
|
90
|
+
export interface Body { seat: number; id: string; kind: string; driver: Driver; owner: string; away: boolean; score: number; r: number }
|
|
91
|
+
|
|
92
|
+
interface Ent {
|
|
93
|
+
id: string; n: number; kind: KindTable; pos: Vec3; vel: Vec3; heading: Vec3; grounded: boolean;
|
|
94
|
+
/** The placement counter: raised whenever the runtime or a handler puts the body somewhere at once. */
|
|
95
|
+
r: number; born: number; arrived: boolean; why: 'join' | 'spawn'; dead: boolean;
|
|
96
|
+
f: Record<string, unknown>; m: Record<string, unknown>;
|
|
97
|
+
seat: number; owner: string; driver: Driver; away: boolean; goal: unknown;
|
|
98
|
+
input: Readonly<Record<string, unknown>>;
|
|
99
|
+
/** Owner movement: the distance this body's claims may still cover (it grows by a tick of `maxSpeed` every tick). */
|
|
100
|
+
allow: number;
|
|
101
|
+
cmds: { name: string; data: unknown }[];
|
|
102
|
+
self: Record<string, unknown>; mself: Record<string, unknown>;
|
|
103
|
+
}
|
|
104
|
+
interface Item { due: number; from: number; fromId: string; seq: number; to: string; kind: 'ev' | 'room' | 'all'; ev: string; data: unknown; at: number; builtIn?: boolean }
|
|
105
|
+
interface PendingAsk { n: string; name: string; who: string; at: number; state: unknown; result?: Record<string, unknown> }
|
|
106
|
+
interface Seat { seat: number; driver: Driver; owner: string; id: string | null; away: boolean }
|
|
107
|
+
interface Ctx { scope: 'none' | 'ent' | 'room' | 'join' | 'move'; ent: Ent | null }
|
|
108
|
+
const NO_SPOTS: readonly Vec3[] = Object.freeze([]);
|
|
109
|
+
/** 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 }
|
|
111
|
+
|
|
112
|
+
/** How many values a stored list, map or struct holds now: what copying it for a handler costs. */
|
|
113
|
+
function sizeOf(fd: Field, v: unknown): number {
|
|
114
|
+
if (fd.t === 'list') return 1 + (v as unknown[]).length * cellsOf(fd.of as Field);
|
|
115
|
+
if (fd.t === 'map') return 1 + Object.keys(v as object).length * (1 + cellsOf(fd.of as Field));
|
|
116
|
+
return cellsOf(fd);
|
|
117
|
+
}
|
|
118
|
+
/** What a handler threw, as a line for the log, with nothing of the thrown value run to make it. */
|
|
119
|
+
function errorText(error: unknown): string {
|
|
120
|
+
if (typeof error === 'string') return error.slice(0, 200);
|
|
121
|
+
const m = error instanceof Error ? own(error, 'message') : undefined;
|
|
122
|
+
return typeof m === 'string' ? m.slice(0, 200) : 'a handler threw a value that is not an Error';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
/* ------------------------------------------------------------------ the saved room */
|
|
127
|
+
|
|
128
|
+
/** 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
|
+
export interface SavedCore {
|
|
130
|
+
v: 1; tick: number; epoch: number; rng: number; nextId: number; seq: number;
|
|
131
|
+
round: [number, number, number, number]; overAt: number; match: [number, number]; trips: number;
|
|
132
|
+
shared: unknown[]; policy: CorePolicy;
|
|
133
|
+
asks?: PendingAsk[];
|
|
134
|
+
guideViews?: [number, Record<string, unknown>][];
|
|
135
|
+
ents: unknown[][]; spawns: unknown[][]; seats: [number, string, string, string | null, number][]; queue: unknown[][]; areas: unknown[][]; ops: unknown[];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface Core {
|
|
139
|
+
readonly tick: number;
|
|
140
|
+
readonly epoch: number;
|
|
141
|
+
step(inputs?: ReadonlyMap<number, StepInput>, guides?: () => void): void;
|
|
142
|
+
guide(seat: number, floor?: unknown): Record<string, unknown> | null;
|
|
143
|
+
vocabulary(value: Vocabulary | null): void;
|
|
144
|
+
goal(seat: number, value?: unknown): unknown;
|
|
145
|
+
answer(n: string, result: Record<string, unknown>): void;
|
|
146
|
+
seatJoin(info: SeatInfo): void;
|
|
147
|
+
seatAway(seat: number, away: boolean): void;
|
|
148
|
+
seatLeave(seat: number): void;
|
|
149
|
+
setPolicy(p: Partial<CorePolicy>): void;
|
|
150
|
+
command(seat: number, name: string, data: unknown): boolean;
|
|
151
|
+
/** The room's public state, packed: `[[n, phase, endsAt], entities]`. */
|
|
152
|
+
snapshot(): unknown[];
|
|
153
|
+
shared(): unknown[];
|
|
154
|
+
bodies(): Body[];
|
|
155
|
+
/** The body in a seat, as the host runtime needs it for the input protocol. */
|
|
156
|
+
bodyOf(seat: number): { id: string; kind: KindTable; r: number; away: boolean; driver: Driver } | null;
|
|
157
|
+
/** What happened since the last call: rounds, effects, a changed roster, changed shared state, a new epoch, a failure. */
|
|
158
|
+
drain(): CoreOut[];
|
|
159
|
+
save(): SavedCore;
|
|
160
|
+
/** The `world` object rules are handed (a test walks it to prove nothing else is reachable). */
|
|
161
|
+
readonly world: object;
|
|
162
|
+
/**
|
|
163
|
+
* `failing`: the handler the budget last stopped. `lost`: area events dropped because the room's queue was full.
|
|
164
|
+
* `tickUnits`: what the last tick used, the state it sends included.
|
|
165
|
+
*/
|
|
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 };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; restore?: SavedCore | null; restoreEpoch?: number; stage?: string; decisions?: boolean } = {}): Core {
|
|
170
|
+
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
|
+
const dims = c.dims;
|
|
172
|
+
const tickHz = c.settings.tickHz;
|
|
173
|
+
const dt = 1 / tickHz;
|
|
174
|
+
const quarter = Math.max(1, Math.floor(c.settings.budget.tick / 4));
|
|
175
|
+
const half = Math.floor(c.settings.budget.tick / 2);
|
|
176
|
+
/** 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
|
+
const floor = Math.max(1, Math.floor(c.settings.budget.tick / (4 * ENTITY_MAX)));
|
|
178
|
+
const stage = String(opts.stage ?? '');
|
|
179
|
+
|
|
180
|
+
let tick = 0;
|
|
181
|
+
let pendingAsks: PendingAsk[] = [];
|
|
182
|
+
let epoch = opts.epoch ?? 1;
|
|
183
|
+
let rng = (opts.seed ?? 1) >>> 0;
|
|
184
|
+
let nextId = 1;
|
|
185
|
+
let seq = 1;
|
|
186
|
+
const ents = new Map<string, Ent>();
|
|
187
|
+
let spawns: Ent[] = [];
|
|
188
|
+
const seats = new Map<number, Seat>();
|
|
189
|
+
let queue: Item[] = [];
|
|
190
|
+
let areas: { from: number; fromId: string; seq: number; shape: any; ev: string; data: unknown }[] = [];
|
|
191
|
+
let ops: ({ op: 'join'; info: SeatInfo } | { op: 'away'; seat: number; away: boolean } | { op: 'leave'; seat: number })[] = [];
|
|
192
|
+
let round = { n: 0, phase: 'over' as 'live' | 'over', endsAt: 0, startedAt: 0 };
|
|
193
|
+
/** The tick a round's time was up (its results follow two ticks later), or 0. */
|
|
194
|
+
let overAt = 0;
|
|
195
|
+
/** 1: a match is running. 0: between matches (after `world.finish()`), until the tick in `restartAt`. */
|
|
196
|
+
let playing = 1;
|
|
197
|
+
let restartAt = 0;
|
|
198
|
+
let finishing = false;
|
|
199
|
+
let endAsked = false;
|
|
200
|
+
let vocabulary: Vocabulary | null = null;
|
|
201
|
+
const restoredGoals = new Map<number, unknown>();
|
|
202
|
+
const guideViews = new Map<number, Record<string, unknown>>();
|
|
203
|
+
let policy: CorePolicy = { reserved: 0, bots: 'fill', level: 3, levelMax: 5 };
|
|
204
|
+
function normalizeCorePolicy(p: Partial<CorePolicy>): CorePolicy {
|
|
205
|
+
const levelMax = Math.max(1, Math.min(5, Math.floor(num(own(p, 'levelMax') ?? policy.levelMax)) || 5));
|
|
206
|
+
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, levelMax, level: Math.max(1, Math.min(levelMax, Math.floor(num(own(p, 'level') ?? policy.level)) || 3)) };
|
|
207
|
+
}
|
|
208
|
+
let shared: Record<string, unknown> = initFields(c.shared, dims);
|
|
209
|
+
let sharedRO: Readonly<Record<string, unknown>> = Object.freeze({});
|
|
210
|
+
let sharedDirty = true;
|
|
211
|
+
/** Raised by every write to `shared`, so the frozen record handed out of room scope is made again only then. */
|
|
212
|
+
let sharedVer = 0;
|
|
213
|
+
let sharedROVer = -1;
|
|
214
|
+
const sharedChanged = (): void => { sharedDirty = true; sharedVer += 1; };
|
|
215
|
+
let out: CoreOut[] = [];
|
|
216
|
+
let fx: [number, unknown, unknown][] = [];
|
|
217
|
+
/** What is left of the running tick's budget. A room's first `room.start` runs before any tick, with a whole one. */
|
|
218
|
+
let left = c.settings.budget.tick;
|
|
219
|
+
let trips = 0;
|
|
220
|
+
let cut = false;
|
|
221
|
+
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 };
|
|
223
|
+
/** The lists, maps and structs the running handler was handed copies of. */
|
|
224
|
+
let touched: Touched[] = [];
|
|
225
|
+
/** Events set aside while phase 3 delivers, so the queue's cap counts them. */
|
|
226
|
+
let aside = 0;
|
|
227
|
+
/** What the last snapshot packed, in values: the next tick is charged for it before any handler runs. */
|
|
228
|
+
let snapCells = 0;
|
|
229
|
+
let stateHeavy = false;
|
|
230
|
+
const viewCost = new Map<KindTable, number>();
|
|
231
|
+
for (const k of c.kinds) viewCost.set(k, VIEW + k.fields.length + k.motion.length);
|
|
232
|
+
const mapShapes = 4 + c.map.boxes.length + c.map.circles.length;
|
|
233
|
+
|
|
234
|
+
const ticks = (seconds: unknown): number => { const s = num(seconds); const n = Math.round(s * tickHz); return s > 0 && Number.isFinite(n) ? Math.max(1, n) : 0; };
|
|
235
|
+
const V = (v: unknown): Vec3 => vec3(v, dims);
|
|
236
|
+
|
|
237
|
+
/* ---------------------------------------------------------------- self: what a handler may write */
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The writable face of a record of declared fields (an entity's fields, its `motion`, `shared` in room scope). A
|
|
241
|
+
* number, a text or a vector is held to its type as it is written. A list, a map or a struct is stored frozen: the
|
|
242
|
+
* first read in a handler makes a copy the handler may change in place, charged by what the stored value holds, and
|
|
243
|
+
* `settle` holds that copy to its declaration when the handler ends.
|
|
244
|
+
*/
|
|
245
|
+
function typed(bag: Record<string, unknown>, list: FieldList, target: object, isShared = false): void {
|
|
246
|
+
const work: Record<string, unknown> = {};
|
|
247
|
+
for (const [name, fd] of list) {
|
|
248
|
+
if (!mutable(fd)) {
|
|
249
|
+
const cost = cellsOf(fd) === 1 ? SET : COPY * cellsOf(fd);
|
|
250
|
+
Object.defineProperty(target, name, { get: () => bag[name], set: (v) => { charge(cost); bag[name] = coerce(fd, v, dims); if (isShared) sharedChanged(); }, enumerable: true });
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
Object.defineProperty(target, name, {
|
|
254
|
+
get: () => {
|
|
255
|
+
let w = work[name];
|
|
256
|
+
if (w === undefined) {
|
|
257
|
+
charge(COPY * sizeOf(fd, bag[name]));
|
|
258
|
+
w = work[name] = thaw(fd, bag[name]);
|
|
259
|
+
touched.push({ bag, name, fd, work, shared: isShared });
|
|
260
|
+
}
|
|
261
|
+
return w;
|
|
262
|
+
},
|
|
263
|
+
set: (v) => { charge(COPY * est(fd, v)); bag[name] = coerce(fd, v, dims); work[name] = undefined; if (isShared) sharedChanged(); },
|
|
264
|
+
enumerable: true,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* The end of a handler: every list, map and struct it was handed a copy of is held to its declaration and stored,
|
|
270
|
+
* frozen. This is the handler's own work, so it is charged to it first, by the size of the copy as it now is. A
|
|
271
|
+
* copy the handler cannot pay for is dropped with every one after it, and the field keeps what it held before.
|
|
272
|
+
*/
|
|
273
|
+
function settle(): void {
|
|
274
|
+
const list = touched;
|
|
275
|
+
if (!list.length) return;
|
|
276
|
+
touched = [];
|
|
277
|
+
let i = 0;
|
|
278
|
+
try {
|
|
279
|
+
for (; i < list.length; i += 1) {
|
|
280
|
+
const p = list[i];
|
|
281
|
+
const w = p.work[p.name];
|
|
282
|
+
if (w === undefined) continue; // assigned whole after it was read: the assignment stored it
|
|
283
|
+
charge(COPY * est(p.fd, w));
|
|
284
|
+
p.bag[p.name] = coerce(p.fd, w, dims);
|
|
285
|
+
p.work[p.name] = undefined;
|
|
286
|
+
if (p.shared) sharedChanged();
|
|
287
|
+
}
|
|
288
|
+
} finally {
|
|
289
|
+
for (; i < list.length; i += 1) list[i].work[list[i].name] = undefined;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
function makeSelf(e: Ent): void {
|
|
293
|
+
const ro = (get: () => unknown): PropertyDescriptor => ({ get, enumerable: true });
|
|
294
|
+
const mself = {};
|
|
295
|
+
typed(e.m, e.kind.motion, mself);
|
|
296
|
+
Object.preventExtensions(mself);
|
|
297
|
+
const self = {};
|
|
298
|
+
Object.defineProperties(self, {
|
|
299
|
+
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 },
|
|
302
|
+
});
|
|
303
|
+
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);
|
|
305
|
+
Object.preventExtensions(self);
|
|
306
|
+
e.self = self; e.mself = mself;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* An entity as a query hands it out: a frozen record of what it holds. Stored state is frozen already, so this
|
|
310
|
+
* copies no value, only names them: its cost is the number of fields, whatever their size (`viewCost`).
|
|
311
|
+
*/
|
|
312
|
+
function viewOf(e: Ent): Readonly<Record<string, unknown>> {
|
|
313
|
+
const o: Record<string, unknown> = { id: e.id, kind: e.kind.name, pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded };
|
|
314
|
+
if (e.kind.player) { o.seat = e.seat; o.owner = e.owner; o.driver = e.driver; o.away = e.away; o.goal = e.goal; }
|
|
315
|
+
for (const [name] of e.kind.fields) o[name] = e.f[name];
|
|
316
|
+
const m: Record<string, unknown> = {};
|
|
317
|
+
for (const [name] of e.kind.motion) m[name] = e.m[name];
|
|
318
|
+
o.motion = Object.freeze(m);
|
|
319
|
+
return Object.freeze(o);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function makeEnt(kind: KindTable, at: unknown, why: 'join' | 'spawn', init: { fields?: unknown; motion?: unknown; heading?: unknown } = {}, player: SeatInfo | null = null): Ent {
|
|
323
|
+
if (ents.size + spawns.length >= ENTITY_MAX) throw new Error(`a room holds at most ${ENTITY_MAX} entities`);
|
|
324
|
+
const n = nextId; nextId += 1;
|
|
325
|
+
const e: Ent = {
|
|
326
|
+
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
|
+
r: 0, born: tick, arrived: false, why, dead: false,
|
|
328
|
+
f: coerceFields(kind.fields, init.fields, dims), m: coerceFields(kind.motion, init.motion, dims),
|
|
329
|
+
seat: player ? player.seat : -1, owner: player ? player.owner : '', driver: player ? player.driver : 'bot', away: false, goal: null,
|
|
330
|
+
input: Object.freeze(initFields(kind.input, dims)), allow: 0, cmds: [], self: {}, mself: {},
|
|
331
|
+
};
|
|
332
|
+
makeSelf(e);
|
|
333
|
+
return e;
|
|
334
|
+
}
|
|
335
|
+
function clampIn(p: Vec3, r: number): Vec3 {
|
|
336
|
+
const b = c.map.bounds;
|
|
337
|
+
const x = Math.max(b.min.x + r, Math.min(b.max.x - r, p.x)); const y = Math.max(b.min.y + r, Math.min(b.max.y - r, p.y));
|
|
338
|
+
return x === p.x && y === p.y ? p : V({ x, y, z: p.z });
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/* ---------------------------------------------------------------- running a handler under the budget */
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* One handler, under the budget. `fn` is everything that touches what the rules hand back: the call itself and the
|
|
345
|
+
* reading of its result. Whatever is thrown in it, by the rules or by the runtime's own reading, is caught here,
|
|
346
|
+
* counted against this handler and goes no further; the lists it changed in place are settled inside the same
|
|
347
|
+
* budget; and the counter and the scope are put back whatever happens. Nothing of the rules' is touched after this
|
|
348
|
+
* returns.
|
|
349
|
+
*/
|
|
350
|
+
function run(kind: string, handler: string, ent: Ent | null, scope: Ctx['scope'], fn: () => void, always: boolean | 'body' = false): boolean {
|
|
351
|
+
if (left <= 0) {
|
|
352
|
+
cut = true;
|
|
353
|
+
// Skipped once the tick's budget is gone. `room.join` and `room.start` never are; a body's `move` and `think`
|
|
354
|
+
// are only when the tick has used half its budget again, which is what a full room's small shares come to.
|
|
355
|
+
if (!always || (always === 'body' && left <= -half)) { stats.skipped += 1; return false; }
|
|
356
|
+
}
|
|
357
|
+
// A handler that runs is given a whole quarter, as the design has it: the tick ends early once the budget is used,
|
|
358
|
+
// so its last handler may take it one quarter over, and no handler is cut short for running late in a busy tick.
|
|
359
|
+
const quota = left > 0 ? quarter : floor;
|
|
360
|
+
const before = cx;
|
|
361
|
+
cx = { scope, ent };
|
|
362
|
+
// Whatever is thrown while the handler runs (by the rules, by the guard, by this file, by JavaScript itself for a key
|
|
363
|
+
// read of nothing) is made with no record of where: recording it costs thousands of loop turns, and only the message
|
|
364
|
+
// is ever kept. So a room whose every handler throws on every tick is no slower than one whose handlers do not.
|
|
365
|
+
const E = Error as unknown as { stackTraceLimit?: number };
|
|
366
|
+
const limit = E.stackTraceLimit;
|
|
367
|
+
E.stackTraceLimit = 0;
|
|
368
|
+
G.left = quota;
|
|
369
|
+
try {
|
|
370
|
+
let bad = false; let thrown: unknown;
|
|
371
|
+
try { fn(); } catch (error) { bad = true; thrown = error; }
|
|
372
|
+
// 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
|
+
try { settle(); } catch (error) { if (!bad) { bad = true; thrown = error; } }
|
|
374
|
+
if (bad) failed(kind, handler, thrown);
|
|
375
|
+
} finally {
|
|
376
|
+
const used = quota - (G.left > 0 ? G.left : 0);
|
|
377
|
+
G.left = Infinity;
|
|
378
|
+
E.stackTraceLimit = limit;
|
|
379
|
+
cx = before;
|
|
380
|
+
touched = [];
|
|
381
|
+
left -= used;
|
|
382
|
+
stats.handlers += 1;
|
|
383
|
+
if (used > stats.maxUnits) { stats.maxUnits = used; stats.worst = `${kind}.${handler}`; }
|
|
384
|
+
}
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
let failing: { kind: string; handler: string } = { kind: '', handler: '' };
|
|
388
|
+
/** In the running tick: whether the budget stopped a handler, and the handler that last threw. A tick cut short with no handler stopped was used up by handlers that threw. */
|
|
389
|
+
let stopped = false;
|
|
390
|
+
let threw: { kind: string; handler: string } | null = null;
|
|
391
|
+
function failed(kind: string, handler: string, error: unknown): void {
|
|
392
|
+
stats.errors += 1;
|
|
393
|
+
stats.lastError = `${kind}.${handler}: ${errorText(error)}`;
|
|
394
|
+
// A throw unwinds the handler and is written down here: that is the tick's own work, and the tick pays for it.
|
|
395
|
+
// (The budget's own stop costs next to nothing, and the handler it stopped has used its share already.)
|
|
396
|
+
if (!(error instanceof BudgetError)) left -= THROWN;
|
|
397
|
+
if (error instanceof BudgetError) { stats.budgetStops += 1; cut = true; stopped = true; failing = { kind, handler }; stats.failing = `${kind}.${handler}`; } else threw = { kind, handler };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/* ---------------------------------------------------------------- world: all that rules are handed */
|
|
401
|
+
|
|
402
|
+
const need = (scope: Ctx['scope'], what: string): void => { if (cx.scope !== scope) throw new Error(`${what} is for ${scope === 'room' ? 'room scope (room.start and room.on handlers)' : 'an entity\'s own handlers'}`); };
|
|
403
|
+
const mine = (self: unknown, what: string): Ent => {
|
|
404
|
+
if (cx.scope !== 'ent' || !cx.ent || self !== cx.ent.self) throw new Error(`${what} takes the entity the handler runs for (self): a handler changes only its own entity`);
|
|
405
|
+
return cx.ent;
|
|
406
|
+
};
|
|
407
|
+
/** The data of an event, a command or an effect, held to its declared shape: charged by what it holds, then read, then frozen. */
|
|
408
|
+
const shapeData = (table: Record<string, FieldList>, name: unknown, what: string, data: unknown): Readonly<Record<string, unknown>> => {
|
|
409
|
+
const shape = typeof name === 'string' && Object.hasOwn(table, name) ? table[name] : null;
|
|
410
|
+
if (!shape) throw new Error(`${what} "${said(name)}" is not declared in shapes`);
|
|
411
|
+
charge(COPY * estFields(shape, data));
|
|
412
|
+
return Object.freeze(coerceFields(shape, data, dims));
|
|
413
|
+
};
|
|
414
|
+
const sender = (): { from: number; fromId: string } => (cx.ent ? { from: cx.ent.n, fromId: cx.ent.id } : { from: 0, fromId: '' });
|
|
415
|
+
/** 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. */
|
|
416
|
+
const push = (item: Omit<Item, 'seq' | 'from' | 'fromId'> & { from?: number; fromId?: string }, runtimes = false): void => {
|
|
417
|
+
if (!runtimes && queue.length + aside >= QUEUE_MAX) throw new Error(`this room already holds ${QUEUE_MAX} events and timers that are waiting: send fewer, or set fewer timers that are far off`);
|
|
418
|
+
const s = item.from === undefined ? sender() : { from: item.from, fromId: item.fromId ?? '' };
|
|
419
|
+
queue.push({ ...item, ...s, seq } as Item); seq += 1;
|
|
420
|
+
};
|
|
421
|
+
const reach = (r: unknown, what: string): number => {
|
|
422
|
+
const n = num(r);
|
|
423
|
+
if (!(n >= 0) || n > REACH_M) throw new Error(`${what} reaches ${REACH_M} m at most`);
|
|
424
|
+
return n;
|
|
425
|
+
};
|
|
426
|
+
const kindFilter = (kind: unknown): KindTable | null => {
|
|
427
|
+
if (kind === undefined || kind === null) return null;
|
|
428
|
+
const k = typeof kind === 'string' && Object.hasOwn(c.kindOf, kind) ? c.kindOf[kind] : null;
|
|
429
|
+
if (!k) throw new Error(`no kind "${said(kind)}" is declared in entities`);
|
|
430
|
+
return k;
|
|
431
|
+
};
|
|
432
|
+
const mapApi = brand(Object.freeze({
|
|
433
|
+
name: c.map.name,
|
|
434
|
+
spot: (name: unknown): Vec3 | undefined => (typeof name === 'string' && Object.hasOwn(c.map.spots, name) ? c.map.spots[name][0] : undefined),
|
|
435
|
+
spots: (name: unknown): readonly Vec3[] => (typeof name === 'string' && Object.hasOwn(c.map.spots, name) ? c.map.spots[name] : NO_SPOTS),
|
|
436
|
+
}));
|
|
437
|
+
let roundApi: Readonly<Record<string, unknown>> = Object.freeze({});
|
|
438
|
+
const refreshRound = (): void => {
|
|
439
|
+
roundApi = brand(Object.freeze({ n: round.n, phase: round.phase, endsAt: round.endsAt, end: (): void => { need('room', 'world.round.end()'); endAsked = true; } }));
|
|
440
|
+
};
|
|
441
|
+
refreshRound();
|
|
442
|
+
/** `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. */
|
|
443
|
+
const sharedBag: Record<string, unknown> = {};
|
|
444
|
+
const sharedRW = {};
|
|
445
|
+
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);
|
|
447
|
+
Object.preventExtensions(sharedRW);
|
|
448
|
+
const sharedView = (): Readonly<Record<string, unknown>> => {
|
|
449
|
+
if (cx.scope === 'room') return sharedRW;
|
|
450
|
+
if (sharedROVer !== sharedVer) { sharedRO = Object.freeze({ ...shared }); sharedROVer = sharedVer; }
|
|
451
|
+
return sharedRO;
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
const world = brand({} as Record<string, unknown>);
|
|
455
|
+
const getters: Record<string, () => unknown> = {
|
|
456
|
+
tick: () => tick, dt: () => dt, round: () => roundApi, shared: sharedView, level: () => policy.level, levelMax: () => policy.levelMax,
|
|
457
|
+
stage: () => stage, tune: () => c.tune, math: () => math, map: () => mapApi,
|
|
458
|
+
};
|
|
459
|
+
for (const [name, get] of Object.entries(getters)) Object.defineProperty(world, name, { get, enumerable: true });
|
|
460
|
+
Object.assign(world, {
|
|
461
|
+
ticks: (seconds: unknown): number => { charge(1); return ticks(seconds); },
|
|
462
|
+
random: (): number => {
|
|
463
|
+
charge(1);
|
|
464
|
+
if (cx.scope === 'none') throw new Error('a pure callback cannot change the room random state');
|
|
465
|
+
// mulberry32: the whole of the dice is one 32-bit number, which is room state.
|
|
466
|
+
rng = (rng + 0x6d2b79f5) >>> 0;
|
|
467
|
+
let x = rng;
|
|
468
|
+
x = Math.imul(x ^ (x >>> 15), x | 1);
|
|
469
|
+
x ^= x + Math.imul(x ^ (x >>> 7), x | 61);
|
|
470
|
+
return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
|
|
471
|
+
},
|
|
472
|
+
send: (target: unknown, ev: unknown, data?: unknown): void => {
|
|
473
|
+
charge(SEND);
|
|
474
|
+
if (cx.scope !== 'ent' && cx.scope !== 'room') throw new Error('world.send is for handlers');
|
|
475
|
+
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 });
|
|
476
|
+
},
|
|
477
|
+
sendRoom: (ev: unknown, data?: unknown): void => {
|
|
478
|
+
charge(SEND);
|
|
479
|
+
if (cx.scope !== 'ent' && cx.scope !== 'room') throw new Error('world.sendRoom is for handlers');
|
|
480
|
+
push({ due: tick + 1, to: '', kind: 'room', ev: ev as string, data: shapeData(c.events, ev, 'the event', data), at: tick });
|
|
481
|
+
},
|
|
482
|
+
announce: (ev: unknown, data?: unknown): void => {
|
|
483
|
+
charge(SEND);
|
|
484
|
+
need('room', 'world.announce');
|
|
485
|
+
push({ due: tick + 1, to: '', kind: 'all', ev: ev as string, data: shapeData(c.events, ev, 'the event', data), at: tick });
|
|
486
|
+
},
|
|
487
|
+
sendArea: (shape: unknown, ev: unknown, data?: unknown): void => {
|
|
488
|
+
// Charged for every entity the room may hold when the area is settled at the end of this tick: those here, and those spawned and not yet in.
|
|
489
|
+
charge(20 + 2 * (ents.size + spawns.length));
|
|
490
|
+
if (cx.scope !== 'ent' && cx.scope !== 'room') throw new Error('world.sendArea is for handlers');
|
|
491
|
+
if (areas.length >= AREAS_MAX) throw new Error(`a tick takes ${AREAS_MAX} area events at most`);
|
|
492
|
+
const sphere = own(shape, 'sphere'); const box = own(shape, 'box'); const cone = own(shape, 'cone');
|
|
493
|
+
let area: unknown = null;
|
|
494
|
+
if (sphere) area = { k: 's', at: V(own(sphere, 'at')), r: reach(own(sphere, 'r'), 'an area') };
|
|
495
|
+
else if (box) area = { k: 'b', min: V(own(box, 'min')), max: V(own(box, 'max')) };
|
|
496
|
+
else if (cone) area = { k: 'c', at: V(own(cone, 'at')), dir: dir(own(cone, 'dir'), dims), r: reach(own(cone, 'r'), 'an area'), half: Math.max(0, Math.min(math.PI, num(own(cone, 'angle')) / 2 || 0)) };
|
|
497
|
+
if (!area) throw new Error('world.sendArea takes { sphere: { at, r } }, { box: { min, max } } or { cone: { at, dir, r, angle } }');
|
|
498
|
+
areas.push({ ...sender(), seq, shape: area, ev: ev as string, data: shapeData(c.events, ev, 'the event', data) }); seq += 1;
|
|
499
|
+
},
|
|
500
|
+
after: (n: unknown, ev: unknown, data?: unknown): void => {
|
|
501
|
+
charge(SEND);
|
|
502
|
+
if (cx.scope !== 'ent' && cx.scope !== 'room') throw new Error('world.after is for handlers');
|
|
503
|
+
const w = Math.round(num(n));
|
|
504
|
+
const wait = w >= 1 ? Math.min(w, 0x7fffffff) : 1;
|
|
505
|
+
push({ due: tick + wait, to: cx.ent ? cx.ent.id : '', kind: cx.ent ? 'ev' : 'room', ev: ev as string, data: shapeData(c.events, ev, 'the event', data), at: tick });
|
|
506
|
+
},
|
|
507
|
+
emit: (effect: unknown, at: unknown, data?: unknown): void => {
|
|
508
|
+
charge(10);
|
|
509
|
+
if (cx.scope !== 'ent' && cx.scope !== 'room') throw new Error('world.emit is for handlers');
|
|
510
|
+
const i = typeof effect === 'string' ? c.effectNames.indexOf(effect) : -1;
|
|
511
|
+
if (i < 0) throw new Error(`the effect "${said(effect)}" is not declared in shapes.effects`);
|
|
512
|
+
if (fx.length >= 256) return;
|
|
513
|
+
const shape = c.effects[c.effectNames[i]];
|
|
514
|
+
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
|
+
},
|
|
516
|
+
spawn: (kind: unknown, at: unknown, fields?: unknown): string => {
|
|
517
|
+
charge(10);
|
|
518
|
+
if (cx.scope !== 'ent' && cx.scope !== 'room') throw new Error('world.spawn is for handlers');
|
|
519
|
+
const k = kindFilter(kind);
|
|
520
|
+
if (!k) throw new Error('world.spawn(kind, at, fields) names the kind of entity to make');
|
|
521
|
+
if (k.player) throw new Error(`a "${k.name}" is a player's body: it comes from room.join when a seat is taken, never from world.spawn`);
|
|
522
|
+
charge(COPY * (estFields(k.fields, fields) + k.motion.length));
|
|
523
|
+
const e = makeEnt(k, at, 'spawn', { fields });
|
|
524
|
+
spawns.push(e);
|
|
525
|
+
return e.id;
|
|
526
|
+
},
|
|
527
|
+
despawn: (self: unknown): void => {
|
|
528
|
+
const e = mine(self, 'world.despawn');
|
|
529
|
+
if (e.kind.player) throw new Error('a player\'s body leaves when its seat is given up, never by world.despawn');
|
|
530
|
+
e.dead = true;
|
|
531
|
+
ents.delete(e.id);
|
|
532
|
+
},
|
|
533
|
+
place: (self: unknown, at: unknown, o?: unknown): void => {
|
|
534
|
+
charge(10 + 12 * COPY);
|
|
535
|
+
const e = mine(self, 'world.place');
|
|
536
|
+
const vel = own(o, 'vel'); const heading = own(o, 'heading');
|
|
537
|
+
e.pos = clampIn(V(at), e.kind.body?.radius ?? 0);
|
|
538
|
+
e.vel = vel !== undefined ? V(vel) : ZERO;
|
|
539
|
+
if (heading !== undefined) e.heading = dir(heading, dims);
|
|
540
|
+
e.r = (e.r + 1) & 0xffff;
|
|
541
|
+
e.allow = 0;
|
|
542
|
+
},
|
|
543
|
+
near: (pos: unknown, r: unknown, kind?: unknown): readonly unknown[] => {
|
|
544
|
+
charge(20 + 2 * ents.size);
|
|
545
|
+
const p = V(pos); const R = reach(r, 'world.near'); const k = kindFilter(kind);
|
|
546
|
+
const found: { d: number; e: Ent }[] = [];
|
|
547
|
+
let cost = 0;
|
|
548
|
+
for (const e of ents.values()) {
|
|
549
|
+
if (k && e.kind !== k) continue;
|
|
550
|
+
const dx = e.pos.x - p.x; const dy = e.pos.y - p.y;
|
|
551
|
+
const d2 = dx * dx + dy * dy;
|
|
552
|
+
if (d2 <= R * R) { found.push({ d: d2, e }); cost += viewCost.get(e.kind) as number; }
|
|
553
|
+
}
|
|
554
|
+
// Sorting them and handing each out is charged before either is done.
|
|
555
|
+
charge(cost);
|
|
556
|
+
found.sort((a, b) => a.d - b.d || a.e.n - b.e.n);
|
|
557
|
+
return Object.freeze(found.map((x) => viewOf(x.e)));
|
|
558
|
+
},
|
|
559
|
+
inBox: (box: unknown, kind?: unknown): readonly unknown[] => {
|
|
560
|
+
charge(20 + 2 * ents.size);
|
|
561
|
+
const min = V(own(box, 'min')); const max = V(own(box, 'max')); const k = kindFilter(kind);
|
|
562
|
+
if (max.x - min.x > 2 * REACH_M || max.y - min.y > 2 * REACH_M) throw new Error(`world.inBox reaches ${REACH_M} m from its centre at most`);
|
|
563
|
+
const found: Ent[] = [];
|
|
564
|
+
let cost = 0;
|
|
565
|
+
for (const e of ents.values()) if ((!k || e.kind === k) && e.pos.x >= min.x && e.pos.x <= max.x && e.pos.y >= min.y && e.pos.y <= max.y) { found.push(e); cost += viewCost.get(e.kind) as number; }
|
|
566
|
+
charge(cost);
|
|
567
|
+
return Object.freeze(found.map(viewOf));
|
|
568
|
+
},
|
|
569
|
+
ray: (from: unknown, direction: unknown, max: unknown): unknown => {
|
|
570
|
+
// Charged before the cast, for every shape it may test: the map's, and every entity in the room.
|
|
571
|
+
charge(20 + 4 * (mapShapes + ents.size));
|
|
572
|
+
const p = V(from); const d = dir(direction, dims); const far = reach(max, 'world.ray');
|
|
573
|
+
const cast = castMap(c.map, p.x, p.y, d.x * far, d.y * far, 0);
|
|
574
|
+
let best: Hit | null = cast.hit;
|
|
575
|
+
for (const e of ents.values()) {
|
|
576
|
+
if (!e.kind.body || e === cx.ent) continue;
|
|
577
|
+
const h = rayCircle(p.x, p.y, d.x * far, d.y * far, e.pos.x, e.pos.y, e.kind.body.radius);
|
|
578
|
+
if (h && (!best || h.t < best.t)) best = { ...h, id: e.id };
|
|
579
|
+
}
|
|
580
|
+
if (!best) return undefined;
|
|
581
|
+
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 });
|
|
582
|
+
},
|
|
583
|
+
sweep: (self: unknown, delta: unknown, o?: unknown): unknown => {
|
|
584
|
+
const e = mine(self, 'world.sweep');
|
|
585
|
+
charge(20 + 4 * (mapShapes + ents.size));
|
|
586
|
+
const d = V(delta);
|
|
587
|
+
const radius = e.kind.body?.radius ?? 0;
|
|
588
|
+
const cast = castMap(c.map, e.pos.x, e.pos.y, d.x, d.y, radius);
|
|
589
|
+
let best: Hit | null = cast.hit;
|
|
590
|
+
// The ids to pass through: the first few of a list, read as plain texts. A longer list is not searched once for every entity.
|
|
591
|
+
const list = own(o, 'ignore');
|
|
592
|
+
const ignore = new Set<string>();
|
|
593
|
+
if (Array.isArray(list)) for (let i = 0; i < list.length && i < IGNORE_MAX; i += 1) { const id = own(list, i); if (typeof id === 'string') ignore.add(id); }
|
|
594
|
+
for (const other of ents.values()) {
|
|
595
|
+
if (other === e || !other.kind.body || ignore.has(other.id)) continue;
|
|
596
|
+
const h = rayCircle(e.pos.x, e.pos.y, d.x, d.y, other.pos.x, other.pos.y, other.kind.body.radius + radius);
|
|
597
|
+
if (h && (!best || h.t < best.t)) best = { ...h, id: other.id };
|
|
598
|
+
}
|
|
599
|
+
const l = Math.sqrt(d.x * d.x + d.y * d.y);
|
|
600
|
+
const t = best ? (l > 0 ? Math.max(0, best.t - SKIN / l) : 0) : 1;
|
|
601
|
+
e.pos = V({ x: e.pos.x + d.x * t, y: e.pos.y + d.y * t, z: 0 });
|
|
602
|
+
e.grounded = true;
|
|
603
|
+
return best ? Object.freeze({ ...(best.id ? { entity: best.id } : {}), at: e.pos, normal: V({ x: best.nx, y: best.ny, z: 0 }) }) : undefined;
|
|
604
|
+
},
|
|
605
|
+
ask: (name: unknown, state?: unknown): boolean => {
|
|
606
|
+
// Charged for the search of the queue below, and for reading the answer: both before they are done.
|
|
607
|
+
charge(10 + (queue.length >> 2) + 2 * ANSWER_MAX);
|
|
608
|
+
if (cx.scope !== 'ent' && cx.scope !== 'room') throw new Error('world.ask is for handlers');
|
|
609
|
+
const a = typeof name === 'string' && Object.hasOwn(c.asks, name) ? c.asks[name] : null;
|
|
610
|
+
if (!a) throw new Error(`no ask "${said(name)}" is declared in asks`);
|
|
611
|
+
const who = cx.ent ? cx.ent.id : '';
|
|
612
|
+
if (pendingAsks.some((q) => q.name === name) || queue.some((q) => q.ev === 'answer' && (q.data as { ask?: string })?.ask === name)) return false;
|
|
613
|
+
charge(COPY * estFields(a.stateFields, state));
|
|
614
|
+
const value = deepFreeze(coerceFields(a.stateFields, state, dims));
|
|
615
|
+
if (JSON.stringify(value).length > 2048) return false;
|
|
616
|
+
const n = `a${epoch.toString(36)}-${(++seq).toString(36)}`;
|
|
617
|
+
pendingAsks.push({ n, name: name as string, who, at: tick, state: value });
|
|
618
|
+
if (opts.decisions) out.push({ t: 'ask', n, state: value, questions: a.questions });
|
|
619
|
+
return true;
|
|
620
|
+
},
|
|
621
|
+
goalDone: (ok?: boolean): void => { if (cx.ent && cx.scope === 'ent') { cx.ent.goal = null; out.push({ t: 'goalDone', seat: cx.ent.seat, ok: ok !== false }); } },
|
|
622
|
+
finish: (): void => { need('room', 'world.finish()'); finishing = true; },
|
|
623
|
+
});
|
|
624
|
+
Object.freeze(world);
|
|
625
|
+
|
|
626
|
+
const moveMap = brand(Object.freeze({ name: c.map.name, spot: mapApi.spot, spots: mapApi.spots, sweep: (body: unknown, delta: unknown): unknown => sweepMap(c.map, body, delta, moveRadius, dims) }));
|
|
627
|
+
let moveRadius = 0;
|
|
628
|
+
const moveCtx = brand({} as Record<string, unknown>);
|
|
629
|
+
for (const [name, get] of Object.entries({ tick: () => tick, dt: () => dt, tune: () => c.publicTune, math: () => math, map: () => moveMap })) Object.defineProperty(moveCtx, name, { get, enumerable: true });
|
|
630
|
+
Object.assign(moveCtx, { ticks: (seconds: unknown): number => { charge(1); return ticks(seconds); } });
|
|
631
|
+
Object.freeze(moveCtx);
|
|
632
|
+
const joinCtx = brand({} as Record<string, unknown>);
|
|
633
|
+
for (const [name, get] of Object.entries({ map: () => mapApi, shared: sharedView, tune: () => c.tune, math: () => math, round: () => roundApi })) Object.defineProperty(joinCtx, name, { get, enumerable: true });
|
|
634
|
+
Object.freeze(joinCtx);
|
|
635
|
+
|
|
636
|
+
/* ---------------------------------------------------------------- events */
|
|
637
|
+
|
|
638
|
+
const roomEvent = (ev: string, data: unknown): void => {
|
|
639
|
+
const fn = c.roomOn[ev];
|
|
640
|
+
if (fn) run('room', `on.${ev}`, null, 'room', () => fn(world, data));
|
|
641
|
+
};
|
|
642
|
+
function deliver(item: Item): boolean {
|
|
643
|
+
if (item.kind === 'room') {
|
|
644
|
+
const fn = c.roomOn[item.ev];
|
|
645
|
+
return fn ? run('room', `on.${item.ev}`, null, 'room', () => fn(world, item.data)) : true;
|
|
646
|
+
}
|
|
647
|
+
if (item.kind === 'all') {
|
|
648
|
+
const roomFn = item.builtIn ? c.roomOn[item.ev] : undefined;
|
|
649
|
+
const heard = hears(item.ev);
|
|
650
|
+
if (!roomFn && !heard) return true;
|
|
651
|
+
if (left <= 0) { cut = true; stats.skipped += 1; return false; }
|
|
652
|
+
if (roomFn) run('room', `on.${item.ev}`, null, 'room', () => roomFn(world, item.data));
|
|
653
|
+
if (!heard) return true;
|
|
654
|
+
// Going round every entity is the tick's own work, a unit an entity: thousands of announcements cannot be gone round for nothing.
|
|
655
|
+
left -= ents.size;
|
|
656
|
+
for (const e of [...ents.values()]) {
|
|
657
|
+
const fn = e.kind.onRoom[item.ev];
|
|
658
|
+
// An announcement made before an entity existed never reaches it.
|
|
659
|
+
if (!fn || e.dead || e.born > item.at) continue;
|
|
660
|
+
run(e.kind.name, `onRoom.${item.ev}`, e, 'ent', () => fn(world, e.self, item.data));
|
|
661
|
+
}
|
|
662
|
+
return true;
|
|
663
|
+
}
|
|
664
|
+
const e = ents.get(item.to);
|
|
665
|
+
if (!e || e.dead) {
|
|
666
|
+
// Undeliverable: the sender hears of it, if it declares a handler. Otherwise the event is dropped.
|
|
667
|
+
const back = deepFreeze({ event: item.ev, data: item.data, to: item.to });
|
|
668
|
+
const from = item.fromId ? ents.get(item.fromId) : null;
|
|
669
|
+
if (from && from.kind.on.undeliverable) run(from.kind.name, 'on.undeliverable', from, 'ent', () => from.kind.on.undeliverable(world, from.self, back));
|
|
670
|
+
else if (!item.fromId && c.roomOn.undeliverable) run('room', 'on.undeliverable', null, 'room', () => c.roomOn.undeliverable(world, back));
|
|
671
|
+
return true;
|
|
672
|
+
}
|
|
673
|
+
const fn = e.kind.on[item.ev];
|
|
674
|
+
return fn ? run(e.kind.name, `on.${item.ev}`, e, 'ent', () => fn(world, e.self, item.data)) : true;
|
|
675
|
+
}
|
|
676
|
+
/** Whether any kind of entity hears an announcement of this event. */
|
|
677
|
+
const heardBy = new Map<string, boolean>();
|
|
678
|
+
function hears(ev: string): boolean {
|
|
679
|
+
let h = heardBy.get(ev);
|
|
680
|
+
if (h === undefined) { h = c.kinds.some((k) => Boolean(k.onRoom[ev])); heardBy.set(ev, h); }
|
|
681
|
+
return h;
|
|
682
|
+
}
|
|
683
|
+
const announce = (ev: string, data: unknown, due: number): void => push({ due, to: '', kind: 'all', ev, data, at: tick, builtIn: true, from: 0, fromId: '' }, true);
|
|
684
|
+
|
|
685
|
+
/* ---------------------------------------------------------------- seats, bots and rounds */
|
|
686
|
+
|
|
687
|
+
const playerBodies = (): Ent[] => [...ents.values()].filter((e) => e.kind.player);
|
|
688
|
+
function results(): ResultRow[] {
|
|
689
|
+
const rows = playerBodies().map((e) => ({ seat: e.seat, id: e.id, driver: e.driver, score: e.kind.score ? Number(e.f[e.kind.score]) || 0 : 0, place: 0 }));
|
|
690
|
+
rows.sort((a, b) => b.score - a.score || a.seat - b.seat);
|
|
691
|
+
// Ties share a place (NETPLAY.md section 26): 1, 1, 3.
|
|
692
|
+
rows.forEach((r, i) => { r.place = i > 0 && rows[i - 1].score === r.score ? rows[i - 1].place : i + 1; });
|
|
693
|
+
return rows;
|
|
694
|
+
}
|
|
695
|
+
function joinBody(info: SeatInfo): Ent | null {
|
|
696
|
+
const player = Object.freeze({ seat: info.seat, driver: info.driver, owner: info.owner });
|
|
697
|
+
let made: Ent | null = null;
|
|
698
|
+
run('room', 'join', null, 'join', () => {
|
|
699
|
+
// What room.join returns is read by its own data properties, and its fields are charged before they are held to their shapes.
|
|
700
|
+
const j: unknown = (c.join as NonNullable<Compiled['join']>)(joinCtx, player);
|
|
701
|
+
const name = own(j, 'kind');
|
|
702
|
+
const k = typeof name === 'string' && Object.hasOwn(c.kindOf, name) ? c.kindOf[name] : null;
|
|
703
|
+
if (!k || !k.player) throw new Error('room.join returns { kind, at } with the kind of a player\'s body');
|
|
704
|
+
const fields = own(j, 'fields'); const motion = own(j, 'motion');
|
|
705
|
+
charge(COPY * (estFields(k.fields, fields) + estFields(k.motion, motion)));
|
|
706
|
+
made = makeEnt(k, own(j, 'at'), 'join', { fields, motion, heading: own(j, 'heading') }, info);
|
|
707
|
+
}, true);
|
|
708
|
+
const e = made as Ent | null;
|
|
709
|
+
if (!e) return null;
|
|
710
|
+
ents.set(e.id, e);
|
|
711
|
+
seats.set(info.seat, { seat: info.seat, driver: info.driver, owner: info.owner, id: e.id, away: false });
|
|
712
|
+
return e;
|
|
713
|
+
}
|
|
714
|
+
function leaveBody(s: Seat): void {
|
|
715
|
+
const e = s.id ? ents.get(s.id) : null;
|
|
716
|
+
if (e && e.kind.player?.leave === 'bot' && policy.bots !== 'off') {
|
|
717
|
+
e.driver = 'bot'; e.owner = ''; e.away = false; e.goal = null;
|
|
718
|
+
s.driver = 'bot'; s.owner = ''; s.away = false;
|
|
719
|
+
} else {
|
|
720
|
+
if (e) {
|
|
721
|
+
if (e.kind.on.leave) run(e.kind.name, 'on.leave', e, 'ent', () => e.kind.on.leave(world, e.self, Object.freeze({})));
|
|
722
|
+
e.dead = true; ents.delete(e.id);
|
|
723
|
+
}
|
|
724
|
+
seats.delete(s.seat);
|
|
725
|
+
}
|
|
726
|
+
out.push({ t: 'seats' });
|
|
727
|
+
roomEvent('seatLeft', Object.freeze({ seat: s.seat, id: e ? e.id : '' }));
|
|
728
|
+
}
|
|
729
|
+
function applyOps(): void {
|
|
730
|
+
const list = ops; ops = [];
|
|
731
|
+
for (const op of list) {
|
|
732
|
+
if (op.op === 'away') {
|
|
733
|
+
const s = seats.get(op.seat);
|
|
734
|
+
const e = s?.id ? ents.get(s.id) : null;
|
|
735
|
+
if (!s || !e || s.driver === 'bot' || s.away === op.away) continue;
|
|
736
|
+
s.away = op.away; e.away = op.away;
|
|
737
|
+
// Back at the wheel: the browser starts from where the server has the body.
|
|
738
|
+
if (!op.away) { e.r = (e.r + 1) & 0xffff; e.allow = 0; }
|
|
739
|
+
out.push({ t: 'seats' });
|
|
740
|
+
roomEvent('seatAway', Object.freeze({ seat: s.seat, id: e.id, away: op.away }));
|
|
741
|
+
} else if (op.op === 'leave') {
|
|
742
|
+
const s = seats.get(op.seat);
|
|
743
|
+
if (s && s.driver !== 'bot') leaveBody(s);
|
|
744
|
+
} else {
|
|
745
|
+
const info = op.info;
|
|
746
|
+
let s = seats.get(info.seat);
|
|
747
|
+
if (s && s.driver !== 'bot' && s.owner === info.owner && s.id && ents.has(s.id)) {
|
|
748
|
+
// The same holder again (a reconnect the relay did not report as a return).
|
|
749
|
+
if (s.away) { ops.unshift({ op: 'away', seat: info.seat, away: false }); applyOps(); }
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
// The seat changed hands without a goodbye: its old holder has left.
|
|
753
|
+
if (s && s.driver !== 'bot') { leaveBody(s); s = seats.get(info.seat); }
|
|
754
|
+
let bot: Ent | null = s && s.id ? ents.get(s.id) ?? null : null;
|
|
755
|
+
if (!bot && playerBodies().length >= c.bots) {
|
|
756
|
+
// A person who joins a full room takes over a bot's body: the bot in the highest seat.
|
|
757
|
+
for (const e of playerBodies()) if (e.driver === 'bot' && (!bot || e.seat > bot.seat)) bot = e;
|
|
758
|
+
}
|
|
759
|
+
if (bot) {
|
|
760
|
+
const e = bot;
|
|
761
|
+
seats.delete(e.seat);
|
|
762
|
+
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;
|
|
763
|
+
seats.set(info.seat, { seat: info.seat, driver: info.driver, owner: info.owner, id: e.id, away: false });
|
|
764
|
+
out.push({ t: 'seats' });
|
|
765
|
+
roomEvent('seatJoined', Object.freeze({ seat: info.seat, id: e.id, driver: info.driver, owner: info.owner, took: true }));
|
|
766
|
+
if (e.kind.on.takeover) run(e.kind.name, 'on.takeover', e, 'ent', () => e.kind.on.takeover(world, e.self, Object.freeze({})));
|
|
767
|
+
} else {
|
|
768
|
+
const e = joinBody(info);
|
|
769
|
+
out.push({ t: 'seats' });
|
|
770
|
+
if (e) roomEvent('seatJoined', Object.freeze({ seat: info.seat, id: e.id, driver: info.driver, owner: info.owner, took: false }));
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
function fillBots(): void {
|
|
776
|
+
// Reserved companions exist independently of practice bots and cannot be taken over by people.
|
|
777
|
+
const first = c.seats - (policy.reserved ?? 0);
|
|
778
|
+
for (const e of playerBodies()) if (e.owner === 'reserved' && e.seat < first) {
|
|
779
|
+
const s = seats.get(e.seat); if (s) leaveBody(s);
|
|
780
|
+
}
|
|
781
|
+
for (let seat = first; seat < c.seats; seat += 1) {
|
|
782
|
+
const s = seats.get(seat);
|
|
783
|
+
if (s && s.driver !== 'bot') continue;
|
|
784
|
+
if (s?.id) { const e = ents.get(s.id); if (e) { e.driver = 'ai'; e.owner = 'reserved'; s.driver = 'ai'; s.owner = 'reserved'; } }
|
|
785
|
+
else joinBody({ seat, driver: 'ai', owner: 'reserved' });
|
|
786
|
+
out.push({ t: 'seats' });
|
|
787
|
+
}
|
|
788
|
+
if (policy.bots !== 'fill' || !c.join) return;
|
|
789
|
+
let n = playerBodies().length;
|
|
790
|
+
for (let seat = c.seats - 1; seat >= 0 && n < c.bots; seat -= 1) {
|
|
791
|
+
if (seats.has(seat)) continue;
|
|
792
|
+
const e = joinBody({ seat, driver: 'bot', owner: '' });
|
|
793
|
+
if (!e) return;
|
|
794
|
+
n += 1;
|
|
795
|
+
out.push({ t: 'seats' });
|
|
796
|
+
roomEvent('seatJoined', Object.freeze({ seat, id: e.id, driver: 'bot', owner: '', took: false }));
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
function startRound(): void {
|
|
800
|
+
if (policy.bots === 'off') for (const e of playerBodies()) if (e.driver === 'bot') {
|
|
801
|
+
const seat = seats.get(e.seat);
|
|
802
|
+
if (seat) leaveBody(seat);
|
|
803
|
+
}
|
|
804
|
+
round = { n: round.n + 1, phase: 'live', endsAt: c.rounds && c.rounds.seconds > 0 ? tick + ticks(c.rounds.seconds) : 0, startedAt: tick };
|
|
805
|
+
overAt = 0;
|
|
806
|
+
refreshRound();
|
|
807
|
+
announce('roundStart', Object.freeze({ n: round.n }), Math.max(tick, 1));
|
|
808
|
+
out.push({ t: 'round', n: round.n, phase: 'live', endsAt: round.endsAt, startedAt: tick });
|
|
809
|
+
}
|
|
810
|
+
function endRound(): void {
|
|
811
|
+
round = { ...round, phase: 'over', endsAt: tick + Math.max(3, ticks(c.rounds ? c.rounds.breakSeconds : 0)) };
|
|
812
|
+
overAt = tick;
|
|
813
|
+
refreshRound();
|
|
814
|
+
}
|
|
815
|
+
function startMatch(): void {
|
|
816
|
+
playing = 1;
|
|
817
|
+
round = { n: 0, phase: 'over', endsAt: 0, startedAt: tick };
|
|
818
|
+
if (c.start) run('room', 'start', null, 'room', () => (c.start as NonNullable<Compiled['start']>)(world), true);
|
|
819
|
+
startRound();
|
|
820
|
+
}
|
|
821
|
+
function turnRounds(): void {
|
|
822
|
+
if (!playing) {
|
|
823
|
+
if (tick < restartAt) return;
|
|
824
|
+
startMatch();
|
|
825
|
+
// Seats stay through a finished match: every holder's body is made again, as if it had just joined.
|
|
826
|
+
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 }] : [])); }
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
if (round.phase === 'live' && (endAsked || (round.endsAt > 0 && tick >= round.endsAt))) endRound();
|
|
830
|
+
endAsked = false;
|
|
831
|
+
if (overAt && tick >= overAt + 2) {
|
|
832
|
+
// Two ticks after the time was up, so a score already on its way (two event hops) is counted.
|
|
833
|
+
const rows = results();
|
|
834
|
+
announce('roundOver', deepFreeze({ n: round.n, results: rows.map((r) => ({ ...r })) }), tick);
|
|
835
|
+
out.push({ t: 'round', n: round.n, phase: 'over', endsAt: round.endsAt, startedAt: round.startedAt, results: rows });
|
|
836
|
+
overAt = 0;
|
|
837
|
+
} else if (round.phase === 'over' && !overAt && tick >= round.endsAt) startRound();
|
|
838
|
+
}
|
|
839
|
+
function finishMatch(): void {
|
|
840
|
+
finishing = false;
|
|
841
|
+
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() });
|
|
842
|
+
for (const e of ents.values()) e.dead = true;
|
|
843
|
+
ents.clear(); spawns = []; queue = []; pendingAsks = []; areas = []; fx = [];
|
|
844
|
+
for (const s of seats.values()) s.id = null;
|
|
845
|
+
shared = initFields(c.shared, dims); sharedChanged();
|
|
846
|
+
epoch += 1;
|
|
847
|
+
playing = 0;
|
|
848
|
+
restartAt = tick + Math.max(3, ticks(c.rounds ? c.rounds.breakSeconds : 0));
|
|
849
|
+
round = { ...round, phase: 'over', endsAt: restartAt };
|
|
850
|
+
overAt = 0;
|
|
851
|
+
refreshRound();
|
|
852
|
+
out.push({ t: 'epoch', epoch }, { t: 'seats' });
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/* ---------------------------------------------------------------- one tick */
|
|
856
|
+
|
|
857
|
+
function inArea(a: any, p: Vec3): boolean {
|
|
858
|
+
if (a.k === 'b') return p.x >= a.min.x && p.x <= a.max.x && p.y >= a.min.y && p.y <= a.max.y;
|
|
859
|
+
const dx = p.x - a.at.x; const dy = p.y - a.at.y;
|
|
860
|
+
const d2 = dx * dx + dy * dy;
|
|
861
|
+
if (d2 > a.r * a.r) return false;
|
|
862
|
+
if (a.k === 's' || d2 === 0) return true;
|
|
863
|
+
const l = Math.sqrt(d2);
|
|
864
|
+
// Inside the cone: the angle to its axis is at most half its opening, compared by cosine.
|
|
865
|
+
return (dx * a.dir.x + dy * a.dir.y) / l >= exact.cos(a.half);
|
|
866
|
+
}
|
|
867
|
+
function step(inputs: ReadonlyMap<number, StepInput> = new Map(), guides?: () => void): void {
|
|
868
|
+
tick = (tick + 1) >>> 0;
|
|
869
|
+
// The tick pays first for the state it sends: what the last snapshot packed. A room that holds more than a tick can send has nothing left for its handlers, and ends as any room does whose budget trips on every tick.
|
|
870
|
+
left = c.settings.budget.tick - SNAP * snapCells;
|
|
871
|
+
cut = false; stopped = false; threw = null;
|
|
872
|
+
// When sending the state takes more than half of a tick's budget, that is what a cut tick is put down to.
|
|
873
|
+
stateHeavy = 2 * SNAP * snapCells > c.settings.budget.tick;
|
|
874
|
+
// A room that holds more than a whole tick's budget can send has no handler left to run that could make it
|
|
875
|
+
// smaller, and every tick it lives it sends all of it again: it ends now, not after two seconds of growing.
|
|
876
|
+
if (left <= 0) { cut = true; out.push({ t: 'fail', why: 'budget', kind: 'room', handler: STATE_TOO_LARGE }); }
|
|
877
|
+
// Phase 0: rounds, seats, bots, arrivals.
|
|
878
|
+
turnRounds();
|
|
879
|
+
if (playing) {
|
|
880
|
+
applyOps();
|
|
881
|
+
fillBots();
|
|
882
|
+
for (const e of [...ents.values()]) {
|
|
883
|
+
if (e.arrived || e.dead) continue;
|
|
884
|
+
const fn = e.kind.on.arrive;
|
|
885
|
+
// With the tick's budget gone it has not arrived: it waits, doing nothing, and arrives on a later tick.
|
|
886
|
+
if (fn && !run(e.kind.name, 'on.arrive', e, 'ent', () => fn(world, e.self, Object.freeze({ why: e.why })))) continue;
|
|
887
|
+
e.arrived = true;
|
|
888
|
+
}
|
|
889
|
+
// External answers enter only on a tick; every floor runs under the caller's usual budget.
|
|
890
|
+
for (const ask of [...pendingAsks]) {
|
|
891
|
+
if (ask.at >= tick || (opts.decisions && !ask.result && tick - ask.at < 5 * tickHz)) continue;
|
|
892
|
+
const e = ask.who ? ents.get(ask.who) : null;
|
|
893
|
+
if (ask.who && !e) { pendingAsks = pendingAsks.filter((x) => x !== ask); continue; }
|
|
894
|
+
const a = c.asks[ask.name];
|
|
895
|
+
pendingAsks = pendingAsks.filter((x) => x !== ask);
|
|
896
|
+
let answered = false;
|
|
897
|
+
run(e?.kind.name ?? 'room', `asks.${ask.name}.floor`, e ?? null, 'none', () => {
|
|
898
|
+
charge(2 * ANSWER_MAX);
|
|
899
|
+
const r = ask.result;
|
|
900
|
+
const valid = (p: any): boolean => Boolean(p && typeof p === 'object' && Object.entries(a.questions).every(([id, raw]) => {
|
|
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
|
+
}));
|
|
906
|
+
const ai = r?.ok === true && valid(r.picks);
|
|
907
|
+
const picks = plainData(ai ? r.picks : a.floor(ask.state), { n: ANSWER_MAX });
|
|
908
|
+
if (!valid(picks)) throw new Error(`asks.${ask.name}.floor must answer every declared question`);
|
|
909
|
+
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
|
+
answered = true;
|
|
911
|
+
});
|
|
912
|
+
if (!answered) {
|
|
913
|
+
// A faulty floor still settles once with valid, conservative declared picks.
|
|
914
|
+
const picks = Object.fromEntries(Object.entries(a.questions).map(([id, raw]) => {
|
|
915
|
+
const q = raw as any;
|
|
916
|
+
return [id, q.type === 'noul' || q.type === 'yes-no' ? false : q.type === 'score' ? 0 : Array.isArray(q.criteria) ? q.criteria[0] : Object.keys(q.criteria)[0]];
|
|
917
|
+
}));
|
|
918
|
+
push({ due: tick, to: ask.who, kind: e ? 'ev' : 'room', ev: 'answer', data: deepFreeze({ ask: ask.name, by: 'floor', picks, why: 'floor-error' }), at: tick, builtIn: true }, true);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
guides?.();
|
|
922
|
+
// Phase 1: one input step and `move` for every body.
|
|
923
|
+
for (const e of [...ents.values()]) {
|
|
924
|
+
if (e.dead || !e.kind.body) continue;
|
|
925
|
+
const k = e.kind;
|
|
926
|
+
const body = k.body as NonNullable<KindTable['body']>;
|
|
927
|
+
let claim: StepInput['claim'] = null;
|
|
928
|
+
if (k.player) {
|
|
929
|
+
const driven = (e.driver === 'person' || (e.driver === 'ai' && inputs.has(e.seat))) && !e.away;
|
|
930
|
+
if (driven) {
|
|
931
|
+
const s = inputs.get(e.seat);
|
|
932
|
+
e.input = Object.freeze(s ? coerceFields(k.input, s.values, dims) : initFields(k.input, dims));
|
|
933
|
+
claim = s?.claim ?? null;
|
|
934
|
+
} else if (k.think && (e.driver !== 'person' || k.player.away === 'think')) {
|
|
935
|
+
// `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.
|
|
936
|
+
let stepIn: Record<string, unknown> | null = null;
|
|
937
|
+
run(k.name, 'think', e, 'ent', () => {
|
|
938
|
+
const back: unknown = (k.think as NonNullable<KindTable['think']>)(world, e.self);
|
|
939
|
+
charge(COPY * k.input.length);
|
|
940
|
+
stepIn = coerceFields(k.input, back, dims);
|
|
941
|
+
}, 'body');
|
|
942
|
+
e.input = Object.freeze(stepIn ?? initFields(k.input, dims));
|
|
943
|
+
} else e.input = Object.freeze(initFields(k.input, dims));
|
|
944
|
+
if (driven && body.owner) {
|
|
945
|
+
// Owner movement: the browser says where its body is, and the server holds the claim to maxSpeed.
|
|
946
|
+
e.allow = Math.min(e.allow + body.maxSpeed * dt * 1.02, body.maxSpeed * 0.25 + 0.05);
|
|
947
|
+
if (claim && claim.r === e.r) {
|
|
948
|
+
const dx = claim.pos.x - e.pos.x; const dy = claim.pos.y - e.pos.y;
|
|
949
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
950
|
+
const go = Math.min(dist, e.allow);
|
|
951
|
+
e.allow -= go;
|
|
952
|
+
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
|
+
const sp = Math.sqrt(claim.vel.x * claim.vel.x + claim.vel.y * claim.vel.y);
|
|
954
|
+
e.vel = sp > body.maxSpeed && sp > 0 ? V({ x: (claim.vel.x / sp) * body.maxSpeed, y: (claim.vel.y / sp) * body.maxSpeed, z: 0 }) : V(claim.vel);
|
|
955
|
+
e.heading = dir(claim.heading, dims);
|
|
956
|
+
}
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
if (!k.move) continue;
|
|
961
|
+
const b = { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: e.mself };
|
|
962
|
+
moveRadius = body.radius;
|
|
963
|
+
run(k.name, 'move', e, 'move', () => (k.move as NonNullable<KindTable['move']>)(b, e.input, moveCtx), 'body');
|
|
964
|
+
// The runtime rounds to 32-bit floats, here and in the browser, so both step from exactly the same numbers.
|
|
965
|
+
// `b` is the runtime's own object: what `move` left in it is read by its own data properties and cannot throw.
|
|
966
|
+
e.pos = clampIn(V(own(b, 'pos')), body.radius); e.vel = V(own(b, 'vel')); e.heading = dir(own(b, 'heading'), dims); e.grounded = own(b, 'grounded') === true;
|
|
967
|
+
}
|
|
968
|
+
// Phase 2: for every entity, its commands and then its tick, starting from a different entity each tick.
|
|
969
|
+
const list = [...ents.values()];
|
|
970
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
971
|
+
const e = list[(i + tick) % list.length];
|
|
972
|
+
// An entity whose `arrive` is still waiting for budget does nothing yet.
|
|
973
|
+
if (e.dead || !e.arrived) continue;
|
|
974
|
+
if (e.cmds.length) {
|
|
975
|
+
const cmds = e.cmds; e.cmds = [];
|
|
976
|
+
for (let j = 0; j < cmds.length; j += 1) {
|
|
977
|
+
const cmd = cmds[j];
|
|
978
|
+
const fn = e.kind.commands[cmd.name];
|
|
979
|
+
if (!fn || e.dead) continue;
|
|
980
|
+
// With the tick's budget gone a command is not lost: it and the ones behind it wait for the next tick.
|
|
981
|
+
if (!run(e.kind.name, `commands.${cmd.name}`, e, 'ent', () => fn(world, e.self, cmd.data))) { e.cmds = cmds.slice(j); break; }
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
if (e.kind.tick && !e.dead) run(e.kind.name, 'tick', e, 'ent', () => (e.kind.tick as NonNullable<KindTable['tick']>)(world, e.self));
|
|
985
|
+
}
|
|
986
|
+
// Phase 3: the events and timers that are due, in a fixed order (sender, then sequence), then spawns.
|
|
987
|
+
const due = queue.filter((q) => q.due <= tick).sort((a, b) => a.due - b.due || a.from - b.from || a.seq - b.seq);
|
|
988
|
+
if (due.length) {
|
|
989
|
+
const later = queue.filter((q) => q.due > tick);
|
|
990
|
+
queue = [];
|
|
991
|
+
// Set aside while they are delivered, and still counted against the queue's cap.
|
|
992
|
+
aside = due.length + later.length;
|
|
993
|
+
let stopped = -1;
|
|
994
|
+
try { for (let i = 0; i < due.length; i += 1) { if (!deliver(due[i])) { stopped = i; break; } } } finally {
|
|
995
|
+
// What the budget left unrun stays queued, in order, ahead of what handlers sent meanwhile.
|
|
996
|
+
queue = [...(stopped >= 0 ? due.slice(stopped) : []), ...later, ...queue];
|
|
997
|
+
aside = 0;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
for (const e of spawns) { e.born = tick; ents.set(e.id, e); }
|
|
1001
|
+
spawns = [];
|
|
1002
|
+
// Who is inside an area is decided at the end of the tick it was sent in; delivery is on the next. A full queue takes no more, and what it could not take is counted.
|
|
1003
|
+
for (const a of areas) for (const e of ents.values()) if (inArea(a.shape, e.pos)) { if (queue.length >= QUEUE_MAX) stats.lost += 1; else queue.push({ due: tick + 1, from: a.from, fromId: a.fromId, seq: a.seq, to: e.id, kind: 'ev', ev: a.ev, data: a.data, at: tick }); }
|
|
1004
|
+
areas = [];
|
|
1005
|
+
if (finishing) finishMatch();
|
|
1006
|
+
}
|
|
1007
|
+
if (fx.length) { out.push({ t: 'fx', tick, list: fx }); fx = []; }
|
|
1008
|
+
if (sharedDirty) { sharedDirty = false; out.push({ t: 'shared' }); }
|
|
1009
|
+
stats.tickUnits = c.settings.budget.tick - left;
|
|
1010
|
+
if (stats.tickUnits > stats.maxTickUnits) stats.maxTickUnits = stats.tickUnits;
|
|
1011
|
+
if (cut) {
|
|
1012
|
+
if (stateHeavy) failing = { kind: 'room', handler: STATE_TOO_LARGE };
|
|
1013
|
+
else if (!stopped && threw) failing = { kind: threw.kind, handler: `${threw.handler} (so many handlers threw that the tick had no budget left)` };
|
|
1014
|
+
stats.failing = `${failing.kind}.${failing.handler}`;
|
|
1015
|
+
stats.ticksCut += 1;
|
|
1016
|
+
trips += 1;
|
|
1017
|
+
// The budget tripping on every tick for two seconds: the room ends, and the log names the handler.
|
|
1018
|
+
if (trips === 2 * tickHz) out.push({ t: 'fail', why: 'budget', kind: failing.kind, handler: failing.handler });
|
|
1019
|
+
} else trips = 0;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/* ---------------------------------------------------------------- the save */
|
|
1023
|
+
|
|
1024
|
+
const saveEnt = (e: Ent): unknown[] => [e.id, e.n, e.kind.index, e.r, e.born, e.arrived ? 1 : 0, e.why, packVec(e.pos, 3), packVec(e.vel, 3), packVec(e.heading, 3), e.grounded ? 1 : 0, e.seat, e.driver, e.owner, e.away ? 1 : 0, e.goal ?? (e.driver === 'ai' ? restoredGoals.get(e.seat) : null) ?? null, e.allow,
|
|
1025
|
+
packFields(e.kind.fields, e.f, dims), packFields(e.kind.motion, e.m, dims), packFields(e.kind.input, e.input as Record<string, unknown>, dims), e.cmds];
|
|
1026
|
+
function loadEnt(w: any[]): Ent {
|
|
1027
|
+
const kind = c.kinds[w[2]];
|
|
1028
|
+
const e: Ent = {
|
|
1029
|
+
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
|
+
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: {},
|
|
1032
|
+
};
|
|
1033
|
+
if (kind.player) restoredGoals.set(e.seat, w[15]);
|
|
1034
|
+
makeSelf(e);
|
|
1035
|
+
return e;
|
|
1036
|
+
}
|
|
1037
|
+
function save(): SavedCore {
|
|
1038
|
+
return {
|
|
1039
|
+
v: 1, tick, epoch, rng, nextId, seq, round: [round.n, round.phase === 'live' ? 1 : 0, round.endsAt, round.startedAt], overAt, match: [playing, restartAt], trips,
|
|
1040
|
+
shared: packFields(c.shared, shared, dims), policy: { ...policy }, asks: pendingAsks, guideViews: [...guideViews],
|
|
1041
|
+
ents: [...ents.values()].map(saveEnt), spawns: spawns.map(saveEnt),
|
|
1042
|
+
seats: [...seats.values()].map((s) => [s.seat, s.driver, s.owner, s.id, s.away ? 1 : 0]),
|
|
1043
|
+
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]),
|
|
1044
|
+
areas: areas.map((a) => [a.from, a.fromId, a.seq, a.shape, a.ev, a.data]),
|
|
1045
|
+
ops: JSON.parse(JSON.stringify(ops)),
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
// A persisted value is untrusted too. Reject structural damage instead of silently making a different match.
|
|
1049
|
+
function validateSave(r: SavedCore): void {
|
|
1050
|
+
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;
|
|
1052
|
+
const bit = (v: unknown): boolean => v === 0 || v === 1;
|
|
1053
|
+
const fields = (list: FieldList, value: unknown): void => {
|
|
1054
|
+
check(Array.isArray(value) && JSON.stringify(value) === JSON.stringify(packFields(list, unpackFields(list, value, dims), dims)));
|
|
1055
|
+
};
|
|
1056
|
+
check(r.v === 1);
|
|
1057
|
+
for (const v of [r.tick, r.epoch, r.rng, r.nextId, r.seq, r.overAt, r.trips]) check(uint(v));
|
|
1058
|
+
check(r.rng <= 4294967295 && r.epoch > 0 && r.nextId > 0 && r.seq > 0);
|
|
1059
|
+
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));
|
|
1061
|
+
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
|
+
check(Object.keys(r.policy).every((key) => ['bots', 'level', 'levelMax', 'reserved'].includes(key)));
|
|
1063
|
+
check(r.policy.reserved === undefined || uint(r.policy.reserved) && r.policy.reserved < c.seats);
|
|
1064
|
+
fields(c.shared, r.shared);
|
|
1065
|
+
check(Array.isArray(r.ents) && Array.isArray(r.spawns) && r.ents.length + r.spawns.length <= ENTITY_MAX);
|
|
1066
|
+
const ids = new Set();
|
|
1067
|
+
for (const w of [...r.ents, ...r.spawns]) {
|
|
1068
|
+
check(Array.isArray(w) && w.length === 21);
|
|
1069
|
+
const k = c.kinds[w[2] as number];
|
|
1070
|
+
check(k && uint(w[2]) && typeof w[0] === 'string' && !ids.has(w[0])); ids.add(w[0]);
|
|
1071
|
+
for (const i of [1, 3, 4]) check(uint(w[i]));
|
|
1072
|
+
check((w[1] as number) > 0 && (w[1] as number) < r.nextId && w[0] === `e${(w[1] as number).toString(36)}` && (w[3] as number) <= 65535 && (w[4] as number) <= r.tick);
|
|
1073
|
+
check(typeof w[16] === 'number' && Number.isFinite(w[16]) && w[16] >= 0);
|
|
1074
|
+
for (const i of [5, 10, 14]) check(bit(w[i]));
|
|
1075
|
+
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)));
|
|
1076
|
+
const pos = unpackVec(w[7] as number[], 3), bounds = c.map.bounds;
|
|
1077
|
+
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
|
+
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);
|
|
1079
|
+
fields(k.fields, w[17]); fields(k.motion, w[18]); fields(k.input, w[19]);
|
|
1080
|
+
check(w[6] === 'join' || w[6] === 'spawn');
|
|
1081
|
+
check(JSON.stringify(w[15]) === JSON.stringify(plainData(w[15], { n: ANSWER_MAX })));
|
|
1082
|
+
check(Array.isArray(w[20]));
|
|
1083
|
+
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];
|
|
1085
|
+
check(JSON.stringify(cmd.data) === JSON.stringify(coerceFields(shape, cmd.data, dims)));
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
check(Array.isArray(r.seats) && r.seats.length <= c.seats);
|
|
1089
|
+
const seatIds = new Set();
|
|
1090
|
+
const heldIds = new Set();
|
|
1091
|
+
for (const [seat, driver, owner, id, away] of r.seats) {
|
|
1092
|
+
check(uint(seat) && seat < c.seats && !seatIds.has(seat)); seatIds.add(seat);
|
|
1093
|
+
check(['person', 'bot', 'ai'].includes(driver) && typeof owner === 'string' && owner.length <= 128 && (id === null || ids.has(id)) && bit(away));
|
|
1094
|
+
if (id !== null) {
|
|
1095
|
+
const body = r.ents.find((e) => e[0] === id);
|
|
1096
|
+
check(!heldIds.has(id) && body && c.kinds[body[2] as number].player && body[11] === seat && body[12] === driver && body[13] === owner && body[14] === away);
|
|
1097
|
+
heldIds.add(id);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
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 <= QUEUE_MAX && Array.isArray(r.ops));
|
|
1102
|
+
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]));
|
|
1104
|
+
check((q[3] as number) < r.seq);
|
|
1105
|
+
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
|
+
else {
|
|
1107
|
+
check(['answer', 'roundStart', 'roundOver'].includes(q[6] as string));
|
|
1108
|
+
const d = q[7] as Record<string, unknown>; check(d && typeof d === 'object');
|
|
1109
|
+
if (q[6] === 'answer') {
|
|
1110
|
+
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
|
+
check(JSON.stringify(d.picks) === JSON.stringify(plainData(d.picks, { n: ANSWER_MAX })));
|
|
1112
|
+
} else {
|
|
1113
|
+
check(uint(d.n));
|
|
1114
|
+
if (q[6] === 'roundOver') {
|
|
1115
|
+
check(Array.isArray(d.results) && d.results.length <= c.seats);
|
|
1116
|
+
for (const row of d.results as ResultRow[]) check(uint(row.seat) && row.seat < c.seats && typeof row.id === 'string' && ['person', 'bot', 'ai'].includes(row.driver) && Number.isFinite(row.score) && uint(row.place));
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
for (const a of r.areas) {
|
|
1122
|
+
check(a.length === 6 && uint(a[0]) && typeof a[1] === 'string' && uint(a[2]));
|
|
1123
|
+
check((a[2] as number) < r.seq);
|
|
1124
|
+
const shape = a[3] as Record<string, unknown>; check(shape && ['s', 'b', 'c'].includes(shape.k as string));
|
|
1125
|
+
for (const key of shape.k === 'b' ? ['min', 'max'] : shape.k === 'c' ? ['at', 'dir'] : ['at']) {
|
|
1126
|
+
const v = shape[key] as Vec3; check(v && [v.x, v.y, v.z].every((n) => typeof n === 'number' && Number.isFinite(n)));
|
|
1127
|
+
}
|
|
1128
|
+
if (shape.k !== 'b') check(typeof shape.r === 'number' && shape.r >= 0 && shape.r <= REACH_M);
|
|
1129
|
+
if (shape.k === 'c') check(typeof shape.half === 'number' && shape.half >= 0 && shape.half <= Math.PI);
|
|
1130
|
+
check(typeof a[4] === 'string' && Object.hasOwn(c.events, a[4] as string));
|
|
1131
|
+
const fields = c.events[a[4] as string]; check(fields && JSON.stringify(a[5]) === JSON.stringify(coerceFields(fields, a[5], dims)));
|
|
1132
|
+
}
|
|
1133
|
+
const occupants = new Map(r.seats.map(([seat, driver, owner]) => [seat, { driver, owner }]));
|
|
1134
|
+
for (const op of r.ops as { op: string; seat: number; away: boolean; info: SeatInfo }[]) {
|
|
1135
|
+
check(['join', 'away', 'leave'].includes(op.op));
|
|
1136
|
+
const seat = op.op === 'join' ? op.info?.seat : op.seat;
|
|
1137
|
+
check(uint(seat) && seat < c.seats);
|
|
1138
|
+
if (op.op === 'join') {
|
|
1139
|
+
check(['person', 'bot', 'ai'].includes(op.info.driver) && typeof op.info.owner === 'string' && op.info.owner.length <= 128);
|
|
1140
|
+
const held = occupants.get(seat);
|
|
1141
|
+
check(!held || held.driver === 'bot' || held.owner === 'reserved' || held.owner === op.info.owner && held.driver === op.info.driver);
|
|
1142
|
+
occupants.set(seat, op.info);
|
|
1143
|
+
}
|
|
1144
|
+
if (op.op === 'leave') occupants.delete(seat);
|
|
1145
|
+
if (op.op === 'away') check(typeof op.away === 'boolean');
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
const r = opts.restore;
|
|
1149
|
+
if (r) {
|
|
1150
|
+
validateSave(r);
|
|
1151
|
+
if (r.v !== 1) throw new Error('this save was written by another version of the runtime');
|
|
1152
|
+
const askNames = new Set<string>();
|
|
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');
|
|
1158
|
+
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;
|
|
1159
|
+
round = { n: r.round[0], phase: r.round[1] === 1 ? 'live' : 'over', endsAt: r.round[2], startedAt: r.round[3] };
|
|
1160
|
+
refreshRound();
|
|
1161
|
+
shared = unpackFields(c.shared, r.shared, dims); sharedChanged();
|
|
1162
|
+
policy = normalizeCorePolicy(r.policy ?? {});
|
|
1163
|
+
for (const [seat, value] of (Array.isArray(r.guideViews) ? r.guideViews : []).filter((v) => Array.isArray(v) && v.length === 2).slice(0, c.seats)) if (Number.isInteger(seat) && seat >= 0 && seat < c.seats) guideViews.set(seat, coerceFields(c.view, value, dims));
|
|
1164
|
+
for (const w of r.ents) { const e = loadEnt(w as any[]); ents.set(e.id, e); }
|
|
1165
|
+
spawns = r.spawns.map((w) => loadEnt(w as any[]));
|
|
1166
|
+
for (const [seat, driver, owner, id, away] of r.seats) seats.set(seat, { seat, driver: driver as Driver, owner, id, away: away === 1 });
|
|
1167
|
+
queue = r.queue.map((q: any[]) => ({ due: q[0], from: q[1], fromId: q[2], seq: q[3], to: q[4], kind: q[5], ev: q[6], data: deepFreeze(q[7]), at: q[8], ...(q[9] === 1 ? { builtIn: true } : {}) }));
|
|
1168
|
+
areas = r.areas.map((a: any[]) => ({ from: a[0], fromId: a[1], seq: a[2], shape: a[3], ev: a[4], data: deepFreeze(a[5]) }));
|
|
1169
|
+
ops = r.ops as typeof ops;
|
|
1170
|
+
} else startMatch();
|
|
1171
|
+
|
|
1172
|
+
function decisionResult(value: unknown): Record<string, unknown> {
|
|
1173
|
+
const v = plainData(value, { n: ANSWER_MAX });
|
|
1174
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? v as Record<string, unknown> : { ok: false, why: 'bad' };
|
|
1175
|
+
}
|
|
1176
|
+
function validGoal(seat: number, value: unknown): Record<string, unknown> | null {
|
|
1177
|
+
const v = plainData(value, { n: ANSWER_MAX }) as any;
|
|
1178
|
+
const def = v && typeof v.goal === 'string' && vocabulary && Object.hasOwn(vocabulary.goals, v.goal) ? vocabulary.goals[v.goal] : null;
|
|
1179
|
+
const players = playerBodies().filter((p) => p.driver === 'person' && !p.away).map((p) => p.seat);
|
|
1180
|
+
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;
|
|
1182
|
+
}
|
|
1183
|
+
return {
|
|
1184
|
+
get tick() { return tick; },
|
|
1185
|
+
get epoch() { return epoch; },
|
|
1186
|
+
step,
|
|
1187
|
+
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, restoredGoals.get(e.seat) ?? e.goal) : null; restoredGoals.clear(); },
|
|
1189
|
+
goal: (seat, value) => {
|
|
1190
|
+
const e = playerBodies().find((x) => x.seat === seat);
|
|
1191
|
+
if (!e) return null;
|
|
1192
|
+
if (value !== undefined) {
|
|
1193
|
+
e.goal = e.driver === 'ai' ? validGoal(seat, value) : null;
|
|
1194
|
+
}
|
|
1195
|
+
return e.goal;
|
|
1196
|
+
},
|
|
1197
|
+
guide: (seat, floorView) => {
|
|
1198
|
+
const e = playerBodies().find((x) => x.seat === seat);
|
|
1199
|
+
if (!e?.kind.guide || !e.kind.think || e.driver !== 'ai') return null;
|
|
1200
|
+
let value: Record<string, unknown> | null = null;
|
|
1201
|
+
const floorCall = floorView !== undefined;
|
|
1202
|
+
run(e.kind.name, floorCall ? 'guide.floor' : 'guide.view', e, floorCall ? 'ent' : 'none', () => {
|
|
1203
|
+
const g = e.kind.guide!;
|
|
1204
|
+
let safeView = floorView;
|
|
1205
|
+
if (floorCall) {
|
|
1206
|
+
const fv = (plainData(floorView, { n: ANSWER_MAX }) ?? {}) as Record<string, any>;
|
|
1207
|
+
const offered = guideViews.get(seat) ?? {};
|
|
1208
|
+
const players = playerBodies().filter((p) => p.driver === 'person' && !p.away).map((p) => p.seat);
|
|
1209
|
+
const asks = (Array.isArray(fv.asks) ? fv.asks : []).slice(0, 4).filter((a: any) => {
|
|
1210
|
+
const def = a && typeof a.k === 'string' && vocabulary?.asks && Object.hasOwn(vocabulary.asks, a.k) ? vocabulary.asks[a.k] : null;
|
|
1211
|
+
return def && players.includes(a.from) && !argsWhy(def.args, a.args, offered, players);
|
|
1212
|
+
}).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
|
+
const goal = validGoal(seat, fv.goal);
|
|
1214
|
+
safeView = { ...coerceFields(c.view, floorView, dims), goal: goal ? { goal: goal.goal, args: goal.args, state: ['active', 'done', 'failed'].includes((fv.goal as any)?.state) ? (fv.goal as any).state : 'active' } : null, asks };
|
|
1215
|
+
}
|
|
1216
|
+
const raw = floorCall ? g.floor?.(world, e.self, deepFreeze(safeView)) : g.view(world, viewOf(e));
|
|
1217
|
+
charge(COPY * (floorCall ? ANSWER_MAX : estFields(c.view, raw)));
|
|
1218
|
+
const v = floorCall ? plainData(raw, { n: ANSWER_MAX }) : coerceFields(c.view, raw, dims);
|
|
1219
|
+
if (v && typeof v === 'object' && !Array.isArray(v) && JSON.stringify(v).length <= 1900) value = v as Record<string, unknown>;
|
|
1220
|
+
});
|
|
1221
|
+
if (!floorCall && value) guideViews.set(seat, value);
|
|
1222
|
+
return value;
|
|
1223
|
+
},
|
|
1224
|
+
seatJoin: (info) => { ops.push({ op: 'join', info: { seat: info.seat, driver: info.driver, owner: String(info.owner ?? '').slice(0, 128) } }); },
|
|
1225
|
+
seatAway: (seat, away) => { ops.push({ op: 'away', seat, away }); },
|
|
1226
|
+
seatLeave: (seat) => { ops.push({ op: 'leave', seat }); },
|
|
1227
|
+
setPolicy: (p) => { policy = normalizeCorePolicy(p); },
|
|
1228
|
+
command: (seat, name, data) => {
|
|
1229
|
+
const s = seats.get(seat);
|
|
1230
|
+
const e = s?.id ? ents.get(s.id) : null;
|
|
1231
|
+
if (!e || typeof name !== 'string' || !Object.hasOwn(c.commands, name) || !e.kind.commands[name] || e.cmds.length >= 16) return false;
|
|
1232
|
+
e.cmds.push({ name, data: deepFreeze(coerceFields(c.commands[name], data, dims)) });
|
|
1233
|
+
return true;
|
|
1234
|
+
},
|
|
1235
|
+
snapshot: () => {
|
|
1236
|
+
packed.n = 0;
|
|
1237
|
+
const list = [...ents.values()].map((e) => packEntity(e.kind, e, dims));
|
|
1238
|
+
// Ten values an entity for what every entity carries (its id, its place, its velocity and heading), and one for each value of its fields.
|
|
1239
|
+
snapCells = packed.n + 10 * list.length;
|
|
1240
|
+
return [[round.n, round.phase === 'live' ? 1 : 0, round.endsAt], list];
|
|
1241
|
+
},
|
|
1242
|
+
shared: () => packFields(c.shared, shared, dims),
|
|
1243
|
+
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 })),
|
|
1244
|
+
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; },
|
|
1245
|
+
drain: () => { const o = out; out = []; return o; },
|
|
1246
|
+
save,
|
|
1247
|
+
world,
|
|
1248
|
+
stats,
|
|
1249
|
+
};
|
|
1250
|
+
}
|