@homie-rocks/studio 0.32.1 → 0.33.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +67 -1
- package/README.md +24 -6
- package/agents/agents.ts +57 -24
- package/bin/homie-studio.mjs +3 -3
- 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/studio.mjs +22 -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/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/rules.ts
ADDED
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* rules.ts — the rules contract, version 2: what a rules module declares, and how the runtime reads it.
|
|
3
|
+
* =============================================================================
|
|
4
|
+
*
|
|
5
|
+
* A game is rules plus view. The rules say what is true in the game; they run in one place (the room's `Table` on the
|
|
6
|
+
* studio's Cloudflare) and nothing of the browser is in them. This file is what a rules module imports:
|
|
7
|
+
*
|
|
8
|
+
* import { defineRules, defineMove, f } from '@homie-rocks/studio/rules';
|
|
9
|
+
*
|
|
10
|
+
* `f` names the type of every piece of state. `defineMove` wraps the movement code a body runs once a tick (its own
|
|
11
|
+
* file, `move.ts`, because a browser runs it too). `defineRules` wraps the module itself. Neither runs anything: they
|
|
12
|
+
* mark and freeze what they are given, so a rules module has no state of its own.
|
|
13
|
+
*
|
|
14
|
+
* `compileRules` is the runtime's side: it reads a module once, checks every declaration against the contract and
|
|
15
|
+
* returns the tables the core (core.ts), the packer (pack.ts), the host runtime (host.ts) and the view (view.ts) work
|
|
16
|
+
* from. A mistake in a declaration is said here, by name, before a room ever starts.
|
|
17
|
+
*
|
|
18
|
+
* THE MODEL (rooms-milestone-1-design.md, section 3). Every entity owns its own state. A handler may write only the
|
|
19
|
+
* entity it runs for (`self`); everything else it reads is read-only. To affect another entity it sends an event, which
|
|
20
|
+
* arrives on a later tick. All state is in declared fields, each with a type and a largest size.
|
|
21
|
+
*
|
|
22
|
+
* No imports but the guard, erasable TypeScript only: Node, a browser bundle and the Worker all load it as it is.
|
|
23
|
+
* =============================================================================
|
|
24
|
+
*/
|
|
25
|
+
import { brand, deepFreeze, own, plainData } from './guard.ts';
|
|
26
|
+
|
|
27
|
+
/** The version of the rules contract this runtime runs. Today's netplay contract is version 1. */
|
|
28
|
+
export const RULES_CONTRACT = 2;
|
|
29
|
+
/** How far a query, a ray or an area event reaches, in metres. */
|
|
30
|
+
export const REACH_M = 64;
|
|
31
|
+
/** The most entities one room holds. A spawn past it throws in the handler that asked. */
|
|
32
|
+
export const ENTITY_MAX = 2048;
|
|
33
|
+
/** The most seats one room holds (worker/seats.mjs `SEAT_MAX`). */
|
|
34
|
+
export const SEATS_MAX = 32;
|
|
35
|
+
/** The most values one declared field may hold when it is full (`cellsOf`), and the most all the fields of one kind of entity may. */
|
|
36
|
+
export const FIELD_CELLS_MAX = 16_384;
|
|
37
|
+
export const KIND_CELLS_MAX = 65_536;
|
|
38
|
+
/** The most events and timers one room holds waiting. A send past it throws in the handler that asked. */
|
|
39
|
+
export const QUEUE_MAX = 16_384;
|
|
40
|
+
|
|
41
|
+
/* ------------------------------------------------------------------ field types */
|
|
42
|
+
|
|
43
|
+
export type FieldKind = 'u8' | 'u16' | 'u32' | 'i8' | 'i16' | 'i32' | 'bit' | 'fix' | 'vec3' | 'dir' | 'tick' | 'ticks' | 'ref' | 'text' | 'list' | 'map' | 'struct' | 'press';
|
|
44
|
+
export interface Field {
|
|
45
|
+
readonly t: FieldKind;
|
|
46
|
+
/** The value a new entity starts with. */
|
|
47
|
+
readonly init?: unknown;
|
|
48
|
+
/** On one integer field of a player's body: this is the score a round's results rank by. */
|
|
49
|
+
readonly score?: true;
|
|
50
|
+
/** `text`: the most characters. `list` and `map`: the most entries. */
|
|
51
|
+
readonly max?: number;
|
|
52
|
+
/** `list` and `map`: what each entry is. */
|
|
53
|
+
readonly of?: Field;
|
|
54
|
+
/** `struct`: its fields, in order. */
|
|
55
|
+
readonly fields?: Readonly<Record<string, Field>>;
|
|
56
|
+
}
|
|
57
|
+
export interface FieldOptions { init?: unknown; score?: boolean }
|
|
58
|
+
export type Fields = Readonly<Record<string, Field>>;
|
|
59
|
+
|
|
60
|
+
const made = (t: FieldKind, o: FieldOptions | undefined, extra: Partial<Field> = {}): Field =>
|
|
61
|
+
Object.freeze({ t, ...extra, ...(o && o.init !== undefined ? { init: o.init } : {}), ...(o && o.score === true ? { score: true as const } : {}) });
|
|
62
|
+
const plain = (t: FieldKind) => (o?: FieldOptions): Field => made(t, o);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The types of declared state. Whole numbers (`u8` to `u32`, `i8` to `i32`) are held to their range; `bit` is true or
|
|
66
|
+
* false; `fix` is a fixed-point number in steps of 1/4096; `vec3` is a position or a velocity in metres; `dir` is a
|
|
67
|
+
* unit vector on the ground plane; `tick` is a moment and `ticks` a length on the room's clock; `ref` is the id of an
|
|
68
|
+
* entity or a player; `text`, `list` and `map` name their largest size. `press` (in `input` only) is a button that
|
|
69
|
+
* fires once.
|
|
70
|
+
*/
|
|
71
|
+
export const f = brand(Object.freeze({
|
|
72
|
+
u8: plain('u8'), u16: plain('u16'), u32: plain('u32'), i8: plain('i8'), i16: plain('i16'), i32: plain('i32'),
|
|
73
|
+
bit: plain('bit'), fix: plain('fix'), vec3: plain('vec3'), dir: plain('dir'), tick: plain('tick'), ticks: plain('ticks'), ref: plain('ref'),
|
|
74
|
+
text: (max: number, o?: FieldOptions): Field => made('text', o, { max }),
|
|
75
|
+
list: (of: Field, max: number, o?: FieldOptions): Field => made('list', o, { of, max }),
|
|
76
|
+
map: (of: Field, max: number, o?: FieldOptions): Field => made('map', o, { of, max }),
|
|
77
|
+
struct: (fields: Fields, o?: FieldOptions): Field => made('struct', o, { fields: Object.freeze({ ...fields }) }),
|
|
78
|
+
press: (): Field => made('press', undefined),
|
|
79
|
+
}));
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* How many values a field holds when it is full: 1 for a number, a bit or a ref, 4 for a vector, 1 and one more for
|
|
83
|
+
* every 64 characters of a text, and for a list, a map or a struct, itself and everything it may hold. It is what
|
|
84
|
+
* copying, checking or sending the field costs at most, so the runtime charges by it (core.ts) and the contract bounds
|
|
85
|
+
* it (`FIELD_CELLS_MAX`): a list of lists multiplies.
|
|
86
|
+
*/
|
|
87
|
+
const CELLS = new WeakMap<object, number>();
|
|
88
|
+
export function cellsOf(fd: Field): number {
|
|
89
|
+
// Asked of a declaration before it has been checked, so it takes anything: what is not a field counts as one value.
|
|
90
|
+
if (fd === null || typeof fd !== 'object') return 1;
|
|
91
|
+
const hit = CELLS.get(fd);
|
|
92
|
+
if (hit !== undefined) return hit;
|
|
93
|
+
// Set before going down, and remembered after: a declaration used in many places (or one that holds itself) is counted once.
|
|
94
|
+
CELLS.set(fd, 1);
|
|
95
|
+
const max = typeof fd.max === 'number' && fd.max > 0 ? fd.max : 0;
|
|
96
|
+
let n = 1;
|
|
97
|
+
if (fd.t === 'vec3' || fd.t === 'dir') n = 4;
|
|
98
|
+
else if (fd.t === 'text') n = 1 + Math.ceil(max / 64);
|
|
99
|
+
else if (fd.t === 'list') n = 1 + max * cellsOf(fd.of as Field);
|
|
100
|
+
else if (fd.t === 'map') n = 1 + max * (1 + cellsOf(fd.of as Field));
|
|
101
|
+
else if (fd.t === 'struct' && fd.fields !== null && typeof fd.fields === 'object') for (const sub of Object.values(fd.fields)) n += cellsOf(sub);
|
|
102
|
+
CELLS.set(fd, n);
|
|
103
|
+
return n;
|
|
104
|
+
}
|
|
105
|
+
/** `cellsOf` for a list of declared fields. */
|
|
106
|
+
export const cellsOfList = (list: FieldList): number => { let n = 0; for (const [, fd] of list) n += cellsOf(fd); return n; };
|
|
107
|
+
|
|
108
|
+
/* ------------------------------------------------------------------ what a module declares */
|
|
109
|
+
|
|
110
|
+
export interface Vec3 { readonly x: number; readonly y: number; readonly z: number }
|
|
111
|
+
/**
|
|
112
|
+
* What handlers are handed. The build's type check of rules (always on from the release that ships the build check)
|
|
113
|
+
* narrows these per kind from the declarations; until then they are deliberately loose.
|
|
114
|
+
*/
|
|
115
|
+
export type World = Record<string, any>;
|
|
116
|
+
export type Self = Record<string, any>;
|
|
117
|
+
export type Handler = (world: World, self: Self, e?: any) => void;
|
|
118
|
+
export type RoomHandler = (world: World, e?: any) => void;
|
|
119
|
+
export type MoveFn = (body: any, input: any, ctx: any) => void;
|
|
120
|
+
|
|
121
|
+
export interface BodyDef { shape: 'circle' | 'sphere' | 'capsule' | 'box'; radius: number; height?: number; maxSpeed: number; sweep?: boolean; move?: 'owner' }
|
|
122
|
+
export interface GuideDef { view: (world: World, self: Self) => unknown; floor?: (world: World, self: Self, view: any) => unknown }
|
|
123
|
+
export interface EntityDef {
|
|
124
|
+
player?: true | { away?: 'neutral' | 'think'; leave?: 'despawn' | 'bot' };
|
|
125
|
+
fields?: Fields;
|
|
126
|
+
motion?: Fields;
|
|
127
|
+
input?: Fields;
|
|
128
|
+
body?: BodyDef;
|
|
129
|
+
guide?: GuideDef;
|
|
130
|
+
tick?: Handler;
|
|
131
|
+
think?: (world: World, self: Self) => Record<string, unknown>;
|
|
132
|
+
on?: Record<string, Handler>;
|
|
133
|
+
commands?: Record<string, Handler>;
|
|
134
|
+
onRoom?: Record<string, Handler>;
|
|
135
|
+
}
|
|
136
|
+
export interface AskDef { state: Fields; questions: Record<string, unknown>; floor: (state: any) => Record<string, unknown> }
|
|
137
|
+
export interface RulesDef {
|
|
138
|
+
contract: 2;
|
|
139
|
+
space: { dims: 2 | 3 };
|
|
140
|
+
move?: Record<string, MoveFn>;
|
|
141
|
+
shapes?: { events?: Record<string, Fields>; commands?: Record<string, Fields>; effects?: Record<string, Fields>; view?: Fields };
|
|
142
|
+
entities: Record<string, EntityDef>;
|
|
143
|
+
shared?: Fields;
|
|
144
|
+
room?: {
|
|
145
|
+
rounds?: { seconds: number; breakSeconds: number };
|
|
146
|
+
bots?: { keep: number };
|
|
147
|
+
start?: RoomHandler;
|
|
148
|
+
join?: (ctx: any, player: { seat: number; driver: 'person' | 'bot' | 'ai'; owner: string }) => { kind: string; at: Vec3; heading?: Vec3; fields?: Record<string, unknown>; motion?: Record<string, unknown> };
|
|
149
|
+
on?: Record<string, RoomHandler>;
|
|
150
|
+
};
|
|
151
|
+
asks?: Record<string, AskDef>;
|
|
152
|
+
map?: string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const RULES = '__rules';
|
|
156
|
+
const MOVE = '__move';
|
|
157
|
+
|
|
158
|
+
/** Wrap a rules module. It is frozen all the way down: a rules module has no state of its own. */
|
|
159
|
+
export function defineRules<R extends RulesDef>(def: R): R {
|
|
160
|
+
return deepFreeze({ ...def, [RULES]: RULES_CONTRACT }) as R;
|
|
161
|
+
}
|
|
162
|
+
/** Wrap a game's movement code: one function per kind of body, `(body, input, ctx)`, run once a tick. */
|
|
163
|
+
export function defineMove<M extends Record<string, MoveFn>>(def: M): M {
|
|
164
|
+
return deepFreeze({ ...def, [MOVE]: true }) as M;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* ------------------------------------------------------------------ settings (game.json "room") */
|
|
168
|
+
|
|
169
|
+
export interface RoomSettings {
|
|
170
|
+
host: 'server' | 'browser';
|
|
171
|
+
offline: boolean;
|
|
172
|
+
tickHz: number;
|
|
173
|
+
inputHz: number;
|
|
174
|
+
durability: { movementSeconds: number };
|
|
175
|
+
budget: { tick: number };
|
|
176
|
+
predict: { catchM: number | null; catchUp: number; snapM: number | null; blendMs: number; interpMs: number | null };
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* The default `budget.tick`: 500,000 units at 20 ticks a second or fewer, and less at a faster rate, so a second of
|
|
180
|
+
* ticks never has more than 10,000,000. The figure is derived from a measurement (rooms-slice-1-notes.md, "The
|
|
181
|
+
* budget, measured"): the dearest charged work costs 12 to 16 ns a unit on the computer it was measured on, and a
|
|
182
|
+
* tick's worst case is a budget and a half (core.ts), so the worst tick takes about 12 ms there. That is a quarter of a
|
|
183
|
+
* 50 ms period, which leaves a server half as fast inside half a period, where the design wants a tick to end.
|
|
184
|
+
*/
|
|
185
|
+
export const BUDGET_TICK = 500_000;
|
|
186
|
+
export const BUDGET_SECOND = 10_000_000;
|
|
187
|
+
export const budgetFor = (tickHz: number): number => Math.min(BUDGET_TICK, Math.floor(BUDGET_SECOND / tickHz));
|
|
188
|
+
export const ROOM_DEFAULTS: RoomSettings = deepFreeze({
|
|
189
|
+
host: 'server', offline: true, tickHz: 20, inputHz: 20, durability: { movementSeconds: 1 }, budget: { tick: BUDGET_TICK },
|
|
190
|
+
predict: { catchM: null, catchUp: 1.25, snapM: null, blendMs: 100, interpMs: null },
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
/** game.json `"room"`, checked: every setting has a default; a value outside its limit is said and the default kept. */
|
|
194
|
+
export function roomSettings(raw: unknown): { settings: RoomSettings; problems: string[] } {
|
|
195
|
+
const problems: string[] = [];
|
|
196
|
+
const r = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw as Record<string, any> : {};
|
|
197
|
+
if (raw !== undefined && r !== raw) problems.push('"room" is an object of settings ({ "host": "server", "tickHz": 20 })');
|
|
198
|
+
const whole = (name: string, v: unknown, lo: number, hi: number, d: number): number => {
|
|
199
|
+
if (v === undefined) return d;
|
|
200
|
+
const n = Number(v);
|
|
201
|
+
if (!Number.isInteger(n) || n < lo || n > hi) { problems.push(`"room.${name}" is a whole number from ${lo} to ${hi}; using ${d}`); return d; }
|
|
202
|
+
return n;
|
|
203
|
+
};
|
|
204
|
+
const num = (name: string, v: unknown, d: number | null): number | null => {
|
|
205
|
+
if (v === undefined) return d;
|
|
206
|
+
const n = Number(v);
|
|
207
|
+
if (!Number.isFinite(n) || n < 0) { problems.push(`"room.${name}" is a number, zero or more; using its default`); return d; }
|
|
208
|
+
return n;
|
|
209
|
+
};
|
|
210
|
+
const known = new Set(['host', 'offline', 'tickHz', 'inputHz', 'durability', 'budget', 'predict']);
|
|
211
|
+
for (const key of Object.keys(r)) if (!known.has(key)) problems.push(`"room.${key}" is not a setting (host, offline, tickHz, inputHz, durability, budget, predict)`);
|
|
212
|
+
let host: 'server' | 'browser' = 'server';
|
|
213
|
+
if (r.host !== undefined) { if (r.host === 'server' || r.host === 'browser') host = r.host; else problems.push('"room.host" is "server" or "browser"; using "server"'); }
|
|
214
|
+
let offline = true;
|
|
215
|
+
if (r.offline !== undefined) { if (typeof r.offline === 'boolean') offline = r.offline; else problems.push('"room.offline" is true or false; using true'); }
|
|
216
|
+
const tickHz = whole('tickHz', r.tickHz, 1, 60, 20);
|
|
217
|
+
const inputHz = whole('inputHz', r.inputHz, 1, tickHz, tickHz);
|
|
218
|
+
const p = r.predict && typeof r.predict === 'object' ? r.predict : {};
|
|
219
|
+
return {
|
|
220
|
+
settings: {
|
|
221
|
+
host, offline, tickHz, inputHz,
|
|
222
|
+
durability: { movementSeconds: whole('durability.movementSeconds', r.durability?.movementSeconds, 1, 60, 1) },
|
|
223
|
+
budget: { tick: Math.max(1, Math.floor(num('budget.tick', r.budget?.tick, budgetFor(tickHz)) as number)) },
|
|
224
|
+
predict: { catchM: num('predict.catchM', p.catchM, null), catchUp: num('predict.catchUp', p.catchUp, 1.25) as number, snapM: num('predict.snapM', p.snapM, null), blendMs: num('predict.blendMs', p.blendMs, 100) as number, interpMs: num('predict.interpMs', p.interpMs, null) },
|
|
225
|
+
},
|
|
226
|
+
problems,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/* ------------------------------------------------------------------ the static map */
|
|
231
|
+
|
|
232
|
+
export interface MapBox { readonly min: Vec3; readonly max: Vec3 }
|
|
233
|
+
export interface MapCircle { readonly at: Vec3; readonly r: number }
|
|
234
|
+
export interface GameMap { readonly name: string; readonly bounds: MapBox; readonly boxes: readonly MapBox[]; readonly circles: readonly MapCircle[]; readonly spots: Readonly<Record<string, readonly Vec3[]>> }
|
|
235
|
+
|
|
236
|
+
const vecOf = (a: unknown, what: string): Vec3 => {
|
|
237
|
+
const p = Array.isArray(a) ? { x: a[0], y: a[1], z: a[2] ?? 0 } : a as { x?: unknown; y?: unknown; z?: unknown } | null;
|
|
238
|
+
const x = Number(p?.x); const y = Number(p?.y); const z = Number(p?.z ?? 0);
|
|
239
|
+
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) throw new Error(`${what} is a point: [x, y] or [x, y, z], in metres`);
|
|
240
|
+
return Object.freeze({ x, y, z });
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* One map file (`games/<id>/map/<name>.json`), checked: `bounds` (the world's edges), `boxes` and `circles` (solid
|
|
245
|
+
* shapes on the ground plane) and named `spots`. Positions are metres, `z` up. Height tiles, spheres and capsules
|
|
246
|
+
* arrive with 3D bodies.
|
|
247
|
+
*/
|
|
248
|
+
export function compileMap(raw: unknown, name = 'main'): GameMap {
|
|
249
|
+
const m = raw && typeof raw === 'object' ? raw as Record<string, any> : {};
|
|
250
|
+
const where = `map/${name}.json`;
|
|
251
|
+
if (!m.bounds) throw new Error(`${where} needs "bounds": { "min": [x, y], "max": [x, y] }, the edges of the world in metres`);
|
|
252
|
+
const box = (b: any, what: string): MapBox => {
|
|
253
|
+
const min = vecOf(b?.min, `${what}.min`); const max = vecOf(b?.max, `${what}.max`);
|
|
254
|
+
if (!(max.x > min.x) || !(max.y > min.y)) throw new Error(`${what}: "max" is above and to the right of "min"`);
|
|
255
|
+
return Object.freeze({ min, max });
|
|
256
|
+
};
|
|
257
|
+
const spots: Record<string, readonly Vec3[]> = {};
|
|
258
|
+
for (const [key, list] of Object.entries(m.spots ?? {})) {
|
|
259
|
+
if (!/^[a-z][A-Za-z0-9_-]{0,31}$/.test(key) || !Array.isArray(list)) throw new Error(`${where} spots.${key}: a spot name is a short word and holds a list of points`);
|
|
260
|
+
spots[key] = Object.freeze(list.map((p, i) => vecOf(p, `${where} spots.${key}[${i}]`)));
|
|
261
|
+
}
|
|
262
|
+
return Object.freeze({
|
|
263
|
+
name,
|
|
264
|
+
bounds: box(m.bounds, `${where} bounds`),
|
|
265
|
+
boxes: Object.freeze((Array.isArray(m.boxes) ? m.boxes : []).map((b: unknown, i: number) => box(b, `${where} boxes[${i}]`))),
|
|
266
|
+
circles: Object.freeze((Array.isArray(m.circles) ? m.circles : []).map((c: any, i: number) => {
|
|
267
|
+
const r = Number(c?.r);
|
|
268
|
+
if (!(r > 0)) throw new Error(`${where} circles[${i}] needs "r", its radius in metres`);
|
|
269
|
+
return Object.freeze({ at: vecOf(c?.at, `${where} circles[${i}].at`), r });
|
|
270
|
+
})),
|
|
271
|
+
spots: Object.freeze(spots),
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/* ------------------------------------------------------------------ compiled tables */
|
|
276
|
+
|
|
277
|
+
export type FieldList = readonly (readonly [string, Field])[];
|
|
278
|
+
export interface KindTable {
|
|
279
|
+
name: string;
|
|
280
|
+
index: number;
|
|
281
|
+
player: null | { away: 'neutral' | 'think'; leave: 'despawn' | 'bot' };
|
|
282
|
+
fields: FieldList;
|
|
283
|
+
motion: FieldList;
|
|
284
|
+
input: FieldList;
|
|
285
|
+
body: null | { shape: string; radius: number; height: number; maxSpeed: number; sweep: boolean; owner: boolean };
|
|
286
|
+
score: string | null;
|
|
287
|
+
tick: Handler | null;
|
|
288
|
+
think: EntityDef['think'] | null;
|
|
289
|
+
on: Record<string, Handler>;
|
|
290
|
+
commands: Record<string, Handler>;
|
|
291
|
+
onRoom: Record<string, Handler>;
|
|
292
|
+
guide: GuideDef | null;
|
|
293
|
+
move: MoveFn | null;
|
|
294
|
+
}
|
|
295
|
+
export interface Compiled {
|
|
296
|
+
contract: 2;
|
|
297
|
+
dims: 2 | 3;
|
|
298
|
+
kinds: KindTable[];
|
|
299
|
+
kindOf: Record<string, KindTable>;
|
|
300
|
+
events: Record<string, FieldList>;
|
|
301
|
+
commands: Record<string, FieldList>;
|
|
302
|
+
effects: Record<string, FieldList>;
|
|
303
|
+
effectNames: string[];
|
|
304
|
+
shared: FieldList;
|
|
305
|
+
view: FieldList;
|
|
306
|
+
rounds: { seconds: number; breakSeconds: number } | null;
|
|
307
|
+
bots: number;
|
|
308
|
+
start: RoomHandler | null;
|
|
309
|
+
join: NonNullable<RulesDef['room']>['join'] | null;
|
|
310
|
+
roomOn: Record<string, RoomHandler>;
|
|
311
|
+
asks: Record<string, AskDef & { stateFields: FieldList }>;
|
|
312
|
+
tune: Readonly<Record<string, unknown>>;
|
|
313
|
+
publicTune: Readonly<Record<string, unknown>>;
|
|
314
|
+
map: GameMap;
|
|
315
|
+
settings: RoomSettings;
|
|
316
|
+
seats: number;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Events the runtime itself sends. A kind hears one by declaring a handler; none is declared in `shapes`. */
|
|
320
|
+
export const BUILT_IN_EVENTS = Object.freeze(['arrive', 'leave', 'takeover', 'undeliverable', 'answer']);
|
|
321
|
+
export const ROOM_EVENTS = Object.freeze(['roundStart', 'roundOver']);
|
|
322
|
+
export const SEAT_EVENTS = Object.freeze(['seatJoined', 'seatAway', 'seatLeft']);
|
|
323
|
+
/** What every entity has, whatever its kind declares. A declared field may not take one of these names. */
|
|
324
|
+
export const BUILT_IN_FIELDS = Object.freeze(['id', 'kind', 'pos', 'vel', 'heading', 'grounded', 'input', 'seat', 'owner', 'driver', 'away', 'goal', 'motion']);
|
|
325
|
+
|
|
326
|
+
const NAME = /^[a-z][A-Za-z0-9_]{0,31}$/;
|
|
327
|
+
const INTS = new Set(['u8', 'u16', 'u32', 'i8', 'i16', 'i32']);
|
|
328
|
+
const TYPES = new Set(['u8', 'u16', 'u32', 'i8', 'i16', 'i32', 'bit', 'fix', 'vec3', 'dir', 'tick', 'ticks', 'ref', 'text', 'list', 'map', 'struct', 'press']);
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* One declared field, checked, and made again as the runtime's own: a frozen record of its type, its sizes and its
|
|
332
|
+
* `init`, with nothing of what the rules handed over left in it. Its `init` is copied as plain data of a bounded size
|
|
333
|
+
* (`plainData`), so no later reader of a declaration (the packer, the view's copy of the declarations as JSON) ever
|
|
334
|
+
* meets a value of the rules'. `fieldList` has bounded the whole declaration before this is called (`cellsOf`).
|
|
335
|
+
*/
|
|
336
|
+
function cleanField(fd: unknown, where: string, inInput: boolean): Field {
|
|
337
|
+
checkField(fd, where, inInput);
|
|
338
|
+
const x = fd as Field;
|
|
339
|
+
const init = own(x, 'init');
|
|
340
|
+
return Object.freeze({
|
|
341
|
+
t: x.t,
|
|
342
|
+
...(x.t === 'text' || x.t === 'list' || x.t === 'map' ? { max: x.max } : {}),
|
|
343
|
+
...(x.t === 'list' || x.t === 'map' ? { of: cleanField(x.of, `${where} (its entries)`, false) } : {}),
|
|
344
|
+
...(x.t === 'struct' ? { fields: Object.freeze(Object.fromEntries(Object.entries(x.fields ?? {}).map(([key, sub]) => [key, cleanField(sub, `${where}.${key}`, false)]))) } : {}),
|
|
345
|
+
...(init !== undefined ? { init: plainData(init, { n: FIELD_CELLS_MAX }, 8, 4096) } : {}),
|
|
346
|
+
...(own(x, 'score') === true ? { score: true as const } : {}),
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
function checkField(fd: unknown, where: string, inInput: boolean): void {
|
|
350
|
+
const x = fd as Field;
|
|
351
|
+
if (!x || typeof x !== 'object' || typeof x.t !== 'string' || !TYPES.has(x.t)) throw new Error(`${where} is not a field type: declare it with f (f.u16(), f.vec3(), f.list(f.ref(), 8)…)`);
|
|
352
|
+
if (x.t === 'press' && !inInput) throw new Error(`${where}: f.press() is for input only (a button that fires once)`);
|
|
353
|
+
if (inInput && !(INTS.has(x.t) || x.t === 'bit' || x.t === 'press' || x.t === 'fix')) throw new Error(`${where}: an input field is a whole number, f.fix(), f.bit() or f.press()`);
|
|
354
|
+
if (x.t === 'text' && !(Number.isInteger(x.max) && (x.max as number) >= 1 && (x.max as number) <= 4096)) throw new Error(`${where}: f.text(max) needs its largest length, 1 to 4096 characters`);
|
|
355
|
+
if (x.t === 'list' || x.t === 'map') {
|
|
356
|
+
if (!(Number.isInteger(x.max) && (x.max as number) >= 1 && (x.max as number) <= 1024)) throw new Error(`${where}: a list or a map declares its largest size, 1 to 1024 entries`);
|
|
357
|
+
checkField(x.of, `${where} (its entries)`, false);
|
|
358
|
+
}
|
|
359
|
+
if (x.t === 'struct') for (const [key, sub] of Object.entries(x.fields ?? {})) { if (!NAME.test(key)) throw new Error(`${where}.${key} is not a field name`); checkField(sub, `${where}.${key}`, false); }
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function fieldList(obj: unknown, where: string, { input = false, builtIns = false } = {}): FieldList {
|
|
363
|
+
if (obj === undefined || obj === null) return [];
|
|
364
|
+
if (typeof obj !== 'object' || Array.isArray(obj)) throw new Error(`${where} is a map of field names to types ({ score: f.u16() })`);
|
|
365
|
+
const out: [string, Field][] = [];
|
|
366
|
+
for (const [key, fd] of Object.entries(obj)) {
|
|
367
|
+
if (!NAME.test(key)) throw new Error(`${where}.${key} is not a field name (a letter, then letters, digits or _)`);
|
|
368
|
+
if (builtIns && BUILT_IN_FIELDS.includes(key)) throw new Error(`${where}.${key}: every entity already has "${key}"; give this field another name`);
|
|
369
|
+
// Its size first, counted once however often a declaration is used inside itself: a field too large is refused before anything walks it.
|
|
370
|
+
const cells = cellsOf(fd as Field);
|
|
371
|
+
if (!(cells <= FIELD_CELLS_MAX)) throw new Error(`${where}.${key} may hold ${cells} values when it is full, and one field holds ${FIELD_CELLS_MAX} at most (a list of lists multiplies: give the lists smaller sizes)`);
|
|
372
|
+
out.push([key, cleanField(fd, `${where}.${key}`, input)]);
|
|
373
|
+
}
|
|
374
|
+
return Object.freeze(out);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function handlers<H>(obj: unknown, where: string): Record<string, H> {
|
|
378
|
+
const out: Record<string, H> = {};
|
|
379
|
+
if (obj === undefined || obj === null) return out;
|
|
380
|
+
if (typeof obj !== 'object') throw new Error(`${where} is a map of names to handlers`);
|
|
381
|
+
for (const [key, fn] of Object.entries(obj)) {
|
|
382
|
+
if (typeof fn !== 'function') throw new Error(`${where}.${key} is a function`);
|
|
383
|
+
out[key] = fn as H;
|
|
384
|
+
}
|
|
385
|
+
return out;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export interface CompileEnv {
|
|
389
|
+
/** tunables.json as read (each value a number, or `{ value }`), with its `public` part. */
|
|
390
|
+
tune?: Record<string, unknown>;
|
|
391
|
+
/** The game's map (`compileMap`). */
|
|
392
|
+
map?: GameMap;
|
|
393
|
+
/** game.json `"room"`, already checked (`roomSettings`). */
|
|
394
|
+
settings?: RoomSettings;
|
|
395
|
+
/** game.json `players.max`. */
|
|
396
|
+
seats?: number;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** tunables.json as rules read it: `{ speed: 6 }` from `{ "speed": { "value": 6, … } }` or `{ "speed": 6 }`; `public` holds what `move` may read. */
|
|
400
|
+
export function tunablesOf(raw: unknown): { tune: Record<string, unknown>; publicTune: Record<string, unknown> } {
|
|
401
|
+
const value = (v: unknown): unknown => (v && typeof v === 'object' && !Array.isArray(v) && 'value' in (v as object) ? (v as { value: unknown }).value : v);
|
|
402
|
+
const all: Record<string, unknown> = {};
|
|
403
|
+
const pub: Record<string, unknown> = {};
|
|
404
|
+
const r = raw && typeof raw === 'object' ? raw as Record<string, unknown> : {};
|
|
405
|
+
for (const [key, v] of Object.entries(r)) {
|
|
406
|
+
if (key === 'public') continue;
|
|
407
|
+
all[key] = value(v);
|
|
408
|
+
}
|
|
409
|
+
const p = r.public && typeof r.public === 'object' ? r.public as Record<string, unknown> : {};
|
|
410
|
+
for (const [key, v] of Object.entries(p)) { pub[key] = value(v); all[key] = value(v); }
|
|
411
|
+
return { tune: deepFreeze(all), publicTune: deepFreeze(pub) };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Read a rules module once and check it against the contract. Throws, naming the declaration, when something is wrong:
|
|
416
|
+
* the build fails with that line, and a `Table` whose rules do not compile refuses joins and says why.
|
|
417
|
+
*/
|
|
418
|
+
export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
|
|
419
|
+
const d = def as RulesDef & Record<string, unknown>;
|
|
420
|
+
if (!d || typeof d !== 'object' || d[RULES] !== RULES_CONTRACT) throw new Error('a rules module is `export default defineRules({ … })`');
|
|
421
|
+
if (d.contract !== RULES_CONTRACT) throw new Error(`rules name their contract: contract: ${RULES_CONTRACT}`);
|
|
422
|
+
const dims = d.space?.dims;
|
|
423
|
+
if (dims !== 2 && dims !== 3) throw new Error('rules name their space: space: { dims: 2 } or { dims: 3 }');
|
|
424
|
+
const settings = env.settings ?? ROOM_DEFAULTS;
|
|
425
|
+
const map = env.map ?? compileMap({ bounds: { min: [-32, -32], max: [32, 32] } });
|
|
426
|
+
const { tune, publicTune } = tunablesOf(env.tune);
|
|
427
|
+
|
|
428
|
+
const events: Record<string, FieldList> = {};
|
|
429
|
+
for (const [key, shape] of Object.entries(d.shapes?.events ?? {})) {
|
|
430
|
+
if ([...BUILT_IN_EVENTS, ...ROOM_EVENTS, ...SEAT_EVENTS].includes(key)) throw new Error(`shapes.events.${key}: "${key}" is an event the runtime sends itself; give yours another name`);
|
|
431
|
+
events[key] = fieldList(shape, `shapes.events.${key}`);
|
|
432
|
+
}
|
|
433
|
+
const commands: Record<string, FieldList> = {};
|
|
434
|
+
for (const [key, shape] of Object.entries(d.shapes?.commands ?? {})) commands[key] = fieldList(shape, `shapes.commands.${key}`);
|
|
435
|
+
const effects: Record<string, FieldList> = {};
|
|
436
|
+
for (const [key, shape] of Object.entries(d.shapes?.effects ?? {})) effects[key] = fieldList(shape, `shapes.effects.${key}`);
|
|
437
|
+
const view = fieldList(d.shapes?.view, 'shapes.view');
|
|
438
|
+
|
|
439
|
+
const move = d.move as (Record<string, MoveFn> & Record<string, unknown>) | undefined;
|
|
440
|
+
if (move !== undefined && (typeof move !== 'object' || move[MOVE] !== true)) throw new Error('move is what move.ts exports: `export const move = defineMove({ … })`');
|
|
441
|
+
|
|
442
|
+
const kinds: KindTable[] = [];
|
|
443
|
+
const kindOf: Record<string, KindTable> = {};
|
|
444
|
+
if (!d.entities || typeof d.entities !== 'object' || !Object.keys(d.entities).length) throw new Error('rules declare at least one kind of entity under `entities`');
|
|
445
|
+
for (const [name, e] of Object.entries(d.entities)) {
|
|
446
|
+
const at = `entities.${name}`;
|
|
447
|
+
if (!NAME.test(name)) throw new Error(`${at} is not a kind name`);
|
|
448
|
+
const fields = fieldList(e.fields, `${at}.fields`, { builtIns: true });
|
|
449
|
+
const motion = fieldList(e.motion, `${at}.motion`);
|
|
450
|
+
const input = fieldList(e.input, `${at}.input`, { input: true });
|
|
451
|
+
const on = handlers<Handler>(e.on, `${at}.on`);
|
|
452
|
+
for (const key of Object.keys(on)) if (!events[key] && !BUILT_IN_EVENTS.includes(key)) throw new Error(`${at}.on.${key}: no event "${key}" is declared in shapes.events`);
|
|
453
|
+
const cmds = handlers<Handler>(e.commands, `${at}.commands`);
|
|
454
|
+
for (const key of Object.keys(cmds)) if (!commands[key]) throw new Error(`${at}.commands.${key}: no command "${key}" is declared in shapes.commands`);
|
|
455
|
+
const onRoom = handlers<Handler>(e.onRoom, `${at}.onRoom`);
|
|
456
|
+
for (const key of Object.keys(onRoom)) if (!events[key] && !ROOM_EVENTS.includes(key)) throw new Error(`${at}.onRoom.${key}: no event "${key}" is declared in shapes.events`);
|
|
457
|
+
let player: KindTable['player'] = null;
|
|
458
|
+
if (e.player !== undefined && e.player !== false as unknown) {
|
|
459
|
+
const p = e.player === true ? {} : e.player;
|
|
460
|
+
if (!p || typeof p !== 'object') throw new Error(`${at}.player is true, or { away, leave }`);
|
|
461
|
+
if (p.away !== undefined && p.away !== 'neutral' && p.away !== 'think') throw new Error(`${at}.player.away is 'neutral' or 'think'`);
|
|
462
|
+
if (p.leave !== undefined && p.leave !== 'despawn' && p.leave !== 'bot') throw new Error(`${at}.player.leave is 'despawn' or 'bot'`);
|
|
463
|
+
player = { away: p.away ?? 'neutral', leave: p.leave ?? 'despawn' };
|
|
464
|
+
if ((player.away === 'think' || player.leave === 'bot') && typeof e.think !== 'function') throw new Error(`${at}: away: 'think' and leave: 'bot' need a think(world, self) handler to steer the body`);
|
|
465
|
+
}
|
|
466
|
+
let body: KindTable['body'] = null;
|
|
467
|
+
if (e.body !== undefined) {
|
|
468
|
+
const b = e.body;
|
|
469
|
+
const shapes = dims === 2 ? ['circle'] : ['sphere', 'capsule', 'box'];
|
|
470
|
+
if (!b || !shapes.includes(b.shape)) throw new Error(`${at}.body.shape is ${shapes.map((s) => `'${s}'`).join(' or ')} when space.dims is ${dims}`);
|
|
471
|
+
if (typeof b.radius !== 'number' || typeof b.maxSpeed !== 'number' || !(b.radius > 0) || !(b.maxSpeed >= 0)) throw new Error(`${at}.body needs radius (metres) and maxSpeed (metres a second)`);
|
|
472
|
+
if (b.move !== undefined && b.move !== 'owner') throw new Error(`${at}.body.move is 'owner', or left out`);
|
|
473
|
+
body = { shape: b.shape, radius: b.radius, height: typeof b.height === 'number' && b.height > 0 ? b.height : 0, maxSpeed: b.maxSpeed, sweep: b.sweep === true, owner: b.move === 'owner' };
|
|
474
|
+
}
|
|
475
|
+
if (player && !body) throw new Error(`${at}: a player's kind needs a body`);
|
|
476
|
+
const moveFn = move && typeof move[name] === 'function' ? move[name] as MoveFn : null;
|
|
477
|
+
if (body && player && !moveFn) throw new Error(`${at}: a player's body moves with move.${name}(body, input, ctx) in move.ts`);
|
|
478
|
+
const scores = fields.filter(([, fd]) => fd.score);
|
|
479
|
+
if (scores.length > 1) throw new Error(`${at}.fields: one field is the score, not ${scores.length}`);
|
|
480
|
+
if (scores.length && (!player || !INTS.has(scores[0][1].t))) throw new Error(`${at}.fields.${scores[0][0]}: score: true goes on one whole-number field of a player's body`);
|
|
481
|
+
if (e.tick !== undefined && typeof e.tick !== 'function') throw new Error(`${at}.tick is a function`);
|
|
482
|
+
if (e.think !== undefined && typeof e.think !== 'function') throw new Error(`${at}.think is a function`);
|
|
483
|
+
if (e.guide !== undefined && (typeof e.guide !== 'object' || typeof e.guide?.view !== 'function')) throw new Error(`${at}.guide needs view(world, self)`);
|
|
484
|
+
const cells = cellsOfList(fields) + cellsOfList(motion);
|
|
485
|
+
if (cells > KIND_CELLS_MAX) throw new Error(`${at}: its fields and motion may hold ${cells} values when they are full, and one entity holds ${KIND_CELLS_MAX} at most`);
|
|
486
|
+
const k: KindTable = { name, index: kinds.length, player, fields, motion, input, body, score: scores[0]?.[0] ?? null, tick: e.tick ?? null, think: e.think ?? null, on, commands: cmds, onRoom, guide: e.guide ?? null, move: moveFn };
|
|
487
|
+
kinds.push(k);
|
|
488
|
+
kindOf[name] = k;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const room = d.room ?? {};
|
|
492
|
+
const roomOn = handlers<RoomHandler>(room.on, 'room.on');
|
|
493
|
+
for (const key of Object.keys(roomOn)) if (!events[key] && !ROOM_EVENTS.includes(key) && !SEAT_EVENTS.includes(key) && key !== 'answer' && key !== 'undeliverable') throw new Error(`room.on.${key}: no event "${key}" is declared in shapes.events`);
|
|
494
|
+
let rounds: Compiled['rounds'] = null;
|
|
495
|
+
if (room.rounds !== undefined) {
|
|
496
|
+
// A number is a number: nothing a module declares is ever turned into one.
|
|
497
|
+
const s = room.rounds?.seconds; const b = room.rounds?.breakSeconds;
|
|
498
|
+
if (typeof s !== 'number' || typeof b !== 'number' || !(s >= 0) || !(b >= 0)) throw new Error('room.rounds is { seconds, breakSeconds } (seconds: 0 means a round ends only when room scope ends it)');
|
|
499
|
+
rounds = { seconds: s, breakSeconds: b };
|
|
500
|
+
}
|
|
501
|
+
const keep = room.bots === undefined ? 0 : typeof room.bots?.keep === 'number' ? Math.floor(room.bots.keep) : NaN;
|
|
502
|
+
if (!(keep >= 0 && keep <= SEATS_MAX)) throw new Error(`room.bots is { keep: n }, 0 to ${SEATS_MAX}`);
|
|
503
|
+
if (room.start !== undefined && typeof room.start !== 'function') throw new Error('room.start is a function');
|
|
504
|
+
if (room.join !== undefined && typeof room.join !== 'function') throw new Error('room.join is a function');
|
|
505
|
+
const players = kinds.filter((k) => k.player);
|
|
506
|
+
if (players.length && typeof room.join !== 'function') throw new Error('room.join(ctx, player) says where a player\'s body starts: return { kind, at }');
|
|
507
|
+
const asks: Compiled['asks'] = {};
|
|
508
|
+
for (const [key, a] of Object.entries(d.asks ?? {})) {
|
|
509
|
+
if (!a || typeof a.floor !== 'function' || !a.questions || typeof a.questions !== 'object') throw new Error(`asks.${key} is { state, questions, floor(state) }`);
|
|
510
|
+
asks[key] = { ...a, stateFields: fieldList(a.state, `asks.${key}.state`) };
|
|
511
|
+
}
|
|
512
|
+
const seats = Math.max(1, Math.min(SEATS_MAX, Math.floor(Number(env.seats)) || 8));
|
|
513
|
+
return {
|
|
514
|
+
contract: RULES_CONTRACT, dims, kinds, kindOf, events, commands, effects, effectNames: Object.keys(effects),
|
|
515
|
+
shared: fieldList(d.shared, 'shared'), view, rounds, bots: Math.min(keep, seats), start: room.start ?? null, join: room.join ?? null, roomOn, asks,
|
|
516
|
+
tune, publicTune, map, settings, seats,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/* ------------------------------------------------------------------ what a view is handed */
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* The declarations a view needs to read a room's frames, with no code in them: the build puts this in the view's
|
|
524
|
+
* bundle, so none of the rules' handlers reach a browser with it.
|
|
525
|
+
*/
|
|
526
|
+
export interface Schema {
|
|
527
|
+
contract: 2;
|
|
528
|
+
dims: 2 | 3;
|
|
529
|
+
seats: number;
|
|
530
|
+
kinds: { name: string; player: boolean; owner: boolean; radius: number; maxSpeed: number; score: string | null; fields: FieldList; motion: FieldList; input: FieldList }[];
|
|
531
|
+
effects: Record<string, FieldList>;
|
|
532
|
+
effectNames: string[];
|
|
533
|
+
commands: Record<string, FieldList>;
|
|
534
|
+
shared: FieldList;
|
|
535
|
+
rounds: { seconds: number; breakSeconds: number } | null;
|
|
536
|
+
settings: RoomSettings;
|
|
537
|
+
}
|
|
538
|
+
export function schemaOf(c: Compiled): Schema {
|
|
539
|
+
return {
|
|
540
|
+
contract: RULES_CONTRACT, dims: c.dims, seats: c.seats,
|
|
541
|
+
kinds: c.kinds.map((k) => ({ name: k.name, player: Boolean(k.player), owner: Boolean(k.body?.owner), radius: k.body?.radius ?? 0, maxSpeed: k.body?.maxSpeed ?? 0, score: k.score, fields: k.fields, motion: k.motion, input: k.input })),
|
|
542
|
+
effects: c.effects, effectNames: c.effectNames, commands: c.commands, shared: c.shared, rounds: c.rounds, settings: c.settings,
|
|
543
|
+
};
|
|
544
|
+
}
|