@homie-rocks/studio 0.54.0 → 0.55.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 +27 -0
- package/lib/background-queue.mjs +31 -0
- package/lib/build.mjs +4 -1
- package/lib/cloudflare.mjs +16 -1
- package/lib/read-replication.mjs +26 -0
- package/lib/scaffold.mjs +28 -10
- package/lib/shop.mjs +7 -0
- package/lib/static-assets.mjs +27 -0
- package/lib/template-history.json +42 -0
- package/lib/worker-build.mjs +13 -4
- package/lib/worker-config.mjs +3 -1
- package/package.json +1 -1
- package/worker/analytics.mjs +84 -0
- package/worker/app-frame.mjs +1 -1
- package/worker/app-graphics.mjs +1 -1
- package/worker/app-streams.mjs +3 -3
- package/worker/asset-paths.mjs +2 -0
- package/worker/background-queue.mjs +30 -0
- package/worker/build-id.mjs +1 -1
- package/worker/built-metadata.mjs +2 -0
- package/worker/catalogue.mjs +18 -0
- package/worker/chat-page.mjs +1 -1
- package/worker/embed.mjs +1 -1
- package/worker/event-resources.mjs +23 -11
- package/worker/function-registry.mjs +4 -0
- package/worker/functions.mjs +7 -5
- package/worker/gate-routing.mjs +61 -0
- package/worker/gate.mjs +12 -68
- package/worker/hosted-registry.mjs +11 -0
- package/worker/hosted.mjs +3 -17
- package/worker/index.mjs +54 -2785
- package/worker/lazy-objects.mjs +40 -0
- package/worker/lobby-limits.mjs +2 -0
- package/worker/lobby.mjs +251 -0
- package/worker/lounge.mjs +3 -3
- package/worker/mcp-event-streams.mjs +3 -3
- package/worker/mcp-events.mjs +4 -4
- package/worker/mcp-store.mjs +12 -1
- package/worker/mcp.mjs +5 -1
- package/worker/office.mjs +16 -20
- package/worker/operational-schema.mjs +14 -0
- package/worker/page-policy.mjs +29 -0
- package/worker/pages.mjs +8 -28
- package/worker/parts.mjs +2 -1
- package/worker/payment-recovery.mjs +33 -19
- package/worker/player-registry.mjs +9 -0
- package/worker/players.mjs +8 -7
- package/worker/purchase-machine.mjs +5 -3
- package/worker/reconcile-guard.mjs +22 -0
- package/worker/request-db.mjs +35 -10
- package/worker/responses.mjs +18 -0
- package/worker/room-chat.mjs +216 -0
- package/worker/room-controls.mjs +296 -0
- package/worker/room-lifecycle.mjs +10 -0
- package/worker/room-protocol.mjs +197 -0
- package/worker/room.mjs +38 -731
- package/worker/rooms-cache.mjs +19 -0
- package/worker/routes.mjs +667 -0
- package/worker/runtime-support.mjs +445 -0
- package/worker/seats.mjs +1 -1
- package/worker/selling/index.mjs +1 -0
- package/worker/selling/runtime.mjs +7 -5
- package/worker/service-run.mjs +1 -1
- package/worker/shop-links.mjs +1 -1
- package/worker/shop-page.mjs +1 -0
- package/worker/shop.mjs +27 -20
- package/worker/stats.mjs +7 -6
- package/worker/stream-objects.mjs +18 -0
- package/worker/stripe-intake.mjs +79 -0
- package/worker/stripe.mjs +3 -0
- package/worker/table.mjs +1212 -0
- package/worker/tool-registry.mjs +10 -0
- package/worker/version.mjs +1 -1
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
/** Shared wire constants and validation; importing these never loads the relay engine. */
|
|
4
|
+
|
|
5
|
+
export { LIMITS, GROWS, capOf } from './limits.mjs';
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
import { CHAT_LIMITS, normalizeChat } from './chat.mjs';
|
|
9
|
+
|
|
10
|
+
export const NET_VERSION = 1;
|
|
11
|
+
/** The contract revision this relay speaks (NETPLAY.md): optional fields, frames and refusals; the wire stays `v: 1`. */
|
|
12
|
+
export const NET_REVISION = 12;
|
|
13
|
+
/**
|
|
14
|
+
* Revision 9 (NETPLAY.md sections 22 and 23). STALL: how long a host may send no snapshot, while others are present,
|
|
15
|
+
* before the room is handed on: 1.5 s unless the game names its own (game.json `netplay.stallMs`), never under
|
|
16
|
+
* 1.5 s (a shorter one swaps hosts on a single slow frame) and never over 10 s (a frozen host must still be replaced
|
|
17
|
+
* while its players are watching a still picture).
|
|
18
|
+
*/
|
|
19
|
+
export const STALL = Object.freeze({ ms: 1500, min: 1500, max: 10_000 });
|
|
20
|
+
/** A game's own stall time, held to STALL's bounds; null when it names none (or not a number). */
|
|
21
|
+
export function stallOf(v) {
|
|
22
|
+
const n = Math.floor(Number(v));
|
|
23
|
+
return Number.isFinite(n) && n > 0 ? Math.max(STALL.min, Math.min(STALL.max, n)) : null;
|
|
24
|
+
}
|
|
25
|
+
const VERSION_TEXT = /^[A-Za-z0-9._-]{1,32}$/;
|
|
26
|
+
/** A game revision (game.json `netplay.version`): 1 to 32 of A-Z a-z 0-9 . _ -, a number as its digits; else null. */
|
|
27
|
+
export function versionOf(v) {
|
|
28
|
+
const t = typeof v === 'number' && Number.isFinite(v) ? String(v) : typeof v === 'string' ? v.trim() : '';
|
|
29
|
+
return VERSION_TEXT.test(t) ? t : null;
|
|
30
|
+
}
|
|
31
|
+
const FEATURE_TEXT = /^[a-z0-9][a-z0-9-]{0,23}$/;
|
|
32
|
+
/** What a build says it can do (`hello.feat`): up to 8 short lowercase words. */
|
|
33
|
+
export function featuresOf(list) {
|
|
34
|
+
if (!Array.isArray(list)) return [];
|
|
35
|
+
const out = [];
|
|
36
|
+
for (const x of list) { const k = String(x ?? '').toLowerCase(); if (FEATURE_TEXT.test(k) && !out.includes(k)) out.push(k); if (out.length >= 8) break; }
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A SOCKET THAT WENT AWAY IS NOT A FAILED ROOM. A browser that closes its tab, loses its network or is replaced by a
|
|
41
|
+
* new host ends its socket without a goodbye, and the runtime reports that as an error ("Network connection lost",
|
|
42
|
+
* "WebSocket peer disconnected"…). Those are departures: the room has already handled them (the seat is held, the
|
|
43
|
+
* host is replaced). Anything else thrown while the room was doing something IS a failure, and is logged with the
|
|
44
|
+
* room and what it was doing. `departure(error)` says which; the transport (the Table) asks it at its boundary.
|
|
45
|
+
*/
|
|
46
|
+
const DEPARTURE = /network connection lost|connection (?:was )?(?:closed|reset|aborted|lost)|peer disconnected|websocket (?:is )?(?:closed|closing|not open|already closed)|socket (?:is )?closed|client disconnected|disconnected|broken pipe|econnreset|epipe|aborted|cancell?ed|the script will never generate a response|durable object reset/i;
|
|
47
|
+
export function departure(error) {
|
|
48
|
+
const text = typeof error === 'string' ? error : `${error?.name ?? ''} ${error?.message ?? ''} ${error?.code ?? ''}`;
|
|
49
|
+
return DEPARTURE.test(text);
|
|
50
|
+
}
|
|
51
|
+
/** An error as a log line carries it: its name and message, cut short. Never the frame it was thrown for. */
|
|
52
|
+
export function errorLine(error) {
|
|
53
|
+
return String(error?.stack && typeof error.stack === 'string' ? error.stack.split('\n').slice(0, 4).join(' | ') : error?.message ?? error).slice(0, 400);
|
|
54
|
+
}
|
|
55
|
+
const POLICY_KINDS = ['open', 'humans-only', 'hybrid', 'beginner'];
|
|
56
|
+
/**
|
|
57
|
+
* A room's policy before the Worker says anything (and the public server's with nothing set): today's behaviour.
|
|
58
|
+
* NETPLAY.md section 17: `server` null is a named room; `aiSeats + guides` seats are kept for AI (the top seats).
|
|
59
|
+
*/
|
|
60
|
+
export const DEFAULT_POLICY = Object.freeze({ v: 1, at: 0, server: null, kind: 'open', aiSeats: 0, guides: 0, bots: 'fill', level: 3, levelMax: 5, speech: 'game', kids: false, brain: 'script' });
|
|
61
|
+
/** An agent's speech: at most one line every 4 s and 8 a minute (the relay drops the rest and counts them). */
|
|
62
|
+
export const AGENT_SPEECH = Object.freeze({ gapMs: 4000, perMinute: 8 });
|
|
63
|
+
/** The party's vote on the dial: open 15 s (or until every seated person voted); one opened by a player per 2 min. */
|
|
64
|
+
export const VOTE = Object.freeze({ ms: 15_000, cooldownMs: 120_000, autoMs: 30 * 60_000 });
|
|
65
|
+
/** Agents left with no person seated for this long are closed (`agents-alone`): an AI never keeps a room alive. */
|
|
66
|
+
export const AGENTS_ALONE_MS = 60_000;
|
|
67
|
+
const CAPS = ['skill', 'agents'];
|
|
68
|
+
/** Revision 7: what a game's host shows an AI (`agent:view`) and how often an AI may act (`agent:do`). */
|
|
69
|
+
export const AGENT_FRAMES = Object.freeze({ viewMs: 2000, viewBytes: 2048, doMs: 3000 });
|
|
70
|
+
const SAY = /^say:([a-z][a-z0-9_]{0,31})$/;
|
|
71
|
+
const d0 = (m) => (m.d && typeof m.d === 'object' && !Array.isArray(m.d) ? m.d : {});
|
|
72
|
+
const ASK = /^ask:([a-z][a-z0-9_]{0,31})$/;
|
|
73
|
+
|
|
74
|
+
/** A policy as the relay keeps it: every field checked (the Worker composed it, but a bad one must not break a room). */
|
|
75
|
+
export function normalizePolicy(p) {
|
|
76
|
+
if (!p || typeof p !== 'object') return null;
|
|
77
|
+
const int = (v, lo, hi, d) => { const n = Math.floor(Number(v)); return Number.isFinite(n) ? Math.max(lo, Math.min(hi, n)) : d; };
|
|
78
|
+
const kind = POLICY_KINDS.includes(p.kind) ? p.kind : 'open';
|
|
79
|
+
const kids = p.kids === true;
|
|
80
|
+
const levelMax = int(p.levelMax, 1, kids ? 3 : 5, kids ? 3 : 5);
|
|
81
|
+
const server = p.server && typeof p.server === 'object' && /^[a-z0-9][a-z0-9-]{0,19}$/.test(String(p.server.id ?? ''))
|
|
82
|
+
? { id: String(p.server.id), name: String(p.server.name ?? p.server.id).replace(/[\u0000-\u001f\u007f]+/g, ' ').slice(0, 40) } : null;
|
|
83
|
+
const reserveOk = kind === 'hybrid' || kind === 'beginner';
|
|
84
|
+
return {
|
|
85
|
+
v: 1, at: Math.max(0, Number(p.at) || 0), server, kind,
|
|
86
|
+
aiSeats: reserveOk ? int(p.aiSeats, 0, 31, 0) : 0, guides: kind === 'beginner' ? int(p.guides, 0, 31, 0) : 0,
|
|
87
|
+
bots: p.bots === 'off' ? 'off' : 'fill', level: Math.min(levelMax, int(p.level, 1, 5, 3)), levelMax,
|
|
88
|
+
speech: ['game', 'lines', 'off'].includes(p.speech) ? p.speech : 'game', kids,
|
|
89
|
+
brain: kind === 'humans-only' ? 'off' : ['off', 'script', 'workers-ai', 'owner-key'].includes(p.brain) ? p.brain : 'script',
|
|
90
|
+
// Revision 8: the room's chat rules (section 19), capped by the server's speech and kids.
|
|
91
|
+
...(p.chat !== undefined ? { chat: normalizeChat(p.chat === false ? { mode: 'off' } : p.chat, { kids, speech: ['game', 'lines', 'off'].includes(p.speech) ? p.speech : 'game' }) } : {}),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const policySig = (p) => JSON.stringify({ ...p, at: 0 });
|
|
95
|
+
/** The lower middle of the votes (DESIGN D8: a tie is settled kindly), or null with none. */
|
|
96
|
+
export function medianVote(list) {
|
|
97
|
+
const v = [...list].filter(Number.isFinite).sort((a, b) => a - b);
|
|
98
|
+
return v.length ? v[Math.floor((v.length - 1) / 2)] : null;
|
|
99
|
+
}
|
|
100
|
+
const utf8 = new TextEncoder();
|
|
101
|
+
// A message's size in bytes. Text that is all ASCII (nearly every frame a game sends) is as long as it is wide: it is
|
|
102
|
+
// not encoded to be measured, which at ten inputs a second from a thousand players was a twentieth of a room's second.
|
|
103
|
+
const ASCII = /^[\x00-\x7f]*$/;
|
|
104
|
+
const byteLength = text => ASCII.test(text) ? text.length : utf8.encode(text).length;
|
|
105
|
+
const PALETTE_SIZE = 12;
|
|
106
|
+
const DEVICE_RANK = { desk: 0, tv: 1, phone: 2 };
|
|
107
|
+
|
|
108
|
+
/** Rate caps (messages per rolling second, per client). Over the cap a message is dropped, counted and reported. */
|
|
109
|
+
export { RATES, EV_RATES, STATE_CAPS } from './limits.mjs';
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* THE OWNER'S CONTROLS (revision 4, NETPLAY.md section 15). The studio's Worker, acting for the signed-in owner,
|
|
113
|
+
* hands the room a control: kick a player out (and hold the door for some minutes), mute one, announce a line to
|
|
114
|
+
* everyone, close the room, or change its seats. The Table verifies the owner's signature before it calls
|
|
115
|
+
* `control()`; this file only applies one. What a player sees of them is new optional frames and two new refusals,
|
|
116
|
+
* so a v1 game that knows none of it keeps playing (its shell shows the notice and the banner).
|
|
117
|
+
*/
|
|
118
|
+
export const CONTROL_LIMITS = Object.freeze({ minutes: 24 * 60, announce: 280, announceSeconds: 3600, bans: 200, mutes: 200, message: 200 });
|
|
119
|
+
/** What a muted player cannot say: an `ev` whose kind starts with one of these (a game's chat, quick lines, emotes). */
|
|
120
|
+
export const SPEECH = /^(?:say|chat|emote)/i;
|
|
121
|
+
/**
|
|
122
|
+
* The contract's 12 player colours: a peer's `colour` (its seat % 12) is an index into this list. The watch page's
|
|
123
|
+
* player strip draws them; a game that colours its players the same way matches it (Gem Rush does).
|
|
124
|
+
*/
|
|
125
|
+
export const NET_PALETTE = Object.freeze(['#8fe36a', '#ffd166', '#ef6f6c', '#6cb4ee', '#c792ea', '#f4a261', '#2ec4b6', '#ff8fab', '#a7c957', '#e9c46a', '#90e0ef', '#f28482']);
|
|
126
|
+
/**
|
|
127
|
+
* What a game lets its watchers see (game.json "watch", passed by the Worker as `conn.watchPolicy`): `follow` (any
|
|
128
|
+
* player's view, the default), `overview` (the whole room only: a game with hidden hands or hidden roles), `off`.
|
|
129
|
+
*/
|
|
130
|
+
export const WATCH_POLICIES = Object.freeze(['follow', 'overview', 'off']);
|
|
131
|
+
const oneLine = (v, max) => String(v ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
|
|
132
|
+
/** A short tag for a browser's room key: enough for the owner to see "the same browser", never the key itself. */
|
|
133
|
+
export function browserTag(key) {
|
|
134
|
+
if (!key) return null;
|
|
135
|
+
let h = 2166136261;
|
|
136
|
+
for (const ch of String(key)) h = Math.imul(h ^ ch.charCodeAt(0), 16777619) >>> 0;
|
|
137
|
+
return h.toString(36).padStart(6, '0').slice(-6);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A seat's handle when its player typed no name: two words, varied per seat and per room ("Velvet Comet"), never
|
|
142
|
+
* "Player 1" (a game that falls back to a list by seat number made every room's first player the same name).
|
|
143
|
+
* At most 16 characters, so a game that trims names at 18 keeps all of it.
|
|
144
|
+
*/
|
|
145
|
+
export const HANDLE_WORDS = Object.freeze({
|
|
146
|
+
first: ['Neon', 'Velvet', 'Static', 'Amber', 'Cobalt', 'Lucky', 'Rapid', 'Silent', 'Solar', 'Lunar', 'Crimson', 'Golden',
|
|
147
|
+
'Frosty', 'Wild', 'Quiet', 'Swift', 'Brave', 'Clever', 'Cosmic', 'Mellow', 'Rusty', 'Shiny', 'Sunny', 'Stormy', 'Turbo',
|
|
148
|
+
'Pixel', 'Retro', 'Jolly', 'Nimble', 'Bold', 'Misty', 'Copper'],
|
|
149
|
+
second: ['Comet', 'Otter', 'Falcon', 'Fox', 'Panda', 'Rocket', 'Tiger', 'Raven', 'Pilot', 'Ranger', 'Rider', 'Moth', 'Koi',
|
|
150
|
+
'Lynx', 'Heron', 'Badger', 'Yeti', 'Wolf', 'Bison', 'Gecko', 'Orca', 'Puma', 'Sparrow', 'Beacon', 'Meteor', 'Nova',
|
|
151
|
+
'Ember', 'Drift', 'Spark', 'Echo', 'Kite', 'Mantis'],
|
|
152
|
+
});
|
|
153
|
+
/** The handle for a seat token (random per seat), skipping any handle another seat in the room already has. */
|
|
154
|
+
export function handleFor(token, taken = new Set()) {
|
|
155
|
+
let h = 2166136261;
|
|
156
|
+
for (const ch of String(token ?? '')) h = Math.imul(h ^ ch.charCodeAt(0), 16777619) >>> 0;
|
|
157
|
+
const { first, second } = HANDLE_WORDS;
|
|
158
|
+
const all = first.length * second.length;
|
|
159
|
+
for (let i = 0; i < all; i += 1) {
|
|
160
|
+
const n = (h + i * 7919) % all;
|
|
161
|
+
const name = `${first[n % first.length]} ${second[Math.floor(n / first.length) % second.length]}`;
|
|
162
|
+
if (!taken.has(name)) return name;
|
|
163
|
+
}
|
|
164
|
+
return `${first[h % first.length]} ${second[h % second.length]}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Random bytes as base64url, with no Node Buffer (runs in workerd too). */
|
|
168
|
+
function randomId(n) {
|
|
169
|
+
const raw = new Uint8Array(n);
|
|
170
|
+
crypto.getRandomValues(raw);
|
|
171
|
+
let s = '';
|
|
172
|
+
for (const b of raw) s += String.fromCharCode(b);
|
|
173
|
+
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Press counts are summed by the host: clamp them so `{fire: 1e9}` is 8, not a billion loop turns. */
|
|
177
|
+
function clampPresses(p) {
|
|
178
|
+
if (!p || typeof p !== 'object' || Array.isArray(p)) return null;
|
|
179
|
+
const out = {};
|
|
180
|
+
let n = 0;
|
|
181
|
+
for (const [k, v] of Object.entries(p)) {
|
|
182
|
+
if (n >= 16) break;
|
|
183
|
+
const c = Math.max(0, Math.min(8, Math.floor(Number(v) || 0)));
|
|
184
|
+
if (c) { out[String(k).slice(0, 32)] = c; n += 1; }
|
|
185
|
+
}
|
|
186
|
+
return n ? out : null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** A kept line the Worker read from the room's history (its id, name and sender's account), for a mute or a kick. */
|
|
190
|
+
function keptRef(a) {
|
|
191
|
+
const k = a.kept && typeof a.kept === 'object' ? a.kept : null;
|
|
192
|
+
if (!k || k.id !== a.line || !k.from || typeof k.from.player !== 'string') return null;
|
|
193
|
+
return { id: k.id, name: oneLine(k.name, CHAT_LIMITS.name) || 'Someone', seat: null, from: { client: null, token: null, browser: null, player: k.from.player } };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
export {VERSION_TEXT,FEATURE_TEXT,DEPARTURE,POLICY_KINDS,CAPS,SAY,d0,ASK,policySig,utf8,ASCII,byteLength,PALETTE_SIZE,DEVICE_RANK,oneLine,randomId,clampPresses,keptRef};
|