@homie-rocks/studio 0.35.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +37 -1
  2. package/agents/agents.ts +8 -6
  3. package/bin/homie-studio.mjs +9 -6
  4. package/lab/lab.ts +15 -0
  5. package/lib/build.mjs +20 -15
  6. package/lib/dev.mjs +16 -1
  7. package/lib/lab-check.mjs +18 -3
  8. package/lib/lab.mjs +21 -7
  9. package/lib/mcp-tools.mjs +2 -1
  10. package/lib/rules-build.mjs +97 -49
  11. package/lib/rules-check-worker.mjs +405 -0
  12. package/lib/rules-check.mjs +69 -0
  13. package/lib/rules-compiler.mjs +30 -0
  14. package/lib/rules-guard.mjs +26 -7
  15. package/lib/rules-types.mjs +167 -0
  16. package/lib/shop-catalog.mjs +20 -1
  17. package/lib/shop-links.mjs +3 -1
  18. package/lib/shop.mjs +14 -8
  19. package/lib/standalone-files.mjs +2 -2
  20. package/lib/standalone.mjs +6 -2
  21. package/lib/stripe-connect.mjs +18 -7
  22. package/lib/template-history.json +83 -0
  23. package/lib/typecheck.mjs +52 -3
  24. package/netplay/NETPLAY.md +123 -6
  25. package/netplay/netplay.ts +289 -53
  26. package/netplay/rules-output.mjs +85 -0
  27. package/package.json +3 -2
  28. package/rules/browser.ts +100 -0
  29. package/rules/core.ts +159 -58
  30. package/rules/guard.ts +11 -4
  31. package/rules/host.ts +90 -15
  32. package/rules/math.ts +16 -0
  33. package/rules/pack.ts +94 -17
  34. package/rules/rules.ts +21 -13
  35. package/rules/types.ts +63 -0
  36. package/rules/view.ts +36 -33
  37. package/standalone/shell/shell.js +8 -6
  38. package/worker/brain.mjs +2 -1
  39. package/worker/hosted.mjs +1 -1
  40. package/worker/index.mjs +7 -11
  41. package/worker/limits.mjs +41 -0
  42. package/worker/pages.mjs +4 -2
  43. package/worker/room.mjs +136 -63
  44. package/worker/stripe.mjs +2 -2
  45. package/worker/version.mjs +1 -1
package/rules/types.ts ADDED
@@ -0,0 +1,63 @@
1
+ /** The build specialises these faces from a game's declarations; the runtime still checks every write. */
2
+ import type { Vec3, BodyDef, Fields } from './rules.ts';
3
+ import type { math } from './math.ts';
4
+
5
+ export type Point = { readonly x: number; readonly y: number; readonly z?: number };
6
+ export type ReadonlyState<T> = T extends object ? { readonly [K in keyof T]: ReadonlyState<T[K]> } : T;
7
+ export type WriteState<T> = T extends Vec3 ? keyof T extends keyof Vec3 ? Point : { [K in keyof T]: WriteState<T[K]> } : T extends object ? { [K in keyof T]: WriteState<T[K]> } : T;
8
+ type Payload<T> = {} extends T ? [data?: WriteState<T>] : [data: WriteState<T>];
9
+ type Query<T> = ReadonlyState<Omit<T, 'input'>>;
10
+ export interface Goal { readonly goal: string; readonly args: Readonly<Record<string, unknown>>; readonly from: 'brain' | 'floor'; readonly at: number; readonly asked: boolean; readonly state: 'active' | 'done' | 'failed' }
11
+ export interface GuideDecision { goal?: string | null; args?: Record<string, unknown>; say?: string | null; sayArgs?: Record<string, unknown> }
12
+ export interface GuideRequest { readonly k: string; readonly args: Readonly<Record<string, unknown>>; readonly from: number; readonly at: number }
13
+ export type GuideView<V, G = Goal, A = GuideRequest> = ReadonlyState<V> & { readonly goal: G | null; readonly asks: readonly A[] };
14
+ export interface Player<G = Goal> { readonly seat: number; readonly owner: string; readonly driver: 'person' | 'bot' | 'ai'; readonly away: boolean; readonly goal: G | null }
15
+ export type Self<F, M, I, K extends string, P extends boolean, G = Goal> = F & {
16
+ readonly id: string; readonly kind: K; readonly pos: Vec3; get vel(): Vec3; set vel(value: Point); get heading(): Vec3; set heading(value: Point); readonly grounded: boolean;
17
+ readonly motion: M; readonly input: ReadonlyState<I>;
18
+ } & (P extends true ? Player<G> : {});
19
+ export interface Hit { readonly entity?: string; readonly at: Point; readonly normal: Vec3; readonly dist?: number }
20
+ export type Area = { sphere: { at: Point; r: number } } | { box: { min: Point; max: Point } } | { cone: { at: Point; dir: Point; r: number; angle: number } };
21
+ export interface MapView { readonly name: string; spot(name: string): Vec3 | undefined; spots(name: string): readonly Vec3[] }
22
+ export interface Clock { readonly tick: number; readonly dt: number; ticks(seconds: number): number; readonly math: typeof math }
23
+ export interface Round { readonly n: number; readonly phase: 'live' | 'over'; readonly endsAt: number }
24
+ export type World<E, F, S, V, C, A, T, Room extends boolean = false> = Clock & {
25
+ readonly tune: ReadonlyState<T>; readonly map: MapView; readonly stage: string; readonly level: number; readonly levelMax: number;
26
+ readonly shared: Room extends true ? S : ReadonlyState<S>;
27
+ readonly round: Round & (Room extends true ? { end(): void } : {});
28
+ random(): number;
29
+ send<K extends keyof E>(target: string, event: K, ...data: Payload<E[K]>): void;
30
+ sendRoom<K extends keyof E>(event: K, ...data: Payload<E[K]>): void;
31
+ sendArea<K extends keyof E>(area: Area, event: K, ...data: Payload<E[K]>): void;
32
+ after<K extends keyof E>(ticks: number, event: K, ...data: Payload<E[K]>): void;
33
+ emit<K extends keyof F>(effect: K, at: Point | string, ...data: Payload<F[K]>): void;
34
+ spawn<K extends keyof C>(kind: K, at: Point, fields?: Partial<WriteState<C[K]>>): string;
35
+ near<K extends keyof V>(at: Point, radius: number, kind: K): readonly Query<V[K]>[];
36
+ near(at: Point, radius: number): readonly Query<V[keyof V]>[];
37
+ inBox<K extends keyof V>(box: { min: Point; max: Point }, kind: K): readonly Query<V[K]>[];
38
+ inBox(box: { min: Point; max: Point }): readonly Query<V[keyof V]>[];
39
+ ray(from: Point, direction: Point, max: number): (Hit & { readonly dist: number }) | undefined;
40
+ ask<K extends keyof A>(name: K, state: WriteState<A[K]>): boolean;
41
+ } & (Room extends true ? { announce<K extends keyof E>(event: K, ...data: Payload<E[K]>): void; finish(): void } : {
42
+ goalDone(ok?: boolean): void;
43
+ despawn(self: Exclude<V[keyof V], Player>): void;
44
+ place(self: V[keyof V], at: Point, options?: { vel?: Point; heading?: Point }): void;
45
+ sweep(self: V[keyof V], delta: Point, options?: { ignore?: readonly string[] }): Hit | undefined;
46
+ });
47
+ export type EventData<T> = ReadonlyState<T>;
48
+ export type Undeliverable<E> = { [K in keyof E]: { to: string; event: K; data: E[K] } }[keyof E];
49
+ export type BuiltIns<E = Record<string, never>> = { arrive: { why: 'join' | 'spawn' }; leave: {}; takeover: {}; undeliverable: Undeliverable<E>; answer: { ask: string; by: string; picks: Readonly<Record<string, unknown>>; why?: string } };
50
+ export type RoomEvents<E = Record<string, never>> = RoundEvents & { seatJoined: { seat: number; id: string; driver: 'person' | 'bot' | 'ai'; owner: string; took: boolean }; seatAway: { seat: number; id: string; away: boolean }; seatLeft: { seat: number; id: string }; answer: BuiltIns['answer']; undeliverable: BuiltIns<E>['undeliverable'] };
51
+ export type RoundEvents = { roundStart: { n: number }; roundOver: { n: number; results: readonly { seat: number; id: string; driver: 'person' | 'bot' | 'ai'; score: number; place: number }[] } };
52
+ type GuideWorld<W> = Pick<W, Extract<keyof W, 'tick' | 'dt' | 'ticks' | 'math' | 'map' | 'tune' | 'stage' | 'level' | 'levelMax' | 'round' | 'shared' | 'near' | 'inBox' | 'ray'>>;
53
+ export type Entity<W, S, I, E, C, V, Answer = BuiltIns['answer'], G = Goal, A = GuideRequest, D = GuideDecision> = {
54
+ player?: true | { away?: 'neutral' | 'think'; leave?: 'despawn' | 'bot' }; fields?: Fields; motion?: Fields; input?: Fields; body?: BodyDef;
55
+ tick?: (world: W, self: S) => void;
56
+ think?: (world: W, self: S) => Partial<I>;
57
+ on?: { [K in keyof (Omit<E, keyof BuiltIns> & Omit<BuiltIns<E>, 'answer'> & { answer: Answer })]?: (world: W, self: S, event: EventData<(Omit<E, keyof BuiltIns> & Omit<BuiltIns<E>, 'answer'> & { answer: Answer })[K]>) => void };
58
+ commands?: { [K in keyof C]?: (world: W, self: S, event: EventData<C[K]>) => void };
59
+ onRoom?: { [K in keyof (E & RoundEvents)]?: (world: W, self: S, event: EventData<(E & RoundEvents)[K]>) => void };
60
+ guide?: { view: (world: GuideWorld<W>, self: ReadonlyState<Omit<S, 'input'>>) => V; floor?: (world: W, self: S, view: GuideView<V, G, A>) => D };
61
+ };
62
+ export interface MoveBody<M> { get pos(): Vec3; set pos(value: Point); get vel(): Vec3; set vel(value: Point); get heading(): Vec3; set heading(value: Point); grounded: boolean; readonly motion: M }
63
+ export type MoveContext<T, M> = Clock & { readonly tune: ReadonlyState<T>; readonly map: MapView & { sweep(body: MoveBody<M>, delta: Point): Hit | undefined } };
package/rules/view.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { rulesOutput } from '../netplay/rules-output.mjs';
2
+ import { rulesCaps, rulesRates } from '../worker/limits.mjs';
1
3
  /*
2
4
  * view.ts — what a rules game's view imports: the room, as a browser sees it.
3
5
  * =============================================================================
@@ -6,10 +8,10 @@
6
8
  * import type rules from './rules';
7
9
  * const room = openRoom<typeof rules>();
8
10
  *
9
- * A game is rules plus view. The rules run on the server; the view draws what it is told and sends what the player
11
+ * A game is rules plus view. The rules run in one host runtime; the view draws what it is told and sends what the player
10
12
  * presses. `openRoom` starts the netplay helper (netplay/netplay.ts), joins the room the play page names and returns
11
13
  * at once: `room.status` moves from `connecting` to `playing`. The rules are imported for their types only, so none
12
- * of their code is in the view's bundle; the build hands this library their declarations as data (`setGame`), and it
14
+ * of their code is imported by the game's view; the build supplies declarations and an optional local host loader, and it
13
15
  * unpacks every frame from them. A view never packs or unpacks state.
14
16
  *
15
17
  * room.me the player's own body
@@ -39,19 +41,20 @@ import type { useAgents, Vocabulary, AskButton } from '../agents/agents.ts';
39
41
  let agentFactory: typeof useAgents | null = null;
40
42
  export function setAgentFactory(factory: typeof useAgents): void { agentFactory = factory; }
41
43
  import { createNetplay } from '../netplay/netplay.ts';
42
- import type { Netplay, NetplayOptions, RoundInfo, Snapshot, StepEntry } from '../netplay/netplay.ts';
44
+ import type { Netplay, NetplayOptions, RulesHostFactory, RoundInfo, Snapshot, StepEntry } from '../netplay/netplay.ts';
45
+ import { lab } from '../lab/lab.ts';
43
46
  import { exposePort } from '../port/probe.ts';
44
- import { BudgetError, G, brand } from './guard.ts';
45
- import { math, sweepMap } from './math.ts';
46
- import { coerce, coerceFields, dir, num, thawFields, unpackEntity, unpackFields, unpackVec, vec3 } from './pack.ts';
47
+ import { BudgetError } from './guard.ts';
48
+ import { moveContext } from './math.ts';
49
+ import { coerce, dir, stepMove, thawFields, unpackEntity, unpackFields, unpackVec, vec3 } from './pack.ts';
47
50
  import type { Unpacked } from './pack.ts';
48
51
  import type { FieldList, MoveFn, Schema, Vec3 } from './rules.ts';
49
52
 
50
53
  /** What the build hands the view library for one game: its declarations, its public tunables and its map. No code. */
51
54
  export interface GameData { vocab?: Vocabulary; id: string; schema: Schema; tune: Record<string, unknown>; map: { name?: string; bounds: { min: unknown; max: unknown }; boxes?: unknown[]; circles?: unknown[]; spots?: Record<string, unknown[]> } }
52
- let current: { game: GameData; move: Record<string, MoveFn> } | null = null;
55
+ let current: { game: GameData; move: Record<string, MoveFn>; load?: () => Promise<RulesHostFactory> } | null = null;
53
56
  /** Called by the build's own two lines at the top of a view's bundle, before the view's code runs. */
54
- export function setGame(game: GameData, move: Record<string, MoveFn> = {}): void { current = { game, move }; }
57
+ export function setGame(game: GameData, move: Record<string, MoveFn> = {}, load?: () => Promise<RulesHostFactory>): void { current = { game, move, load }; }
55
58
 
56
59
  /** An entity as a view reads it: the built-in fields, the kind's declared fields by name, and its `motion`. */
57
60
  export interface Entity {
@@ -82,7 +85,7 @@ export interface Room<R = unknown> {
82
85
  follow(id: string | null): void;
83
86
  /** Extra values for the testing tools' receipts (`window.__homiePort`). */
84
87
  probe(extra: Record<string, () => unknown>): void;
85
- /** A guide's ask buttons and a person's ask (today's relay frames; guides are driven by the server in a later release). */
88
+ /** A guide's ask buttons and a person's ask, handled by the room's rules host. */
86
89
  ask(id: string, askId: string, args?: Record<string, unknown>): void;
87
90
  askButtons(id: string, offer?: Record<string, unknown>): AskButton[];
88
91
  readonly tune: Readonly<Record<string, unknown>>;
@@ -110,8 +113,8 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
110
113
  const period = 1000 / tickHz;
111
114
  const sendEvery = Math.ceil(tickHz / Math.max(1, schema.settings.inputHz));
112
115
  const clock = opts.now ?? ((): number => (typeof performance !== 'undefined' ? performance.now() : Date.now()));
113
- const net = createNetplay<unknown, unknown, unknown>({ game: game.id, maxPlayers: schema.seats, snapshotHz: tickHz, ...opts.net, rules: true });
114
- const agents = game.vocab && agentFactory ? agentFactory(net, game.vocab, { roles: ['guide', 'party'], view: () => ({}), manual: true }) : null;
116
+ const net = createNetplay<unknown, unknown, unknown>({ game: game.id, maxPlayers: schema.seats, snapshotHz: tickHz, heartbeatMs: 0, rulesHost: { mode: schema.settings.host, offline: schema.settings.offline, load: current?.load }, ...opts.net, rules: true, rulesLimits: { output: rulesOutput, bytes: rulesCaps(schema.seats), rates: rulesRates(tickHz, schema.seats) } });
117
+ const agents = game.vocab && agentFactory ? agentFactory(net, game.vocab, { roles: ['guide', 'party'], view: () => ({}), manual: true, viewOnly: true }) : null;
115
118
  const kindOf = new Map(schema.kinds.map((k) => [k.name, k]));
116
119
  const listeners = new Map<string, Set<(e: any) => void>>();
117
120
  const emit = (name: string, e: unknown): void => { for (const fn of listeners.get(name) ?? []) { try { fn(e); } catch (err) { console.warn(`[room] on('${name}')`, err); } } };
@@ -119,7 +122,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
119
122
  const map = { bounds: { min: vecOf(game.map.bounds.min), max: vecOf(game.map.bounds.max) }, boxes: (game.map.boxes ?? []).map((b: any) => ({ min: vecOf(b.min), max: vecOf(b.max) })), circles: (game.map.circles ?? []).map((c: any) => ({ at: vecOf(c.at), r: Number(c.r) })) };
120
123
  const spots: Record<string, readonly Vec3[]> = {};
121
124
  for (const [key, list] of Object.entries(game.map.spots ?? {})) spots[key] = Object.freeze(list.map(vecOf));
122
- const tune = Object.freeze({ ...game.tune });
125
+ const tune = Object.freeze({ ...(lab.rulesTune({ public: game.tune }).public as Record<string, unknown>) });
123
126
 
124
127
  let status: RoomStatus = 'connecting';
125
128
  let latest: Frame | null = null;
@@ -167,12 +170,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
167
170
  const tickAt = (now: number): number => (base ? base.tick + (now - base.at) / period : 0);
168
171
  /** The tick `move` is being run for: the step just taken, or the one after it while the own body is drawn between ticks. */
169
172
  let moveTick = 0;
170
- const moveCtx = brand(Object.freeze({
171
- get tick() { return moveTick; }, dt: 1 / tickHz, tune, math,
172
- // As the server's `ctx.ticks` reads it: a plain number, or nothing.
173
- 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; },
174
- map: brand(Object.freeze({ name: game.map.name ?? 'main', spot: (name: string) => spots[name]?.[0], spots: (name: string) => spots[name] ?? Object.freeze([]), sweep: (body: any, delta: unknown) => sweepMap(map, body, delta, myKind()?.radius ?? 0, dims) })),
175
- }));
173
+ const moveCtx = moveContext({ tick: () => moveTick, tickHz, tune, map, name: game.map.name ?? 'main', spots, radius: () => myKind()?.radius ?? 0, dims });
176
174
  function rebase(k: number): void {
177
175
  const rtt = net.stats().rtt ?? 100;
178
176
  base = { tick: k + Math.ceil(rtt / period + TARGET_LEAD) + sendEvery - 1, at: clock() };
@@ -190,12 +188,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
190
188
  const fn = moves[kindName];
191
189
  if (!fn) return body;
192
190
  moveTick = t;
193
- G.left = Math.max(1, Math.floor(schema.settings.budget.tick / 4));
194
- try { fn(body, input, moveCtx); } catch (err) { if (!(err instanceof BudgetError)) console.warn('[room] move', err); }
195
- G.left = Infinity;
196
- // Rounded to 32-bit floats and held to the declared shapes, as the server does it: both hold the same numbers.
197
- const list = kindOf.get(kindName)?.motion ?? [];
198
- return { pos: vec3(body.pos, dims), vel: vec3(body.vel, dims), heading: dir(body.heading, dims), grounded: body.grounded === true, motion: thawFields(list, coerceFields(list, body.motion, dims)) };
191
+ return stepMove(fn, body, input, moveCtx, Math.max(1, Math.floor(schema.settings.budget.tick / 4)), kindOf.get(kindName)?.motion ?? [], dims, (err) => { if (!(err instanceof BudgetError)) console.warn('[room] move', err); });
199
192
  }
200
193
  /** The input values the last step held (a press is never held). */
201
194
  let held: Readonly<Record<string, unknown>> = Object.freeze({});
@@ -243,12 +236,12 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
243
236
  }
244
237
  function pump(): void {
245
238
  if (closed) return;
246
- if (net.offline) setStatus(net.closedWhy ? 'closed' : 'offline');
239
+ if (net.offline && !net.rulesHosting) setStatus(net.closedWhy ? 'closed' : 'offline');
247
240
  const now = clock();
248
241
  const hidden = typeof document !== 'undefined' && document.hidden === true;
249
242
  // Effects play for everyone who draws the room: a watcher has no body and still sees them.
250
243
  fire();
251
- if (!mine || !base || net.seat === null || !net.connected) return;
244
+ if (!mine || !base || net.seat === null || (!net.connected && !net.rulesHosting)) return;
252
245
  if (hidden) {
253
246
  // A hidden tab's timers are too slow to keep input alive, and a body must not run on without its player:
254
247
  // one neutral entry at once (every field at its init, no press), then no more steps until the tab shows again.
@@ -306,7 +299,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
306
299
  function roundNow(): RoomRound | null {
307
300
  if (!round || !latest) return null;
308
301
  // The room's clock now, from the newest snapshot: a view never turns a tick into time itself.
309
- const serverTick = latest.k + (net.connected ? (clock() - latest.at) / period : 0);
302
+ const serverTick = latest.k + ((net.connected || net.rulesHosting) ? (clock() - latest.at) / period : 0);
310
303
  return { ...round, secondsLeft: round.endsAt ? Math.max(0, (round.endsAt - serverTick) / tickHz) : Infinity, ...(results ? { results } : {}) };
311
304
  }
312
305
  /** Effects wait for the tick they were emitted on to be drawn (an effect on the player's own body plays at once). */
@@ -323,7 +316,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
323
316
  }
324
317
 
325
318
  net.on('snapshot', onSnapshot);
326
- net.on('role', (e) => { if (e.snap) onSnapshot(e.snap); if (net.offline) setStatus(net.closedWhy ? 'closed' : 'offline'); });
319
+ net.on('role', (e) => { if (e.snap) onSnapshot(e.snap); if (net.offline && !net.rulesHosting) setStatus(net.closedWhy ? 'closed' : 'offline'); });
327
320
  net.on('link', (e) => {
328
321
  if (e.state === 'online') { base = null; if (status !== 'playing') setStatus('connecting'); } else if (e.state === 'closed') setStatus('closed'); else if (e.state === 'alone' || e.state === 'offline') setStatus('offline');
329
322
  });
@@ -335,10 +328,20 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
335
328
  return;
336
329
  }
337
330
  // A player's speech and emotes, and a guide's lines and goals, as the relay carries them today.
338
- if (/^(?:say|chat|emote)/i.test(e.k) && !(agents && (e.d as { ai?: boolean })?.ai)) emit('say', { kind: e.k, seat: e.from, ...(e.d && typeof e.d === 'object' ? e.d as object : { text: e.d }) });
339
- else if (e.k === 'agent:goal' && e.from === net.host?.seat) emit('goal', e.d);
340
- else if (e.k === 'agent:ask' && e.from === net.host?.seat) emit('ask', e.d);
341
- else if (/^ask:/.test(e.k)) emit('ask', { ask: e.k.slice(4), ...(e.d && typeof e.d === 'object' ? e.d as object : {}) });
331
+ if (/^(?:say|chat|emote)/i.test(e.k) && !(agents && (e.d as { ai?: boolean })?.ai)) {
332
+ const data = e.d && typeof e.d === 'object' ? e.d as Record<string, unknown> : { text: e.d };
333
+ emit('say', { ...data, kind: e.k, seat: e.from, slot: -1, line: typeof data.line === 'string' ? data.line : undefined, text: typeof data.text === 'string' ? data.text : '', args: {} });
334
+ }
335
+ else if (e.k === 'agent:goal' && e.from === null) {
336
+ const data = e.d as import('../agents/agents.ts').GoalEvent;
337
+ const goal = (g: import('../agents/agents.ts').Goal) => ({ ...g, asked: g.asked === true });
338
+ emit('goal', { ...data, goal: goal(data.goal), prev: data.prev ? goal(data.prev) : null });
339
+ }
340
+ else if (e.k === 'agent:ask' && e.from === null) emit('ask', e.d);
341
+ else if (/^ask:/.test(e.k)) {
342
+ const data = e.d && typeof e.d === 'object' ? e.d as Record<string, unknown> : {};
343
+ emit('ask', { ...data, ask: e.k.slice(4), k: e.k.slice(4), slot: typeof data.slot === 'number' ? data.slot : -1, from: e.from, at: clock(), args: data.args && typeof data.args === 'object' ? data.args : {} });
344
+ }
342
345
  });
343
346
 
344
347
  agents?.on('say', (e) => emit('say', e));
@@ -406,7 +409,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
406
409
  self: () => { const m = meNow(); return m ? { x: m.pos.x, y: m.pos.y, z: m.pos.z } : null; },
407
410
  peer: (seat: number) => { if (!latest) return null; for (const u of latest.ents.values()) if (u.seat === seat) return { x: u.pos.x, y: u.pos.y, z: u.pos.z }; return null; },
408
411
  scores: () => roster().map((r) => ({ seat: r.seat, score: r.score })),
409
- score: myScore, tick: () => latest?.k ?? 0, epoch: () => epoch, hosted: () => 'server', status: () => status,
412
+ score: myScore, tick: () => latest?.k ?? 0, epoch: () => epoch, hosted: () => net.rulesHosting ? 'browser' : schema.settings.host, status: () => status,
410
413
  });
411
414
  if (typeof window !== 'undefined') {
412
415
  exposePort(net as Netplay<unknown, unknown, unknown>, {
@@ -84,20 +84,22 @@
84
84
  /** The room's address on the studio's site: a friend opens it in any browser and lands in this room. */
85
85
  function linkOf(room) { return site + '/' + game + '/play?room=' + encodeURIComponent(room); }
86
86
 
87
+ var canOffline = !app.room || app.room.host === 'browser' || app.room.offline !== false;
88
+ noticeEl.querySelector('[data-notice-offline]').hidden = !canOffline;
87
89
  // What the button and its sheet say: the room, or plainly that this copy plays offline.
88
90
  function paint() {
89
91
  var alone = !state.online || (state.link && state.link.state === 'alone');
90
- var word = !state.starts ? 'Finding a room…' : alone ? 'Playing offline · Try again' : state.link && state.link.state === 'reconnecting' ? 'Reconnecting…' : labelOf(state.room);
92
+ var word = !state.starts ? 'Finding a room…' : alone ? (canOffline ? 'Playing offline · Try again' : 'Connection needed · Try again') : state.link && state.link.state === 'reconnecting' ? 'Reconnecting…' : labelOf(state.room);
91
93
  ui.querySelector('[data-room-code]').textContent = word;
92
94
  ui.querySelector('[data-room-label]').textContent = state.online ? labelOf(state.room) : 'Offline';
93
95
  ui.querySelector('[data-room-state]').textContent = !site ? 'This copy has no online address' : alone ? 'No room answered' : '';
94
96
  // A copy the site has outgrown cannot invite anybody: a friend's browser would land on the newer version, which
95
97
  // this copy cannot join. The line about updating stands where the invite was.
96
- ui.querySelector('[data-room-link]').textContent = state.outdated ? STALE : state.online ? linkOf(state.room).replace(/^https?:\/\//, '') : 'Your game and its bots are on this device.';
98
+ ui.querySelector('[data-room-link]').textContent = state.outdated ? STALE : state.online ? linkOf(state.room).replace(/^https?:\/\//, '') : (canOffline ? 'Your game and its bots are on this device.' : 'This game needs a connection.');
97
99
  ui.querySelector('[data-invite]').hidden = !state.online || state.outdated;
98
100
  ui.querySelector('[data-quick]').textContent = alone ? 'Try again' : 'Quick play';
99
101
  if (alone) ui.querySelector('[data-quick]').classList.add('primary'); else ui.querySelector('[data-quick]').classList.remove('primary');
100
- ui.querySelector('[data-offline]').hidden = !state.online;
102
+ ui.querySelector('[data-offline]').hidden = !state.online || !canOffline;
101
103
  wake();
102
104
  tellRects();
103
105
  }
@@ -113,7 +115,7 @@
113
115
  try { if (typeof AbortController === 'function') { var ac = new AbortController(); opts.signal = ac.signal; setTimeout(function () { try { ac.abort(); } catch (e) {} }, 6000); } } catch (e) {}
114
116
  return fetch(site + '/' + game + '/api/lobby' + (q.length ? '?' + q.join('&') : ''), opts)
115
117
  .then(function (r) { return r.json(); })
116
- .then(function (j) { return j && typeof j.room === 'string' && ROOM.test(j.room) ? j.room : null; })
118
+ .then(function (j) { if (j && j.error === 'stale') { state.outdated = true; flash(STALE); } return j && typeof j.room === 'string' && ROOM.test(j.room) ? j.room : null; })
117
119
  .catch(function () { return null; });
118
120
  }
119
121
 
@@ -165,7 +167,7 @@
165
167
  lobby().then(function (room) {
166
168
  state.finding = false;
167
169
  if (state.starts !== asked) return;
168
- if (room) start(room); else { flash('No room answered: still playing offline.'); paint(); }
170
+ if (room) start(room); else { flash(state.outdated ? STALE : canOffline ? 'No room answered: still playing offline.' : 'This game needs a connection.'); paint(); }
169
171
  });
170
172
  return;
171
173
  }
@@ -184,7 +186,7 @@
184
186
  function go() { if (state.finding) return; var kept = remembered(); hide(); if (kept && site) start(kept); else find(); }
185
187
  function join(code) {
186
188
  if (!ROOM.test(code)) { flash('A room code is 1 to 32 letters, digits, - or _.'); return false; }
187
- if (!site) { flash('This copy has no online address: it plays offline.'); return false; }
189
+ if (!site) { flash(canOffline ? 'This copy has no online address: it plays offline.' : 'This game needs an online address and a connection.'); return false; }
188
190
  remember(code); hide(); open(false); start(code);
189
191
  return true;
190
192
  }
package/worker/brain.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { LIMITS } from './limits.mjs';
1
2
  /**
2
3
  * BRAINS (@homie-rocks/studio 0.17.0, NETPLAY.md section 18): what an AI guide decides, a few seconds at a time, and
3
4
  * only ever in the game's own words. Pure: no Worker import and no Node built-in, so the relay (room.mjs), the Table's
@@ -649,7 +650,7 @@ export async function clefDecide(env, { vocab, ctx = {}, model, ms = 8000, local
649
650
  * 8 questions; Choice 2 to 26 options, Score 2 to 10 levels, a yes/no; short words
650
651
  * picksOf(answers, questions) the answers as the game uses them: { tactic: 'surround', wave: false, pressure: 2.6 }
651
652
  */
652
- export const DECIDE = Object.freeze({ questions: 8, options: 26, levels: 10, words: 160, option: 40, stateBytes: 2048, bytes: 6144, gapMs: 3000, perMinute: 20, inFlight: 2, ms: 2500 });
653
+ export const DECIDE = Object.freeze({ questions: 8, options: 26, levels: 10, words: 160, option: 40, stateBytes: 2048, bytes: LIMITS.decide, gapMs: 3000, perMinute: 20, inFlight: 2, ms: 2500 });
653
654
  const QID = /^[A-Za-z][A-Za-z0-9_.-]{0,39}$/;
654
655
  const OPT = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,39}$/;
655
656
 
package/worker/hosted.mjs CHANGED
@@ -49,7 +49,7 @@ export function compiledFor(id) {
49
49
  */
50
50
  export function startHost(game, { send, log, onPause, onResume, onEnd, ...recovery }) {
51
51
  return createHost({
52
- game, build: games.get(game)?.build ?? undefined, compiled: compiledFor(game), send, log, onPause, onResume, onEnd,
52
+ enforceOverrun: true, game, build: games.get(game)?.build ?? undefined, compiled: compiledFor(game), send, log, onPause, onResume, onEnd,
53
53
  clock: { now: () => Date.now(), setTimer: (fn, ms) => setTimeout(fn, ms), clearTimer: (h) => clearTimeout(h) },
54
54
  ...recovery,
55
55
  });
package/worker/index.mjs CHANGED
@@ -1148,7 +1148,7 @@ async function route(request, env, ctx) {
1148
1148
  const cur = versionOf(meta?.netplay?.version) ?? '';
1149
1149
  const gv = versionOf(url.searchParams.get('gv')) ?? '';
1150
1150
  const stall = Number(meta?.netplay?.stallMs) || 0;
1151
- const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}${b ? `&b=${b}` : ''}${who ? `&via=${encodeURIComponent(who)}` : ''}${sub === '__net' ? `&wp=${policy}${w ? '&w=1' : ''}` : ''}&pol=${encodeFacts(pol)}&chat=${encodeFacts(lean)}${acct ? '&acct=1' : ''}${member ? '&mem=1' : ''}${hub ? '&hub=1' : ''}${ag ? `&ag=${encodeFacts(ag)}` : ''}${badge ? `&bd=${encodeURIComponent(badge)}` : ''}&cur=${encodeURIComponent(cur)}${gv ? `&gv=${encodeURIComponent(gv)}` : ''}${stall ? `&stall=${stall}` : ''}${meta?.room?.host === 'server' ? '&host=server' : ''}`;
1151
+ const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}${b ? `&b=${b}` : ''}${who ? `&via=${encodeURIComponent(who)}` : ''}${sub === '__net' ? `&wp=${policy}${w ? '&w=1' : ''}` : ''}&pol=${encodeFacts(pol)}&chat=${encodeFacts(lean)}${acct ? '&acct=1' : ''}${member ? '&mem=1' : ''}${hub ? '&hub=1' : ''}${ag ? `&ag=${encodeFacts(ag)}` : ''}${badge ? `&bd=${encodeURIComponent(badge)}` : ''}&cur=${encodeURIComponent(cur)}${gv ? `&gv=${encodeURIComponent(gv)}` : ''}${stall ? `&stall=${stall}` : ''}${meta?.room?.contract ? `&rules=1&hz=${meta.room.tickHz ?? 20}` : ''}${meta?.room?.host === 'server' ? '&host=server' : ''}`;
1152
1152
  // The room's own failure to answer is told apart from the browser going away while it was connecting (a closed
1153
1153
  // tab, a reload racing its own socket): the second is a departure, said in one line with its room, never an
1154
1154
  // uncaught error with nothing on it.
@@ -1388,7 +1388,7 @@ export class Table {
1388
1388
  send: (m, text) => room.hostFrame(m, text),
1389
1389
  log: (line) => { if (line.ev === 'persist-failed') this.storageProblem(new Error(line.error)); else this.say(line); },
1390
1390
  onPause: () => { this.pauseFromRestore = false; this.pausedAt ??= Date.now(); this.armRoom(this.pausedAt + ROOM_PAUSE_MS); },
1391
- onResume: () => { this.pauseFromRestore = false; this.pausedAt = null; if (this.hostRt) { try { this.saveRoom(this.hostRt.save()); } catch { /* retry already scheduled */ } } this.retimeRound(); this.armRoom(Date.now() + ROOM_ALARM_MS); },
1391
+ onResume: () => { this.pauseFromRestore = false; this.pausedAt = null; if (this.hostRt) { try { this.saveRoom(this.hostRt.save()); } catch { /* retry already scheduled */ } } this.armRoom(Date.now() + ROOM_ALARM_MS); },
1392
1392
  onEnd: (why) => this.endHosted(why),
1393
1393
  });
1394
1394
  room.setServerHost(this.hostRt);
@@ -1396,7 +1396,6 @@ export class Table {
1396
1396
  room.setCurrent(build.build);
1397
1397
  if (pauseWriteFailed) { try { this.saveRoom(this.hostRt.save()); } catch { /* the save retry retains the original absence */ } }
1398
1398
  if (saved) {
1399
- this.retimeRound();
1400
1399
  room.hostFrame({ t: 'state', k: 'shared', d: this.hostRt.core.shared() });
1401
1400
  room.hostFrame({ t: 'snap', from: null, e: epoch, k: this.hostRt.tick, st: Date.now(), d: this.hostRt.core.snapshot(), c: [] });
1402
1401
  }
@@ -1446,13 +1445,6 @@ export class Table {
1446
1445
  }
1447
1446
  }
1448
1447
 
1449
- retimeRound() {
1450
- if (!this.hostRt) return;
1451
- const r = this.hostRt.core.save().round;
1452
- const period = 1000 / hostedBuild(this.game).settings.tickHz;
1453
- this.room.hostFrame({ t: 'round', round: { ...this.room.lastRound, n: r[0], phase: r[1] ? 'live' : 'over', endsAt: Date.now() + (r[2] - this.hostRt.tick) * period, startedAt: Date.now() + (r[3] - this.hostRt.tick) * period } });
1454
- }
1455
-
1456
1448
  storageProblem(error) {
1457
1449
  this.storageHealth = { ok: false, message: String(error?.message ?? error).slice(0, 160), since: this.storageHealth?.ok === false ? this.storageHealth.since : Date.now() };
1458
1450
  if (Date.now() < (this.storageLogAt ?? 0)) return;
@@ -1908,8 +1900,12 @@ export class Table {
1908
1900
  this.report();
1909
1901
  return json(res);
1910
1902
  }
1903
+ room.rules = url.searchParams.get('rules') === '1';
1904
+ // Any tick rate a game may declare (1 to 60): the relay's allowances are the page's, from the same table.
1905
+ const hz = Number(url.searchParams.get('hz'));
1906
+ room.tickHz = Number.isInteger(hz) && hz >= 1 && hz <= 60 ? hz : 20;
1911
1907
  // The owner's room size: a room already open takes it from the next visitor on.
1912
- if (room.seatCap !== max) room.setSeats(max, perAddress(max));
1908
+ if (room.seatCap !== max || room.rules && room.maxPlayers !== max) room.setSeats(max, perAddress(max));
1913
1909
  // Revision 9: the game's own stall time and the build that is live now, the Worker's word with every socket.
1914
1910
  const said = url.searchParams.has('cur');
1915
1911
  if (said) { room.setStall(url.searchParams.get('stall')); room.setCurrent(url.searchParams.get('cur') || null); }
@@ -0,0 +1,41 @@
1
+ /** Size caps for up to 16 seats: UTF-8 bytes in rules rooms, JSON string length in legacy rooms. */
2
+ export const LIMITS = Object.freeze({
3
+ hello: 2048, snap: 16384, in: 2048, ev: 4096, ckpt: 65536, state: 8192, round: 8192, roster: 4096, ping: 256, other: 512,
4
+ // Room chat (revision 8): a typed line (280 characters, emoji count double), a reaction.
5
+ say: 1536, react: 256,
6
+ // A game's own decision (section 20): a few typed questions about its state.
7
+ decide: 6144,
8
+ });
9
+ /**
10
+ * What grows with the seats (every body, every result, every slot): the checkpoint, round and roster caps double
11
+ * for a room of 17 to 32 (revision 3). Measured: a 32-seat courier game's checkpoint reached 56 KB of the 64 KB
12
+ * a 16-seat room allows. The snapshot cap does not grow: 32 bodies are about 1.5 KB.
13
+ */
14
+ export const GROWS = Object.freeze(['ckpt', 'round', 'roster']);
15
+ export const capOf = (t, seats) => (LIMITS[t] ?? LIMITS.other) * (GROWS.includes(t) && seats > 16 ? 2 : 1);
16
+
17
+ export const RATES = Object.freeze({ snap: 30, in: 60, ev: 30, ckpt: 4, state: 64, round: 4, roster: 8, ping: 8, other: 8, say: 4, react: 10, decide: 2 });
18
+ /** `ev` per second by the sender's role: a screen is a spectator, and the host is somebody's phone. */
19
+ export const EV_RATES = Object.freeze({ host: 30, replica: 10, screen: 2 });
20
+ /** The keyed state channel: at most this many keys and bytes per room. */
21
+ export const STATE_CAPS = Object.freeze({ keys: 64, bytes: 65536 });
22
+ /** Runtime output only. Player allowances above remain unchanged. */
23
+ export const RULES_FRAMES = Object.freeze(['snap', 'ckpt', 'state', 'ev', 'round', 'roster', 'decide', 'caps', 'rules-end']);
24
+ export const rulesRates = (tickHz = 20, seats = 8) => ({
25
+ // A snapshot a tick, as the server sends, and a quarter over for the ticks a late timer runs together.
26
+ snap: Math.max(30, Math.ceil(tickHz * 1.25)), ckpt: 4, state: tickHz + 64,
27
+ ev: Math.ceil((tickHz + seats * EV_RATES.replica + 32) / 0.8), round: 8,
28
+ // A decision a tick is what one declared ask can make (the next waits for the answer), as on a server host.
29
+ roster: seats * 2 + 8, decide: Math.ceil((tickHz + seats * 5 + 8) / 0.8), caps: 4, 'rules-end': 4,
30
+ });
31
+ export const rulesCaps = seats => Object.fromEntries(RULES_FRAMES.map(t => [t, capOf(t, seats)]));
32
+ /** Two seconds of headroom absorb timer stalls and transport batching. */
33
+ export function takeRulesToken(buckets, kind, rate, now, burst = rate * 2) {
34
+ let b = buckets.get(kind);
35
+ if (!b) { b = { tokens: burst, at: now }; buckets.set(kind, b); }
36
+ b.tokens = Math.min(burst, b.tokens + Math.max(0, now - b.at) * rate / 1000);
37
+ b.at = now;
38
+ if (b.tokens + 1e-9 < 1) return Math.ceil((1 - b.tokens) * 1000 / rate);
39
+ b.tokens = Math.max(0, b.tokens - 1);
40
+ return 0;
41
+ }
package/worker/pages.mjs CHANGED
@@ -565,7 +565,8 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
565
565
  if (m.what === 'roster') state.roster = m.slots;
566
566
  // Stopped for good. Removed, or the room closed: the notice. A room with no place left at all: the same notice
567
567
  // with its own words, because "try another room" is the only thing a player can do about it.
568
- if (m.what === 'closed') { state.closed = m.why; if (m.why === 'kicked' || m.why === 'room-closed' || m.why === 'agents-off') notice(m.why === 'kicked' ? 'kicked' : 'closed', m); else if (m.why === 'room-full' || m.why === 'too-many') notice('full', m); }
568
+ if (m.what === 'line') state.gameLine = typeof m.text === 'string' ? m.text.slice(0, 200) : null;
569
+ if (m.what === 'closed') { state.closed = m.why; if (typeof m.message === 'string') state.gameLine = m.message.slice(0, 200); if (m.why === 'kicked' || m.why === 'room-closed' || m.why === 'agents-off') notice(m.why === 'kicked' ? 'kicked' : 'closed', m); else if (m.why === 'room-full' || m.why === 'too-many') notice('full', m); }
569
570
  // A game's net.pickPlayer(seat): only an owner's page listens (its overlay opens that player's card).
570
571
  if (m.what === 'pick' && (m.seat === null || typeof m.seat === 'number')) { try { window.dispatchEvent(new CustomEvent('homie-pick', { detail: { seat: m.seat } })); } catch (e) {} }
571
572
  paint();
@@ -775,7 +776,8 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
775
776
  if (f && f.counts && f.counts.watchers) bits.push(f.counts.watchers + ' watching');
776
777
  if (state.full) bits.push('waiting for a seat');
777
778
  // Stopped for good: nothing is knocking any more, so it never says "reconnecting". What is true, in a word.
778
- if (state.closed) bits.push({ replaced: 'opened in another tab', 'room-full': 'room full', 'too-many': 'room full', stale: 'updating…', kicked: 'removed from this room', 'room-closed': 'room closed', 'agents-off': 'room closed' }[state.closed] || 'disconnected: reload to play');
779
+ if (state.gameLine) bits.push(state.gameLine);
780
+ else if (state.closed) bits.push({ replaced: 'opened in another tab', 'room-full': 'room full', 'too-many': 'room full', stale: 'updating…', kicked: 'removed from this room', 'room-closed': 'room closed', 'agents-off': 'room closed' }[state.closed] || 'disconnected: reload to play');
779
781
  // The link (section 22): cut off from a room it was in and knocking again ("reconnecting" is true only then), or
780
782
  // playing by itself because the room never answered, or did not come back in time.
781
783
  else if (state.link && state.link.state === 'reconnecting') bits.push('reconnecting');