@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.
- package/CHANGELOG.md +40 -0
- package/README.md +4 -2
- package/agents/agents.ts +8 -6
- package/app-starters/welcome/app.json +106 -0
- package/app-starters/welcome/index.html +4 -0
- package/app-starters/welcome/src/main.ts +107 -0
- package/apps/APPS.md +61 -0
- package/apps/apps.ts +28 -0
- package/bin/homie-studio.mjs +22 -10
- package/lab/lab.ts +15 -0
- package/lib/art-cli.mjs +2 -2
- package/lib/art-tools.mjs +6 -6
- package/lib/asset-check.mjs +3 -2
- package/lib/asset-import.mjs +3 -2
- package/lib/asset-manifest.mjs +8 -8
- package/lib/build.mjs +26 -18
- package/lib/characters.mjs +7 -7
- package/lib/check.mjs +49 -0
- package/lib/cloudflare.mjs +1 -1
- package/lib/codex.mjs +2 -2
- package/lib/collision.mjs +1 -1
- package/lib/decisions.mjs +7 -7
- package/lib/dev.mjs +42 -10
- package/lib/directory.mjs +1 -1
- package/lib/doctor.mjs +1 -1
- package/lib/handoff.mjs +1 -1
- package/lib/lab-check.mjs +18 -3
- package/lib/lab.mjs +29 -15
- package/lib/library.mjs +2 -2
- package/lib/mcp-tools.mjs +23 -3
- package/lib/parts-build.mjs +4 -3
- package/lib/parts-cli.mjs +3 -3
- package/lib/parts-store.mjs +8 -7
- package/lib/parts-tools.mjs +7 -7
- package/lib/parts.mjs +19 -14
- package/lib/perf.mjs +2 -1
- package/lib/player-image.mjs +2 -2
- package/lib/rules-build.mjs +97 -49
- package/lib/rules-check-worker.mjs +405 -0
- package/lib/rules-check.mjs +69 -0
- package/lib/rules-compiler.mjs +30 -0
- package/lib/rules-guard.mjs +26 -7
- package/lib/rules-types.mjs +167 -0
- package/lib/scaffold.mjs +21 -2
- package/lib/shop.mjs +1 -1
- package/lib/standalone-files.mjs +2 -2
- package/lib/standalone.mjs +12 -3
- package/lib/stats.mjs +2 -1
- package/lib/studio.mjs +32 -6
- package/lib/style-board.mjs +10 -9
- package/lib/template-history.json +85 -1
- package/lib/typecheck.mjs +52 -3
- package/links/links.d.ts +3 -0
- package/links/links.mjs +16 -0
- package/netplay/NETPLAY.md +123 -6
- package/netplay/netplay.ts +289 -53
- package/netplay/rules-output.mjs +85 -0
- package/package.json +16 -4
- package/parts/PARTS.md +10 -5
- package/rules/browser.ts +100 -0
- package/rules/core.ts +159 -58
- package/rules/guard.ts +11 -4
- package/rules/host.ts +90 -15
- package/rules/math.ts +16 -0
- package/rules/pack.ts +94 -17
- package/rules/rules.ts +21 -13
- package/rules/types.ts +63 -0
- package/rules/view.ts +36 -33
- package/shop/SHOP.md +13 -0
- package/site/SITE.md +1 -1
- package/standalone/shell/shell.js +8 -6
- package/worker/account-page.mjs +4 -3
- package/worker/app-format.mjs +42 -0
- package/worker/app-records.mjs +100 -0
- package/worker/app-shell.mjs +29 -0
- package/worker/brain.mjs +2 -1
- package/worker/embed.mjs +4 -2
- package/worker/hosted.mjs +1 -1
- package/worker/index.mjs +40 -20
- package/worker/limits.mjs +41 -0
- package/worker/pages.mjs +37 -29
- package/worker/parts.mjs +11 -9
- package/worker/room.mjs +136 -63
- package/worker/schema.mjs +6 -5
- package/worker/seats.mjs +2 -2
- package/worker/shop-page.mjs +4 -3
- package/worker/site.mjs +49 -40
- package/worker/standalone.mjs +2 -2
- 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
|
|
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
|
|
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
|
|
45
|
-
import {
|
|
46
|
-
import { coerce,
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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))
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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: () => '
|
|
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
|
|
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
|
}
|
package/worker/account-page.mjs
CHANGED
|
@@ -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
|
|
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
|
|
115
|
-
<p class="lead"
|
|
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:
|
|
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
|
-
|
|
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 ||
|
|
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
|
});
|