@homie-rocks/studio 0.35.1 → 0.37.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 (89) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +4 -2
  3. package/agents/agents.ts +8 -6
  4. package/app-starters/welcome/app.json +106 -0
  5. package/app-starters/welcome/index.html +4 -0
  6. package/app-starters/welcome/src/main.ts +107 -0
  7. package/apps/APPS.md +61 -0
  8. package/apps/apps.ts +28 -0
  9. package/bin/homie-studio.mjs +22 -10
  10. package/lab/lab.ts +15 -0
  11. package/lib/art-cli.mjs +2 -2
  12. package/lib/art-tools.mjs +6 -6
  13. package/lib/asset-check.mjs +3 -2
  14. package/lib/asset-import.mjs +3 -2
  15. package/lib/asset-manifest.mjs +8 -8
  16. package/lib/build.mjs +26 -18
  17. package/lib/characters.mjs +7 -7
  18. package/lib/check.mjs +49 -0
  19. package/lib/cloudflare.mjs +1 -1
  20. package/lib/codex.mjs +2 -2
  21. package/lib/collision.mjs +1 -1
  22. package/lib/decisions.mjs +7 -7
  23. package/lib/dev.mjs +42 -10
  24. package/lib/directory.mjs +1 -1
  25. package/lib/doctor.mjs +1 -1
  26. package/lib/handoff.mjs +1 -1
  27. package/lib/lab-check.mjs +18 -3
  28. package/lib/lab.mjs +29 -15
  29. package/lib/library.mjs +2 -2
  30. package/lib/mcp-tools.mjs +23 -3
  31. package/lib/parts-build.mjs +4 -3
  32. package/lib/parts-cli.mjs +3 -3
  33. package/lib/parts-store.mjs +8 -7
  34. package/lib/parts-tools.mjs +7 -7
  35. package/lib/parts.mjs +19 -14
  36. package/lib/perf.mjs +2 -1
  37. package/lib/player-image.mjs +2 -2
  38. package/lib/rules-build.mjs +97 -49
  39. package/lib/rules-check-worker.mjs +405 -0
  40. package/lib/rules-check.mjs +69 -0
  41. package/lib/rules-compiler.mjs +30 -0
  42. package/lib/rules-guard.mjs +26 -7
  43. package/lib/rules-types.mjs +167 -0
  44. package/lib/scaffold.mjs +21 -2
  45. package/lib/shop.mjs +1 -1
  46. package/lib/standalone-files.mjs +2 -2
  47. package/lib/standalone.mjs +12 -3
  48. package/lib/stats.mjs +2 -1
  49. package/lib/studio.mjs +32 -6
  50. package/lib/style-board.mjs +10 -9
  51. package/lib/template-history.json +85 -1
  52. package/lib/typecheck.mjs +52 -3
  53. package/links/links.d.ts +3 -0
  54. package/links/links.mjs +16 -0
  55. package/netplay/NETPLAY.md +123 -6
  56. package/netplay/netplay.ts +289 -53
  57. package/netplay/rules-output.mjs +85 -0
  58. package/package.json +16 -4
  59. package/parts/PARTS.md +10 -5
  60. package/rules/browser.ts +100 -0
  61. package/rules/core.ts +159 -58
  62. package/rules/guard.ts +11 -4
  63. package/rules/host.ts +90 -15
  64. package/rules/math.ts +16 -0
  65. package/rules/pack.ts +94 -17
  66. package/rules/rules.ts +21 -13
  67. package/rules/types.ts +63 -0
  68. package/rules/view.ts +36 -33
  69. package/shop/SHOP.md +13 -0
  70. package/site/SITE.md +1 -1
  71. package/standalone/shell/shell.js +8 -6
  72. package/worker/account-page.mjs +4 -3
  73. package/worker/app-format.mjs +42 -0
  74. package/worker/app-records.mjs +100 -0
  75. package/worker/app-shell.mjs +29 -0
  76. package/worker/brain.mjs +2 -1
  77. package/worker/embed.mjs +4 -2
  78. package/worker/hosted.mjs +1 -1
  79. package/worker/index.mjs +40 -20
  80. package/worker/limits.mjs +41 -0
  81. package/worker/pages.mjs +37 -29
  82. package/worker/parts.mjs +11 -9
  83. package/worker/room.mjs +136 -63
  84. package/worker/schema.mjs +6 -5
  85. package/worker/seats.mjs +2 -2
  86. package/worker/shop-page.mjs +4 -3
  87. package/worker/site.mjs +49 -40
  88. package/worker/standalone.mjs +2 -2
  89. 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>, {
package/shop/SHOP.md CHANGED
@@ -201,3 +201,16 @@ Game and TV checkout use the same tab if a new tab is unavailable, then return t
201
201
  During rollback, the released Worker grants only a cart’s first item until the new Worker repairs it; it
202
202
  allows whole-cart player refunds even when a tip is included, and counts partially refunded carts at
203
203
  the full amount toward a cap. The additive schema preserves its original rows and entitlement keys.
204
+
205
+ ## Shop button on the screen
206
+
207
+ An open shop with items for this game or app shows **Shop** above its play/open screen. The shell reserves 52 pixels plus the safe area above the content, so the button never covers its controls. In `game.json` or `app.json`, set `"screen": { "shop": false }` to hide this button (merge with any existing screen settings). The room panel and your own shop buttons still work. Closed shops, kids policies and an empty selection show no button.
208
+
209
+ ```ts
210
+ import { createShop } from '@homie-rocks/studio/shop';
211
+ const shop = createShop();
212
+ shopButton.onclick = () => shop.open();
213
+ itemButton.onclick = () => shop.open('supporter');
214
+ ```
215
+
216
+ These calls open the same shop in games and apps. Inside a player card, purchases leave for the studio's shop; ordinary play/open pages start Stripe checkout. The studio's TV and kids policies still apply.
package/site/SITE.md CHANGED
@@ -398,7 +398,7 @@ A studio with a `shop.json` ([../shop/SHOP.md](../shop/SHOP.md)) has `/shop/` (i
398
398
  one neutral age question, Buy on Stripe's own page, or a link for a parent), `/shop/thanks`, `/shop/refunds/` (the
399
399
  refund policy every seller publishes) and `/shop/parent/<link>` (what a parent opens). The account page lists the
400
400
  player's purchases and badges, with the self-serve refund of an unused item. A play page whose game the studio
401
- sells something for carries the store sheet (the game's `shop.open()`, and Shop in the room sheet); the protective shop policy hides it on kids servers and sends big-screen shoppers to a phone. The studio can edit these policies in shop.json. With a `shop.json`, `/shop/` belongs to the
401
+ sells something for carries the store sheet, and play/open screens show a visible Shop button in a reserved strip above the content. `screen.shop: false` in game.json or app.json hides this button; `createShop().open()` or `.open(item)` opens the same sheet from the experience's own button. Closed shops and experiences without items show no button; the protective shop policy hides it on kids servers and sends big-screen shoppers to a phone. The studio can edit these policies in shop.json. With a `shop.json`, `/shop/` belongs to the
402
402
  shop (a `site/pages/shop/` is not served); without one it is the studio's own. The directory manifest says only
403
403
  whether the shop is open and its till.
404
404
 
@@ -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
  }
@@ -108,11 +108,12 @@ export function accountPage(cat, { origin = '', next = '/account/', owner = null
108
108
  const studio = cat?.studio?.name ?? 'Studio';
109
109
  const boot = { next, owner, link, mode, features, studio };
110
110
  const games = (cat?.games ?? []).filter((g) => g.saves).map((g) => g.name);
111
- const back = next !== '/account/' ? `<p class="back"><a class="ghost" href="${esc(next)}" data-back>Back to the game</a></p>` : '';
111
+ const app = (cat.games ?? []).some((g) => g.kind === 'app' && next.startsWith(`/${g.id}/`));
112
+ const back = next !== '/account/' ? `<p class="back"><a class="ghost" href="${esc(next)}" data-back>Back to the ${app ? 'app' : 'game'}</a></p>` : '';
112
113
  const main = `<section class="acct" data-account>
113
114
  <p class="kicker">${esc(studio)}</p>
114
- <h1>Your player account</h1>
115
- <p class="lead">Keep your characters and progress on every device you play on. It is an account on ${esc(studio)} only, made with a passkey: Face ID, a fingerprint or your device's PIN. No password, and no email needed.</p>
115
+ <h1>${app ? 'Your account' : 'Your player account'}</h1>
116
+ <p class="lead">${app ? 'Sign in for your role in this app.' : 'Keep your characters and progress on every device you play on.'} It is an account on ${esc(studio)} only, made with a passkey: Face ID, a fingerprint or your device's PIN. No password, and no email needed.</p>
116
117
  <noscript><p class="lead">This page needs JavaScript for passkeys.</p></noscript>
117
118
  <div class="card" data-view="loading"><p>Looking for your account…</p></div>
118
119
 
@@ -0,0 +1,42 @@
1
+ /** App metadata shared by the build and the Worker. No browser role grants authority. */
2
+ export const APP_SURFACES = ['phone', 'wall', 'kiosk', 'tablet'];
3
+ export const APP_WORDS = Object.freeze({ open: 'Open', join: 'Scan to join', one: 'person', many: 'people' });
4
+ const key = /^[a-z][a-z0-9-]{0,39}$/;
5
+ const object = (x) => x && typeof x === 'object' && !Array.isArray(x);
6
+ export function appProblems(meta) {
7
+ const bad = [];
8
+ if (!object(meta)) return ['app.json must be an object'];
9
+ const roles = object(meta.roles) ? meta.roles : {};
10
+ const collections = object(meta.records?.collections) ? meta.records.collections : {};
11
+ if (!object(meta.roles) || !Object.keys(meta.roles).length) bad.push('roles must declare at least one role');
12
+ for (const [id, role] of Object.entries(roles)) {
13
+ if (!key.test(id) || !object(role) || typeof role.signIn !== 'boolean' || !Array.isArray(role.can) || role.can.some((s) => !/^(read|create|update|delete):[a-z][a-z0-9-]{0,39}$/.test(s))) bad.push(`role ${id}: declare signIn and can (read/create/update/delete:collection)`);
14
+ }
15
+ if (!Object.values(roles).some((r) => r?.signIn === false)) bad.push('declare a public role');
16
+ if (!object(meta.surfaces) || !Object.keys(meta.surfaces).length) bad.push('declare surfaces');
17
+ for (const [surface, role] of Object.entries(meta.surfaces ?? {})) if (!APP_SURFACES.includes(surface) || !Object.hasOwn(meta.roles ?? {}, role)) bad.push(`surface ${surface}: name a declared role`);
18
+ if (!object(meta.records) || typeof meta.records.persist !== 'boolean' || !object(meta.records.collections)) bad.push('records declares persist and collections');
19
+ for (const [id, c] of Object.entries(collections)) {
20
+ if (!key.test(id) || !object(c) || !object(c.fields)) { bad.push(`collection ${id}: declare fields`); continue; }
21
+ for (const [field, spec] of Object.entries(c.fields)) if (!key.test(field) || !object(spec) || !['string', 'number', 'boolean'].includes(spec.type) || (spec.enum && (!Array.isArray(spec.enum) || !spec.enum.length))) bad.push(`collection ${id}.${field}: invalid field`);
22
+ }
23
+ for (const r of Object.values(roles)) for (const cap of Array.isArray(r?.can) ? r.can : []) if (!Object.hasOwn(meta.records?.collections ?? {}, String(cap).split(':')[1])) bad.push(`unknown collection in ${cap}`);
24
+ for (const [k, v] of Object.entries(meta.words ?? {})) if (!Object.hasOwn(APP_WORDS, k) || typeof v !== 'string' || !v.trim() || v.length > 60) bad.push(`words.${k}: use a short label`);
25
+ return bad;
26
+ }
27
+ export const appWords = (g) => g?.kind === 'app' ? { ...APP_WORDS, ...g.words } : { open: 'Play', join: 'Scan to play', one: 'player', many: 'players' };
28
+ export const openPath = (g) => `/${g.id}/${g.kind === 'app' ? 'open' : 'play'}`;
29
+ export function appRole(meta, url) {
30
+ const surface = url.searchParams.get('surface') ?? ((url.pathname.endsWith('/tv') || url.pathname.endsWith('/watch')) || url.searchParams.get('want') === 'screen' ? 'wall' : 'phone');
31
+ return url.searchParams.get('role') ?? meta.surfaces?.[surface] ?? Object.keys(meta.roles ?? {}).find((r) => !meta.roles[r].signIn);
32
+ }
33
+ export function recordProblem(collection, data, { create = false } = {}) {
34
+ if (!object(data) || JSON.stringify(data).length > 8192) return 'record must be an object below 8 KB';
35
+ for (const k of Object.keys(data)) if (!Object.hasOwn(collection.fields, k)) return `unknown field ${k}`;
36
+ for (const [k, s] of Object.entries(collection.fields)) {
37
+ const v = data[k];
38
+ if (v === undefined) { if (s.required) return `missing ${k}`; continue; }
39
+ if (typeof v !== s.type || (s.type === 'number' && !Number.isFinite(v)) || (typeof v === 'string' && v.length > (s.maxLength ?? 256)) || (s.enum && !s.enum.includes(v)) || (create && s.initial !== undefined && v !== s.initial)) return `invalid ${k}`;
40
+ }
41
+ return null;
42
+ }
@@ -0,0 +1,100 @@
1
+ /** Lasting app records in the studio's own D1. Room hosts never authorize writes. */
2
+ import { appRole, recordProblem } from './app-format.mjs';
3
+ import { isOwner, sameOrigin } from './office.mjs';
4
+ import { players } from './players.mjs';
5
+ import { ownerAllowed } from './stats.mjs';
6
+ import { appOriginOf } from './standalone.mjs';
7
+ export const APPS_MIGRATION_FILE = '0014_studio_apps.sql';
8
+ export const APPS_MIGRATION = `CREATE TABLE IF NOT EXISTS app_records (
9
+ app TEXT NOT NULL, collection TEXT NOT NULL, id TEXT NOT NULL, value TEXT NOT NULL,
10
+ version INTEGER NOT NULL DEFAULT 1, updated_at INTEGER NOT NULL,
11
+ PRIMARY KEY (app, collection, id)
12
+ );
13
+ CREATE TABLE IF NOT EXISTS app_roles (
14
+ app TEXT NOT NULL, role TEXT NOT NULL, player TEXT NOT NULL,
15
+ PRIMARY KEY (app, role, player)
16
+ );
17
+ CREATE TABLE IF NOT EXISTS app_role_links (
18
+ app TEXT NOT NULL, role TEXT NOT NULL, token TEXT NOT NULL,
19
+ PRIMARY KEY (app, role)
20
+ );
21
+ `;
22
+ const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', 'cache-control': 'no-store' } });
23
+ const fail = (status, error) => json({ ok: false, error }, status);
24
+ const ID = /^[A-Za-z0-9_-]{1,64}$/;
25
+ async function bodyOf(request) {
26
+ const reader = request.body?.getReader();
27
+ if (!reader) return {};
28
+ let size = 0; const chunks = [];
29
+ for (;;) { const { done, value } = await reader.read(); if (done) break; size += value.length; if (size > 12_288) { await reader.cancel(); throw new Error('body too large'); } chunks.push(value); }
30
+ const bytes = new Uint8Array(size); let at = 0;
31
+ for (const chunk of chunks) { bytes.set(chunk, at); at += chunk.length; }
32
+ return JSON.parse(new TextDecoder().decode(bytes) || '{}');
33
+ }
34
+ /** Private links conceal staff addresses; a signed-in grant is still required on every request. */
35
+ export async function appAccess(request, env, meta, url) {
36
+ const role = appRole(meta, url);
37
+ const declared = Object.hasOwn(meta.roles ?? {}, role) ? meta.roles[role] : null;
38
+ if (!declared) return { ok: false, status: 404, error: 'unknown role' };
39
+ if (!declared.signIn) return { ok: true, role, can: declared.can };
40
+ if (!env.DB) return { ok: false, status: 503, error: 'app roles need the studio database' };
41
+ const link = await env.DB.prepare('SELECT token FROM app_role_links WHERE app = ?1 AND role = ?2').bind(meta.id, role).first();
42
+ if (!link || link.token !== url.searchParams.get('access')) return { ok: false, status: 404, error: 'private role link required' };
43
+ if (await isOwner(request, env)) return { ok: true, role, can: declared.can };
44
+ const person = await players.of(request, env);
45
+ if (!person || person.guest) return { ok: false, status: 401, error: 'sign in to this studio' };
46
+ const grant = await env.DB.prepare('SELECT player FROM app_roles WHERE app = ?1 AND role = ?2 AND player = ?3').bind(meta.id, role, person.id).first();
47
+ return grant ? { ok: true, role, can: declared.can } : { ok: false, status: 403, error: 'this account has no role grant' };
48
+ }
49
+ export async function appRecordsRoute(request, env, meta, url, sub) {
50
+ if (!sub.startsWith('api/app/')) return null;
51
+ if (!env.DB) return fail(503, 'app records need the studio database');
52
+ try {
53
+ if (sub === 'api/app/roles') {
54
+ const key = await ownerAllowed(request, env, { kinds: ['office'] });
55
+ if (!key && !(await isOwner(request, env))) return fail(403, 'owner only');
56
+ if (request.method !== 'POST') return fail(405, 'POST required');
57
+ if (!key && !sameOrigin(request, url)) return fail(403, 'same origin required');
58
+ const b = await bodyOf(request);
59
+ if (!Object.hasOwn(meta.roles, b.role) || !meta.roles[b.role].signIn) return fail(400, 'name a sign-in role');
60
+ if (b.player !== undefined) {
61
+ if (!ID.test(b.player)) return fail(400, 'invalid account');
62
+ const p = await players.get(b.player, env);
63
+ if (!p || p.guest) return fail(400, 'role grants require an existing signed-in account');
64
+ await env.DB.prepare(b.revoke === true ? 'DELETE FROM app_roles WHERE app = ?1 AND role = ?2 AND player = ?3' : 'INSERT OR IGNORE INTO app_roles (app, role, player) VALUES (?1, ?2, ?3)').bind(meta.id, b.role, b.player).run();
65
+ }
66
+ const token = crypto.randomUUID().replaceAll('-', '');
67
+ if (b.rotate === true) await env.DB.prepare('DELETE FROM app_role_links WHERE app = ?1 AND role = ?2').bind(meta.id, b.role).run();
68
+ await env.DB.prepare('INSERT OR IGNORE INTO app_role_links (app, role, token) VALUES (?1, ?2, ?3)').bind(meta.id, b.role, token).run();
69
+ const row = await env.DB.prepare('SELECT token FROM app_role_links WHERE app = ?1 AND role = ?2').bind(meta.id, b.role).first();
70
+ return json({ ok: true, link: `${url.origin}/${meta.id}/open?role=${b.role}&access=${row.token}`, role: b.role });
71
+ }
72
+ const match = /^api\/app\/records\/([a-z][a-z0-9-]{0,39})(?:\/([A-Za-z0-9_-]{1,64}))?$/.exec(sub);
73
+ if (!match || !Object.hasOwn(meta.records?.collections ?? {}, match[1])) return fail(404, 'unknown collection');
74
+ if (meta.records.persist !== true) return fail(409, 'use netplay state for transient records, or enable records.persist');
75
+ const access = await appAccess(request, env, meta, url);
76
+ if (!access.ok) return fail(access.status, access.error);
77
+ const [, collection, id] = match;
78
+ const op = ({ GET: 'read', POST: 'create', PUT: 'update', DELETE: 'delete' })[request.method];
79
+ if (!op || !access.can.includes(`${op}:${collection}`)) return fail(403, 'role cannot perform this operation');
80
+ if (op !== 'read' && !sameOrigin(request, url) && !(appOriginOf(request) && !meta.roles[access.role].signIn)) return fail(403, 'same origin required');
81
+ if (op === 'read') {
82
+ const rows = id ? [await env.DB.prepare('SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 AND id = ?3').bind(meta.id, collection, id).first()].filter(Boolean)
83
+ : (await env.DB.prepare('SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 ORDER BY updated_at, id LIMIT 1000').bind(meta.id, collection).all()).results;
84
+ return json({ ok: true, records: rows.map((r) => ({ id: r.id, data: JSON.parse(r.value), version: r.version, updatedAt: r.updated_at })) });
85
+ }
86
+ if (!id) return fail(400, 'record id required');
87
+ const body = await bodyOf(request);
88
+ if (op !== 'delete') { const bad = recordProblem(meta.records.collections[collection], body.data, { create: op === 'create' }); if (bad) return fail(400, bad); }
89
+ if (op !== 'create' && (!Number.isSafeInteger(body.version) || body.version < 1)) return fail(400, 'record version required');
90
+ const stmt = op === 'create'
91
+ ? env.DB.prepare('INSERT OR IGNORE INTO app_records (app, collection, id, value, version, updated_at) SELECT ?1, ?2, ?3, ?4, 1, ?5 WHERE (SELECT COUNT(*) FROM app_records WHERE app = ?1 AND collection = ?2) < 1000').bind(meta.id, collection, id, JSON.stringify(body.data), Date.now())
92
+ : op === 'update' ? env.DB.prepare('UPDATE app_records SET value = ?4, version = version + 1, updated_at = ?5 WHERE app = ?1 AND collection = ?2 AND id = ?3 AND version = ?6').bind(meta.id, collection, id, JSON.stringify(body.data), Date.now(), body.version)
93
+ : env.DB.prepare('DELETE FROM app_records WHERE app = ?1 AND collection = ?2 AND id = ?3 AND version = ?4').bind(meta.id, collection, id, body.version);
94
+ const result = await stmt.run();
95
+ return result.meta.changes ? json({ ok: true, id, version: op === 'create' ? 1 : body.version + 1 }) : fail(409, 'record changed, already exists, or collection full; reload');
96
+ } catch (error) {
97
+ if (error instanceof SyntaxError || error.message === 'body too large') return fail(400, error.message);
98
+ return fail(503, 'app database unavailable; apply studio migrations');
99
+ }
100
+ }
@@ -0,0 +1,29 @@
1
+ /** Only the shell's own frame can ask; role and access come from the shell, never the message. */
2
+ export const APP_SHELL_JS = String.raw`(function () {
3
+ var boot = window.__HOMIE_PLAY || window.__HOMIE_APP || window.__HOMIE_WATCH;
4
+ if (!boot || boot.kind !== 'app') return;
5
+ var frame = document.querySelector('iframe.game') || document.querySelector('iframe');
6
+ var params = new URLSearchParams(location.search);
7
+ var inflight = 0;
8
+ addEventListener('message', async function (e) {
9
+ var m = e.data;
10
+ if (frame && e.source === frame.contentWindow && m?.type === 'homie-app-ticket' && /^[a-f0-9]{32}$/.test(m.ticket)) { params.set('ticket', m.ticket); history.replaceState(null, '', location.pathname + '?' + params); return; }
11
+ if (!frame || e.source !== frame.contentWindow || !m || m.type !== 'homie-app' || !/^[a-f0-9]{32}$/.test(m.id || '')) return;
12
+ if (!/^[a-z][a-z0-9-]{0,39}$/.test(m.collection || '') || !['GET','POST','PUT','DELETE'].includes(m.method) || (m.record !== undefined && !/^[A-Za-z0-9_-]{1,64}$/.test(m.record))) return;
13
+ var result;
14
+ if (inflight >= 8) result = { ok: false, error: 'too many requests' };
15
+ else {
16
+ inflight++;
17
+ try {
18
+ var q = new URLSearchParams();
19
+ ['role','surface','access'].forEach(function (k) { var v = params.get(k); if (v) q.set(k, v); });
20
+ if (boot.screen && !q.has('role')) q.set('surface', 'wall');
21
+ var path = (boot.site || '') + '/' + boot.game + '/api/app/records/' + m.collection + (m.record ? '/' + m.record : '') + '?' + q;
22
+ var res = await fetch(path, { method: m.method, headers: { 'content-type': 'application/json' }, body: m.method === 'GET' ? undefined : JSON.stringify({ data: m.data, version: m.version }) });
23
+ result = await res.json();
24
+ } catch (err) { result = { ok: false, error: 'records unavailable; reconnect and retry' }; }
25
+ finally { inflight--; }
26
+ }
27
+ e.source.postMessage({ type: 'homie-app-result', id: m.id, result: result }, '*');
28
+ });
29
+ })();`;
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/embed.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { appWords, openPath } from './app-format.mjs';
1
2
  /**
2
3
  * Player cards. X's reference: developer.x.com, "Cards: player card" (archived)
3
4
  * (redirects to docs.x.com/overview as checked 2026-10-08). Surviving official sample:
@@ -71,7 +72,8 @@ export function httpsAddress(value, origin) {
71
72
  try { const u = new URL(value, origin); return u.protocol === 'https:' && !u.username && !u.password ? u.href : null; } catch { return null; }
72
73
  }
73
74
  export function playerProperties(cat, game, { origin = '', title = game?.name, description = game?.blurb } = {}) {
74
- const player = httpsAddress(`/${game?.id}/play/embed`, origin);
75
+ if (!game) return {};
76
+ const player = httpsAddress(`${openPath(game)}/embed`, origin);
75
77
  const measured = game?.playerImage;
76
78
  const picture = measured && httpsAddress(measured.src, origin);
77
79
  // twitter:site goes out when the studio has set its handle; a card is never withheld for the lack of one
@@ -80,7 +82,7 @@ export function playerProperties(cat, game, { origin = '', title = game?.name, d
80
82
  if (!playerEnabled(cat, game) || !player || !picture || playerImageRule(measured)) return {};
81
83
  return { ...(handle ? { 'twitter:site': handle } : {}), 'twitter:card': 'player', 'twitter:player': player, 'twitter:player:width': PLAYER_SIZE.width,
82
84
  'twitter:player:height': PLAYER_SIZE.height, 'twitter:image': picture, 'twitter:title': title,
83
- 'twitter:description': String(description || `Play ${game.name} in your browser.`).slice(0, 200) };
85
+ 'twitter:description': String(description || `${appWords(game).open} ${game.name} in your browser.`).slice(0, 200) };
84
86
  }
85
87
 
86
88
  /** Browser restrictions belong to the outer host; failures must not kill a game's input loop.
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
  });