@homie-rocks/studio 0.21.0 → 0.23.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 (70) hide show
  1. package/CHANGELOG.md +68 -1
  2. package/README.md +92 -0
  3. package/assets/assets.ts +500 -0
  4. package/assets/render-page.ts +629 -0
  5. package/assets/safety.mjs +194 -0
  6. package/bin/homie-studio.mjs +81 -2
  7. package/chat/CHAT.md +101 -0
  8. package/chat/OWNERS.md +78 -0
  9. package/lib/art-cli.mjs +296 -0
  10. package/lib/art-tools.mjs +331 -0
  11. package/lib/asset-check.mjs +189 -0
  12. package/lib/asset-import.mjs +136 -0
  13. package/lib/asset-manifest.mjs +318 -0
  14. package/lib/build.mjs +32 -2
  15. package/lib/chat-cli.mjs +89 -0
  16. package/lib/cloudflare.mjs +5 -2
  17. package/lib/codex.mjs +43 -1
  18. package/lib/colour.mjs +160 -0
  19. package/lib/decisions.mjs +800 -0
  20. package/lib/directory.mjs +9 -1
  21. package/lib/library.mjs +169 -0
  22. package/lib/mcp-tools.mjs +22 -4
  23. package/lib/mcp.mjs +6 -3
  24. package/lib/optimise.mjs +380 -0
  25. package/lib/remix-assets.mjs +84 -0
  26. package/lib/render3d.mjs +81 -0
  27. package/lib/scaffold.mjs +39 -2
  28. package/lib/site.mjs +6 -1
  29. package/lib/studio.mjs +136 -9
  30. package/lib/style-board.mjs +227 -0
  31. package/lib/style-presets.mjs +121 -0
  32. package/lib/template-history.json +76 -0
  33. package/mcp/ui/card.css +15 -0
  34. package/mcp/ui/cast.js +62 -0
  35. package/mcp/ui/decision.js +61 -0
  36. package/mcp/ui/lineup.js +68 -0
  37. package/mcp/ui/rights.js +30 -0
  38. package/mcp/ui/style.js +154 -0
  39. package/netplay/NETPLAY.md +191 -3
  40. package/netplay/netplay.ts +150 -3
  41. package/package.json +15 -2
  42. package/port/index.ts +3 -2
  43. package/port/view.ts +238 -0
  44. package/site/SITE.md +11 -1
  45. package/starters/ember-vale/src/main.ts +22 -2
  46. package/starters/gem-rush/src/main.ts +21 -1
  47. package/starters/gem-rush-3d/assets/RIGHTS.md +203 -0
  48. package/starters/gem-rush-3d/assets/manifest.json +2318 -0
  49. package/starters/gem-rush-3d/credits.json +31 -0
  50. package/starters/gem-rush-3d/game.json +11 -0
  51. package/starters/gem-rush-3d/index.html +20 -0
  52. package/starters/gem-rush-3d/lab.json +19 -0
  53. package/starters/gem-rush-3d/src/main.ts +2107 -0
  54. package/starters/gem-rush-3d/style.json +66 -0
  55. package/starters/gem-rush-3d/tunables.json +13 -0
  56. package/worker/agents.mjs +3 -3
  57. package/worker/chat-page.mjs +556 -0
  58. package/worker/chat-store.mjs +161 -0
  59. package/worker/chat-words.mjs +46 -0
  60. package/worker/chat.mjs +419 -0
  61. package/worker/index.mjs +153 -8
  62. package/worker/office-page.mjs +115 -0
  63. package/worker/office.mjs +137 -6
  64. package/worker/pages.mjs +32 -9
  65. package/worker/qr.mjs +11 -0
  66. package/worker/room.mjs +288 -10
  67. package/worker/servers.mjs +5 -1
  68. package/worker/site.mjs +9 -4
  69. package/worker/stats.mjs +4 -1
  70. package/worker/version.mjs +1 -1
@@ -0,0 +1,194 @@
1
+ /**
2
+ * IS THIS MODEL FILE SAFE TO LOAD? One answer for every place a model enters a game: the browser loader
3
+ * (assets/assets.ts, before three.js parses a byte), `homie-studio assets add` and `assets check` (Node), and the
4
+ * starter library's build. Plain JavaScript with no imports, so a browser bundle and Node read the same rules.
5
+ *
6
+ * A model is a glTF 2.0 binary (.glb): a 12-byte header, a JSON chunk and one binary chunk. It is refused when:
7
+ * - it is not a GLB (a bare .gltf names its buffers and pictures by address: that is an external file);
8
+ * - any buffer or picture names a `uri` other than a small `data:` one (an external URI could be fetched from
9
+ * anywhere, at any size, by every player's browser);
10
+ * - the file, its declared buffers, a buffer view or an accessor is bigger than the limits (a 200 MB buffer would
11
+ * take a phone's whole page memory before anything checked it), or an accessor reaches past its buffer;
12
+ * - it requires an extension three.js cannot read, or names more meshes, nodes, pictures or animations than any
13
+ * game asset needs.
14
+ * Warnings (extensions used but not required, a picture type a phone may not decode) never refuse.
15
+ */
16
+
17
+ /** The limits, in bytes and counts: `LIMITS.game` for a shipped asset, `LIMITS.import` for a raw file coming in. */
18
+ export const LIMITS = Object.freeze({
19
+ game: Object.freeze({ bytes: 5 * 1024 * 1024, json: 2 * 1024 * 1024, buffer: 5 * 1024 * 1024, dataUri: 256 * 1024, vertices: 500_000, meshes: 512, nodes: 4096, materials: 64, images: 32, textures: 64, animations: 256, skins: 16, accessors: 8192 }),
20
+ import: Object.freeze({ bytes: 64 * 1024 * 1024, json: 8 * 1024 * 1024, buffer: 64 * 1024 * 1024, dataUri: 1024 * 1024, vertices: 4_000_000, meshes: 4096, nodes: 16384, materials: 256, images: 128, textures: 256, animations: 1024, skins: 64, accessors: 32768 }),
21
+ });
22
+
23
+ /** Extensions three.js's GLTFLoader reads (r185). A file that REQUIRES any other is refused. */
24
+ export const READABLE_EXTENSIONS = Object.freeze([
25
+ 'KHR_meshopt_compression', 'EXT_meshopt_compression', 'KHR_mesh_quantization', 'KHR_texture_basisu', 'EXT_texture_webp',
26
+ 'EXT_texture_avif', 'KHR_texture_transform', 'KHR_materials_unlit', 'KHR_materials_emissive_strength', 'KHR_materials_clearcoat',
27
+ 'KHR_materials_ior', 'KHR_materials_specular', 'KHR_materials_transmission', 'KHR_materials_volume', 'KHR_materials_sheen',
28
+ 'KHR_materials_iridescence', 'KHR_materials_anisotropy', 'KHR_materials_dispersion', 'KHR_lights_punctual', 'EXT_mesh_gpu_instancing',
29
+ 'KHR_draco_mesh_compression',
30
+ ]);
31
+
32
+ const PICTURE_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/ktx2', 'image/avif'];
33
+ const COMPONENT_BYTES = { 5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4 };
34
+ const TYPE_SIZE = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT2: 4, MAT3: 9, MAT4: 16 };
35
+ const MAGIC = 0x46546c67; // "glTF"
36
+ const JSON_CHUNK = 0x4e4f534a;
37
+ const BIN_CHUNK = 0x004e4942;
38
+
39
+ const view = (bytes) => new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
40
+ const size = (n) => (n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB` : `${Math.ceil(n / 1024)} KB`);
41
+
42
+ /** The bytes a `data:` URI decodes to, without decoding it. */
43
+ function dataUriBytes(uri) {
44
+ const comma = uri.indexOf(',');
45
+ if (comma < 0) return Infinity;
46
+ const head = uri.slice(5, comma);
47
+ const body = uri.length - comma - 1;
48
+ return /;base64$/i.test(head) ? Math.floor((body * 3) / 4) : body;
49
+ }
50
+
51
+ /**
52
+ * Read a GLB's header and chunks: { json, bin: { offset, length } | null } or throws a plain-words Error.
53
+ * `bytes` is a Uint8Array (a Node Buffer is one).
54
+ */
55
+ export function readGlb(bytes, { maxJson = LIMITS.import.json } = {}) {
56
+ if (!(bytes instanceof Uint8Array)) throw new Error('not a file (expected bytes)');
57
+ if (bytes.byteLength < 20) throw new Error('too short to be a glTF binary (.glb)');
58
+ const dv = view(bytes);
59
+ if (dv.getUint32(0, true) !== MAGIC) {
60
+ const head = new TextDecoder().decode(bytes.subarray(0, Math.min(64, bytes.byteLength))).trimStart();
61
+ throw new Error(head.startsWith('{') ? 'a .gltf with separate files (its buffers and pictures are loaded by address): only a self-contained .glb is loaded' : 'not a glTF binary (.glb)');
62
+ }
63
+ const version = dv.getUint32(4, true);
64
+ if (version !== 2) throw new Error(`glTF version ${version} (only 2 is read)`);
65
+ const length = dv.getUint32(8, true);
66
+ if (length > bytes.byteLength) throw new Error(`the header says ${size(length)} but the file has ${size(bytes.byteLength)}: it is cut short`);
67
+ let at = 12;
68
+ let json = null;
69
+ let bin = null;
70
+ while (at + 8 <= length) {
71
+ const chunkLength = dv.getUint32(at, true);
72
+ const type = dv.getUint32(at + 4, true);
73
+ const start = at + 8;
74
+ if (start + chunkLength > length) throw new Error('a chunk runs past the end of the file');
75
+ if (type === JSON_CHUNK && json === null) {
76
+ if (chunkLength > maxJson) throw new Error(`its JSON is ${size(chunkLength)}, over ${size(maxJson)}`);
77
+ try { json = JSON.parse(new TextDecoder().decode(bytes.subarray(start, start + chunkLength))); } catch { throw new Error('its JSON chunk is not JSON'); }
78
+ } else if (type === BIN_CHUNK && bin === null) bin = { offset: start, length: chunkLength };
79
+ at = start + chunkLength + ((4 - (chunkLength % 4)) % 4);
80
+ }
81
+ if (!json || typeof json !== 'object') throw new Error('it has no JSON chunk');
82
+ return { json, bin, length };
83
+ }
84
+
85
+ /**
86
+ * Check a GLB against `limits` (LIMITS.game by default). Returns
87
+ * { ok, problems: [plain words], warnings: [plain words], info: { bytes, triangles, vertices, meshes, nodes,
88
+ * materials, images, textures, animations, skins, joints, extensions: { used, required } } }
89
+ * Never throws: a file it cannot read is `ok: false` with the reason.
90
+ */
91
+ export function checkGlb(bytes, limits = LIMITS.game) {
92
+ const L = { ...LIMITS.game, ...limits };
93
+ const problems = [];
94
+ const warnings = [];
95
+ const info = { bytes: bytes?.byteLength ?? 0, triangles: 0, vertices: 0, meshes: 0, nodes: 0, materials: 0, images: 0, textures: 0, animations: 0, skins: 0, joints: 0, extensions: { used: [], required: [] } };
96
+ if (info.bytes > L.bytes) problems.push(`the file is ${size(info.bytes)}, over the ${size(L.bytes)} a model may be here`);
97
+ let glb;
98
+ try { glb = readGlb(bytes, { maxJson: L.json }); } catch (error) { problems.push(error.message); return { ok: false, problems, warnings, info }; }
99
+ const j = glb.json;
100
+ const arr = (k) => (Array.isArray(j[k]) ? j[k] : []);
101
+ const v = String(j.asset?.version ?? '');
102
+ if (!/^2\./.test(v)) problems.push(`asset.version is "${v || 'missing'}" (glTF 2.x is read)`);
103
+
104
+ // Counts.
105
+ const counts = { meshes: arr('meshes').length, nodes: arr('nodes').length, materials: arr('materials').length, images: arr('images').length, textures: arr('textures').length, animations: arr('animations').length, skins: arr('skins').length, accessors: arr('accessors').length };
106
+ Object.assign(info, { meshes: counts.meshes, nodes: counts.nodes, materials: counts.materials, images: counts.images, textures: counts.textures, animations: counts.animations, skins: counts.skins });
107
+ for (const [k, n] of Object.entries(counts)) if (L[k] !== undefined && n > L[k]) problems.push(`${n} ${k} (at most ${L[k]})`);
108
+
109
+ // Extensions.
110
+ const used = arr('extensionsUsed').map(String);
111
+ const required = arr('extensionsRequired').map(String);
112
+ info.extensions = { used, required };
113
+ for (const e of required) if (!READABLE_EXTENSIONS.includes(e)) problems.push(`it requires the extension ${e}, which three.js cannot read`);
114
+ for (const e of used) if (!required.includes(e) && !READABLE_EXTENSIONS.includes(e)) warnings.push(`it uses ${e}, which three.js ignores`);
115
+ if (required.includes('KHR_draco_mesh_compression')) warnings.push('Draco geometry: the decoder is about 250 KB more to download; meshopt is the house format');
116
+
117
+ // Buffers: only the GLB's own binary chunk, or a small data: URI. Never an address.
118
+ let declared = 0;
119
+ arr('buffers').forEach((b, i) => {
120
+ const len = Number(b?.byteLength);
121
+ if (!Number.isFinite(len) || len < 0) { problems.push(`buffer ${i} has no byteLength`); return; }
122
+ // A meshopt fallback buffer (no uri, never loaded by a reader that decodes meshopt) holds nothing in the file.
123
+ const fallback = b.uri === undefined && (b.extensions?.EXT_meshopt_compression?.fallback === true || b.extensions?.KHR_meshopt_compression?.fallback === true);
124
+ if (fallback) { if (len > L.buffer * 4) problems.push(`buffer ${i} (a meshopt fallback) declares ${size(len)}`); return; }
125
+ declared += len;
126
+ if (len > L.buffer) problems.push(`buffer ${i} declares ${size(len)}, over ${size(L.buffer)}`);
127
+ if (typeof b.uri === 'string') {
128
+ if (!/^data:/i.test(b.uri)) problems.push(`buffer ${i} is loaded from an address (${b.uri.slice(0, 60)}): an external URI`);
129
+ else if (dataUriBytes(b.uri) > L.dataUri) problems.push(`buffer ${i} is a data: URI of ${size(dataUriBytes(b.uri))}, over ${size(L.dataUri)}`);
130
+ } else if (i === 0) {
131
+ if (!glb.bin) problems.push('buffer 0 has no binary chunk to point at');
132
+ else if (len > glb.bin.length) problems.push(`buffer 0 declares ${size(len)} but the binary chunk holds ${size(glb.bin.length)}`);
133
+ } else problems.push(`buffer ${i} has no uri and is not the binary chunk`);
134
+ });
135
+ if (declared > L.bytes) problems.push(`its buffers declare ${size(declared)} in all, over ${size(L.bytes)}`);
136
+ const bufLen = (i) => Number(arr('buffers')[i]?.byteLength ?? -1);
137
+
138
+ // Buffer views and accessors stay inside what was declared.
139
+ const views = arr('bufferViews');
140
+ views.forEach((bv, i) => {
141
+ const end = Number(bv?.byteOffset ?? 0) + Number(bv?.byteLength ?? NaN);
142
+ if (!Number.isFinite(end) || bufLen(Number(bv?.buffer)) < 0 || end > bufLen(Number(bv.buffer))) problems.push(`buffer view ${i} reaches past its buffer`);
143
+ });
144
+ let maxCount = 0;
145
+ arr('accessors').forEach((a, i) => {
146
+ const count = Number(a?.count);
147
+ if (!Number.isInteger(count) || count < 0) { problems.push(`accessor ${i} has no count`); return; }
148
+ maxCount = Math.max(maxCount, count);
149
+ if (count > L.vertices * 3) problems.push(`accessor ${i} holds ${count.toLocaleString('en-US')} elements`);
150
+ if (a.bufferView === undefined) return;
151
+ const bv = views[Number(a.bufferView)];
152
+ const el = (COMPONENT_BYTES[a.componentType] ?? 0) * (TYPE_SIZE[a.type] ?? 0);
153
+ if (!bv || !el) { problems.push(`accessor ${i} points at nothing it can read`); return; }
154
+ const stride = Number(bv.byteStride ?? 0) || el;
155
+ const need = Number(a.byteOffset ?? 0) + (count ? stride * (count - 1) + el : 0);
156
+ // A meshopt-compressed view holds its decoded size in its extension; the plain view is the fallback.
157
+ const decoded = Number(bv.extensions?.EXT_meshopt_compression?.byteLength ?? bv.extensions?.KHR_meshopt_compression?.byteLength ?? bv.byteLength);
158
+ if (need > Math.max(Number(bv.byteLength), decoded)) problems.push(`accessor ${i} reaches past its buffer view`);
159
+ });
160
+
161
+ // Pictures: inside the file (a buffer view), or a small data: URI. Never an address.
162
+ arr('images').forEach((im, i) => {
163
+ if (typeof im?.uri === 'string') {
164
+ if (!/^data:/i.test(im.uri)) problems.push(`picture ${i} is loaded from an address (${im.uri.slice(0, 60)}): an external URI`);
165
+ else if (dataUriBytes(im.uri) > L.dataUri) problems.push(`picture ${i} is a data: URI of ${size(dataUriBytes(im.uri))}, over ${size(L.dataUri)}`);
166
+ } else if (im?.bufferView === undefined) problems.push(`picture ${i} has neither a buffer view nor a uri`);
167
+ const type = String(im?.mimeType ?? (typeof im?.uri === 'string' ? /^data:([^;,]+)/.exec(im.uri)?.[1] ?? '' : ''));
168
+ if (type && !PICTURE_TYPES.includes(type)) warnings.push(`picture ${i} is ${type}`);
169
+ });
170
+
171
+ // Triangles and vertices, as drawn: each mesh times the nodes that place it (GPU instancing counted once per node).
172
+ const accessors = arr('accessors');
173
+ const meshTris = arr('meshes').map((m) => (Array.isArray(m?.primitives) ? m.primitives : []).reduce((n, p) => {
174
+ const mode = p?.mode ?? 4;
175
+ const count = p?.indices !== undefined ? Number(accessors[p.indices]?.count ?? 0) : Number(accessors[p?.attributes?.POSITION]?.count ?? 0);
176
+ return n + (mode === 4 ? Math.floor(count / 3) : mode === 5 || mode === 6 ? Math.max(0, count - 2) : 0);
177
+ }, 0));
178
+ const meshVerts = arr('meshes').map((m) => (Array.isArray(m?.primitives) ? m.primitives : []).reduce((n, p) => n + Number(accessors[p?.attributes?.POSITION]?.count ?? 0), 0));
179
+ for (const node of arr('nodes')) {
180
+ if (node?.mesh === undefined) continue;
181
+ const inst = Number(accessors[node.extensions?.EXT_mesh_gpu_instancing?.attributes?.TRANSLATION]?.count ?? 1) || 1;
182
+ info.triangles += (meshTris[node.mesh] ?? 0) * inst;
183
+ info.vertices += (meshVerts[node.mesh] ?? 0) * inst;
184
+ }
185
+ info.joints = arr('skins').reduce((n, s) => Math.max(n, Array.isArray(s?.joints) ? s.joints.length : 0), 0);
186
+ if (info.vertices > L.vertices) problems.push(`${info.vertices.toLocaleString('en-US')} vertices drawn (at most ${L.vertices.toLocaleString('en-US')})`);
187
+ void maxCount;
188
+ return { ok: problems.length === 0, problems, warnings, info };
189
+ }
190
+
191
+ /** One line for a refusal: "refused <label>: a; b". */
192
+ export function refusal(label, result) {
193
+ return `refused ${label}: ${result.problems.slice(0, 4).join('; ')}${result.problems.length > 4 ? `; and ${result.problems.length - 4} more` : ''}`;
194
+ }
@@ -100,6 +100,10 @@
100
100
  * tap in their own browser, from the link this prints)
101
101
  * homie-studio office revoke (every office key, play ticket and pending ask ends)
102
102
  *
103
+ * homie-studio chat [--game <id>] room chat (NETPLAY.md section 19): each game's rules, every live room's last
104
+ * minutes, players' reports, the review's day; chat rules <game> [--server <id>]
105
+ * --mode off|emoji|lines|text --who anyone|signed-in|members … | --reset;
106
+ * chat remove <game> <room> <line id>; chat budget <neurons>; chat words
103
107
  * homie-studio servers [--game <id>] every server of every game (worker/servers.mjs): its policy, door, the AI's
104
108
  * level, live rooms and AI, members, and builds that predate servers
105
109
  * homie-studio servers new <game> "<Name>" --policy open|humans-only|hybrid|beginner [--ai <n>] [--guides <n>]
@@ -163,6 +167,31 @@
163
167
  * Claude artifact; --open: open it in this computer's browser
164
168
  * homie-studio codex link <id> a one-time link to the codex on the live site (a private page for the owner)
165
169
  *
170
+ * homie-studio style init <id> [--prompt "<the person's words>"] [--hands-on] [--budget <usd>]
171
+ * art direction as decisions (games/<id>/codex/decisions.json): render style,
172
+ * palette, shape, proportions, materials, light, camera, fonts, effects; the cast,
173
+ * its routes, library family, scale and budgets; rigs, animation, in-game budgets.
174
+ * Each starts as an automatic pick with a why; the first asset built on one pins it
175
+ * homie-studio style [show] <id> [--phase style] the decisions (· auto, ~ steered, ● pinned by use, ■ locked)
176
+ * homie-studio style set|steer|lock|unlock <id> <decision> … change one ("warmer"), lock it for the person (--words
177
+ * "<what they said>"); a locked one changes only with --unlock --reason, and
178
+ * --confirm after the person saw what goes stale (style blast)
179
+ * homie-studio style board <id> three directions drawn by the engine (free): swatches in codex/board/
180
+ * homie-studio style pick <id> <a|b|c> [--mix style.palette=b] the person's pick (or a mix) from the board
181
+ * homie-studio style mood|golden|blast|prompt <id> … a paid mood image's record, golden images, the blast radius
182
+ * of a change, the derived style prompt every generation starts from
183
+ * homie-studio assets [list] <id> every asset: route, licence, measurements, spend (games/<id>/assets/manifest.json)
184
+ * homie-studio assets find "<words>" [--kind prop] [--family kenney] the free CC0 starter library (HOMIE_LIBRARY: a copy)
185
+ * homie-studio assets add <id> <library item> [--height <m>] | --file <model> --license <kind> [--attribution "…"]
186
+ * copied in, checked (no external URIs, no oversized files), made phone-sized,
187
+ * recorded with its licence; RIGHTS.md and credits.json follow
188
+ * homie-studio assets redo <id> <asset> made again from its kept raw file, free (today's budgets and style)
189
+ * homie-studio assets optimise <in> --out <file> [--triangles 1500] [--texture 512] [--height <m>]
190
+ * homie-studio assets check <id> phone budgets (triangles, draw calls, picture memory, download), the
191
+ * glTF-Validator, licences, stale assets, big files in git
192
+ * homie-studio assets lineup|review|rights|stale|remove <id> … the lineup (true scale, silhouettes, palette drift),
193
+ * a reviewer's score, RIGHTS.md, what a changed decision made stale
194
+ *
166
195
  * homie-studio statusline the current build in one line (what Claude Code's status line shows)
167
196
  * homie-studio statusline --install [--project <folder>]
168
197
  * turn it on in Claude Code for this studio (.claude/settings.local.json);
@@ -194,6 +223,7 @@ import { STUDIO_VERSION } from '../lib/version.mjs';
194
223
  import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
195
224
  import { playersOwner, playersShow } from '../lib/players.mjs';
196
225
  import { officeAnnounce, officeClose, officeInvite, officeKey, officeKick, officeLaunch, officeLines, officeLink, officeMute, officeRevoke, officeShow } from '../lib/office.mjs';
226
+ import { chatBudget, chatLines, chatRemove, chatRulesSet, chatShow, chatWords } from '../lib/chat-cli.mjs';
197
227
  import { agentsBrain, agentsBrainKey, agentsPass, agentsPasses, agentsRevoke, serversClose, serversLevel, serversLines, serversList, serversMember, serversNew, serversSet } from '../lib/servers.mjs';
198
228
  import { AgentSeat } from '../lib/agent-seat.mjs';
199
229
  import { Feed, currentFeed, currentId, flushProgress, publicFeed, readFeed, recordChange, startProgress } from '../lib/progress.mjs';
@@ -203,11 +233,12 @@ import { installStatusLine, statusLine } from '../lib/statusline.mjs';
203
233
  import { restartWithProxy } from '../lib/net.mjs';
204
234
  import { demoGames, formatDemo } from '../lib/demo.mjs';
205
235
  import { serveMcp } from '../lib/mcp.mjs';
236
+ import { artLines, assetsCommand, styleCommand } from '../lib/art-cli.mjs';
206
237
  import { formatHandoff, handoff } from '../lib/handoff.mjs';
207
238
 
208
239
  const argv = process.argv.slice(2);
209
240
  const flags = new Map();
210
- const BOOL_FLAGS = ['off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile'];
241
+ const BOOL_FLAGS = ['off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged'];
211
242
  const positional = [];
212
243
  for (let i = 0; i < argv.length; i++) {
213
244
  const a = argv[i];
@@ -226,15 +257,27 @@ function print(result) {
226
257
  if (result.ok === false && result.command !== 'port check' && !(result.command === 'look' && result.rows)) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}${result.instead ? `\n${result.instead}` : ''}\n`); return; }
227
258
  if (result.ok === false && result.command === 'port check' && !result.rows) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}\n`); return; }
228
259
  const lines = [];
260
+ if (/^(style|assets)( |$)/.test(String(result.command ?? ''))) { process.stdout.write(`${artLines(result).join('\n')}\n`); return; }
229
261
  switch (result.command) {
230
262
  case 'new':
231
263
  lines.push(`${result.name} is a studio now: ${result.dir}`, '', 'Wrote:', ...result.wrote.map((f) => ` ${f}`), '', `Dependencies: ${result.installed}`, '', 'Next:', ...result.next.map((n) => ` ${n}`), '', result.online);
232
264
  break;
233
265
  case 'game remix':
234
266
  lines.push(`games/${result.id} is a remix of ${result.from} (${result.files.length} files). Its game.json credits the original: "${result.credit}"${result.page ? ` (${result.page})` : ''}, on its landing and in its credits. Make it yours in games/${result.id}/src/, then: npx homie-studio dev`);
267
+ if (result.assets?.carried) lines.push(` assets: ${result.assets.fetched.length} carried from the original (checked by SHA-256)${result.assets.placeholders.length ? `, ${result.assets.placeholders.length} grey placeholder(s) where the licence does not let a remix carry them (${result.assets.placeholders.map((p) => p.asset).join(', ')}): see games/${result.id}/assets/RIGHTS.md` : ''}`);
268
+ if (result.installNeeded) lines.push(`It needs ${result.needsAdded.map((n) => `${n.name} ${n.version}`).join(', ')}, now in the studio's package.json: run npm install first.`);
269
+ for (const h of result.needsHeld ?? []) lines.push(`It was written against ${h.name} ${h.want}; this studio pins ${h.have}, which stays.`);
270
+ if (result.needsNotAdded?.length) lines.push(`Its game.json also asks for ${result.needsNotAdded.join(', ')}: not added (a remix adds only what the toolkit's starters use). Read the code before adding any.`);
235
271
  break;
236
272
  case 'game new':
237
273
  lines.push(`games/${result.id} is a new game from the ${result.from} starter. Change it in games/${result.id}/src/, then: npx homie-studio dev`);
274
+ if (result.installNeeded) lines.push(`It needs ${result.needsAdded.map((n) => `${n.name} ${n.version}`).join(', ')}, now in the studio's package.json: run npm install first.`);
275
+ for (const h of result.needsHeld ?? []) lines.push(`It was written against ${h.name} ${h.want}; this studio pins ${h.have}, which stays.`);
276
+ if (result.models) {
277
+ const m = result.models;
278
+ lines.push(`Models: ${m.fetched} from the starter library (${m.from}) into games/${result.id}/public/models/, each checked by SHA-256.`);
279
+ if (m.missing.length) lines.push(` ${m.missing.length} not here${m.why ? ` (${m.why})` : `: ${m.missing.map((x) => `${x.asset} (${x.why})`).join(', ')}`}. The game draws stand-ins for them; once the library is reachable, homie-studio assets add ${result.id} <item> --as <asset> brings each in (assets/manifest.json names them).`);
280
+ }
238
281
  break;
239
282
  case 'dev stop':
240
283
  lines.push(result.stopped.length ? `Stopped this studio's dev server (${result.stopped.join(', ')}).` : `Nothing to stop: ${result.why ?? 'no dev server of this studio is running'}.`);
@@ -388,6 +431,20 @@ function print(result) {
388
431
  case 'servers':
389
432
  lines.push(...serversLines(result));
390
433
  break;
434
+ case 'chat':
435
+ lines.push(...chatLines(result));
436
+ break;
437
+ case 'chat words':
438
+ for (const [k, v] of Object.entries(result.groups)) lines.push(`${k}: ${v.join(', ')}`);
439
+ lines.push(result.note);
440
+ break;
441
+ case 'chat remove':
442
+ lines.push(result.message);
443
+ break;
444
+ case 'chat rules':
445
+ case 'chat budget':
446
+ lines.push(result.asked ? `Asked: ${result.what}` : result.message, ...(result.asked ? [`Owner's one-tap link (until the ask ends, 15 min): ${result.link}`, result.use] : []));
447
+ break;
391
448
  case 'servers new':
392
449
  lines.push(result.message, ...(result.notes ?? []).map((n) => ` ${n}`));
393
450
  break;
@@ -552,6 +609,8 @@ async function main() {
552
609
  if (cmd === 'version' || flags.has('version')) return { ok: true, command: 'version', version: STUDIO_VERSION };
553
610
  if (cmd === 'new') return newStudio(positional[1], { name: flags.get('name'), homie: flags.get('homie'), slug: flags.get('slug'), install: !flags.has('no-install'), template: flags.has('template') });
554
611
  if (cmd === 'starters') return { ok: true, command: 'starters', starters: starters() };
612
+ // The floor's built-in words need no studio: anyone can read what a studio's chat always holds.
613
+ if (cmd === 'chat' && sub === 'words') return chatWords();
555
614
  if (cmd === 'demo') return demoGames();
556
615
  if (cmd === 'mcp') {
557
616
  // stdout carries MCP messages only from here on (lib/mcp.mjs); every word for a person goes to stderr.
@@ -573,7 +632,11 @@ async function main() {
573
632
  if (!positional[2] || !positional[3]) return { ok: false, command: 'perf compare', why: 'usage: homie-studio perf compare <before dir> <after dir> [--goal <metric>] [--min 3]' };
574
633
  return perfCompare(positional[2], positional[3], { goal: String(flags.get('goal') ?? DEFAULT_GOAL), min: flags.has('min') ? Number(flags.get('min')) / 100 : 0.03, guards: flags.get('guards') ? String(flags.get('guards')).split(',').map((x) => x.trim()).filter(Boolean) : null, also: flags.get('also') ? String(flags.get('also')).split(',').map((x) => x.trim()).filter(Boolean) : [] });
575
634
  }
635
+ // The starter library and the optimiser work anywhere; the rest of style and assets inside a studio (lib/art-cli.mjs).
636
+ if (cmd === 'assets' && ['find', 'optimise', 'optimize'].includes(sub)) return assetsCommand(null, sub, positional, flags, { log });
576
637
  const root = requireStudio();
638
+ if (cmd === 'style') return styleCommand(root, sub, positional, flags, { log });
639
+ if (cmd === 'assets') return assetsCommand(root, sub, positional, flags, { log });
577
640
  if (cmd === 'statusline') return installStatusLine(root, { remove: flags.has('remove'), replace: flags.has('replace'), project: flags.get('project') ?? null });
578
641
  if (cmd === 'codex') return codexCommand(root, sub);
579
642
  if (cmd === 'progress') return progressCommand(root, sub);
@@ -614,6 +677,10 @@ async function main() {
614
677
  const game = positional[1] ?? listGames(root)[0]?.id;
615
678
  const url = flags.get('url') ?? siteUrl(root);
616
679
  if (!url) return { ok: false, command: 'check', why: 'give --url (the local dev address or the live site)' };
680
+ // A big binary committed under games/ fails the check before any browser starts: it belongs in R2, never in git.
681
+ const { bigCommittedFiles } = await import('../lib/asset-check.mjs');
682
+ const big = bigCommittedFiles(root).filter((f) => f.path.startsWith(`games/${game}/`));
683
+ if (big.length) return { ok: false, command: 'check', why: `committed files over 5 MB under games/${game}: ${big.map((f) => `${f.path} (${(f.bytes / 1024 / 1024).toFixed(1)} MB)`).join(', ')}. Big media goes to the studio's R2 (homie-studio storage add, then media move); raw models stay in art/<slug>/raw/ (git-ignored); a shipped model is made phone-sized with homie-studio assets optimise. Take it out of git (git rm --cached), then check again.` };
617
684
  return tracked(root, 'checks', (report) => check({ url, game, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log, report }), 'check');
618
685
  }
619
686
  if (cmd === 'look') {
@@ -660,6 +727,16 @@ async function main() {
660
727
  if (sub === 'close') return officeClose(root, positional[2], positional[3], { url, minutes: flags.get('minutes'), reopen: flags.has('reopen') });
661
728
  if (sub === 'revoke') return officeRevoke(root, { url });
662
729
  }
730
+ if (cmd === 'chat') {
731
+ // Room chat (0.23.0, NETPLAY.md section 19): rules, the room's last minutes, reports, the review's budget.
732
+ const url = flags.get('url');
733
+ if (sub === 'words') return chatWords();
734
+ if (!sub) return chatShow(root, { url, game: flags.get('game') });
735
+ if (sub === 'rules') return chatRulesSet(root, positional[2], flags, { url });
736
+ if (sub === 'remove') return chatRemove(root, positional[2], positional[3], positional[4], { url, all: flags.has('all') });
737
+ if (sub === 'budget') return chatBudget(root, positional[2], { url });
738
+ return { ok: false, command: 'chat', why: `unknown: chat ${sub} (rules, remove, budget, words)` };
739
+ }
663
740
  if (cmd === 'servers') {
664
741
  const url = flags.get('url');
665
742
  if (!sub || sub === 'list') return serversList(root, { url, game: flags.get('game') });
@@ -882,7 +959,9 @@ async function dev(root) {
882
959
  log(`Local site: http://127.0.0.1:${port}/ (each game: http://127.0.0.1:${port}/<id>/play — open it in two browsers)`);
883
960
  const ai = devConfig(root, flags.has('remote-ai'));
884
961
  if (ai.note) log(ai.note);
885
- const child = spawn(bin, ['dev', '--local', '--ip', '127.0.0.1', '--port', port, ...ai.args], { cwd: workerDir(root), env, stdio: 'inherit' });
962
+ // --remote-ai: Wrangler's --local turns every remote binding off ("not supported"), so a dev with the real Workers AI
963
+ // (the guides' brains, room chat's review) runs without it; everything else stays local all the same.
964
+ const child = spawn(bin, ['dev', ...(flags.has('remote-ai') ? [] : ['--local']), '--ip', '127.0.0.1', '--port', port, ...ai.args], { cwd: workerDir(root), env, stdio: 'inherit' });
886
965
  mkdirSync(dirname(devFile(root)), { recursive: true });
887
966
  writeFileSync(devFile(root), `${JSON.stringify({ pid: process.pid, child: child.pid, port: Number(port), at: new Date().toISOString() })}\n`);
888
967
  log(`Stop it with: npx --no-install homie-studio dev --stop (this studio's dev server only)`);
package/chat/CHAT.md ADDED
@@ -0,0 +1,101 @@
1
+ # Room chat
2
+
3
+ Every studio game has room chat from `@homie-rocks/studio` 0.23.0: reactions that float up every
4
+ screen in the room (the players', the watchers', the big screen's), the game's quick lines, and
5
+ typed messages where the room's rules allow them. It feels like the Homie app's live rooms and
6
+ homie.rocks's room chat because it is the same thing: the same five reactions in the same order,
7
+ the same message shapes, the same float on a television.
8
+
9
+ The wire, the rules and the helper are in [NETPLAY.md section 19](../netplay/NETPLAY.md). This
10
+ page is the short version for a game's author, and why the review uses what it uses. For what a
11
+ studio's owner is responsible for, read [OWNERS.md](OWNERS.md).
12
+
13
+ ## What a game gets with no work
14
+
15
+ - **The Chat pill** on the play page, beside the room button: the room's last minutes, the
16
+ reactions, the quick lines, typing where allowed, Report on a line, and two switches ("Show my
17
+ messages over my character", "Show chat on this screen").
18
+ - **The float:** every reaction rises up every screen in the room.
19
+ - **The ticker:** a new line shows for a moment where the play page's status chip sits.
20
+ - **The big screen** (`/<game>/tv`) shows the room's lines in a corner; the **watch page** has a
21
+ Chat button; **homie.rocks** shows the room's chat on its card when the studio allows it.
22
+
23
+ ## What a game can add
24
+
25
+ **Its rules, in game.json** (the owner can change every one in the office):
26
+
27
+ ```json
28
+ "chat": {
29
+ "mode": "text",
30
+ "who": "signed-in",
31
+ "react": "anyone",
32
+ "slow": 2,
33
+ "max": 140,
34
+ "emoji": { "gem": "💎" },
35
+ "lines": { "gg": "Good game!", "gem": "Grab that gem!", "help": "Help me!", "wait": "Wait for me!" }
36
+ }
37
+ ```
38
+
39
+ | Field | | Default |
40
+ |---|---|---|
41
+ | `mode` | `off`, `emoji` (reactions only), `lines` (and quick lines), `text` (and typing) | `text` |
42
+ | `who` | who may type: `anyone`, `signed-in` (a player account with a passkey), `members` (of the room's server) | `signed-in` |
43
+ | `react` | who may send reactions and quick lines (the same three) | `anyone` |
44
+ | `slow`, `max` | seconds between one person's lines; a typed line's length | 2, 140 |
45
+ | `links`, `swears` | `block` or `allow` (a link is never clickable; slurs, sexual words, threats and contact details are always held) | `block` |
46
+ | `ai` | review typed messages with the studio's own Workers AI | `true` |
47
+ | `bubbles`, `watchers`, `hub` | over the speaker's character; watchers may send; homie.rocks may show it | `true` |
48
+ | `emoji`, `lines` | up to 3 more reactions; up to 12 quick lines (each passes the chat floor at build) | the five; 8 friendly lines |
49
+ | `block`, `allow` | the studio's own words to hold (`word*` inside words) or to let through | none |
50
+
51
+ `"chat": false` turns it off. A kids server, and every beginner server, keeps chat to emoji and
52
+ quick lines whatever the game says. Where new lines show for a moment: game.json
53
+ `"screen": { "chat": "bottom-right" }` (any corner, `top-center` or `bottom-center`), or `false`
54
+ for only the pill's count.
55
+
56
+ **Speech bubbles over characters**, with the port kit (the starters draw them):
57
+
58
+ ```ts
59
+ import { createBubbles, paintBubbles, BUBBLE_FONT } from '@homie-rocks/studio/port';
60
+ const bubbles = createBubbles({ measure: (t) => { ctx.font = BUBBLE_FONT; return ctx.measureText(t).width; } });
61
+ net.on('say', (s) => bubbles.say(s.seat, s.text, { id: s.id, kind: s.kind }));
62
+ net.on('unchat', (e) => e.ids.forEach((id) => bubbles.remove(id)));
63
+ // each frame, after the names: the anchor is just over each speaking body's name
64
+ paintBubbles(ctx, bubbles.place(speakers.map((s) => ({ key: s.seat, x: s.x, y: s.nameTop, self: s.mine }))));
65
+ ```
66
+
67
+ A 3D game draws them the same way on its HUD canvas (Gem Rush 3D does), or reads `place()`'s boxes
68
+ and draws its own. **The game's own chat UI** (a key that opens a wheel of quick lines, say):
69
+ `net.sayLine('gg')`, `net.react('fire')`, `net.say(text)`; `net.on('chat')` for a log of your own.
70
+
71
+ ## Moderation: what it uses, and why
72
+
73
+ The owner asked for "Cloudflare's new decisions API". That is **Clef**, Cloudflare's decision
74
+ models, launched on Workers AI on 2026-10-01 (Birthday Week): `@cf/cloudflare/clef-flash` (9B) and
75
+ `@cf/cloudflare/clef` (27B). They do not write text: you send a `state` (here, the message) and
76
+ typed questions, and they return a probability for every allowed answer. Cloudflare names "trust
77
+ and safety: score user submissions against your own policy rubric" as a use.
78
+
79
+ | | Clef-flash (the default) | Clef | Llama Guard 3 8B | AI Gateway Guardrails |
80
+ |---|---|---|---|---|
81
+ | What | a decision model: our own question, a probability per answer | the same, larger | Meta's fixed safety categories (S1–S14) | Llama Guard run on prompts and responses of model calls through a gateway |
82
+ | Price | $0.09 per M input tokens, no output charge: measured about 2.3 neurons a short message (the question is most of it) | $0.24 per M: about 6 neurons | $0.48 per M in, $0.03 out: up to about 10 neurons a message with its policy prompt | Llama Guard's price, plus the model call it guards |
83
+ | Speed | about 40 ms median, 120 ms p95 (Cloudflare's numbers) | about 210 ms median | about 500 ms (Cloudflare's guardrails notes) | a model call plus about 500 ms |
84
+ | Fits game chat | our categories: insults, hate, sexual, grooming, harm, spam | the same, more accurate | no insults or bullying category; most game-chat abuse reads as "safe" | not for arbitrary text: it guards model traffic |
85
+
86
+ Clef-flash is the default because it is the cheapest per message by about five times, the fastest
87
+ by far (a message waits for it before anyone sees it), and lets the studio ask exactly what game
88
+ chat needs. Honest caveats: it is one day old as this ships, Cloudflare publishes no moderation
89
+ accuracy numbers for it, and the message it reads is a player's, so a player can try to steer it
90
+ (less of a risk than with a model that writes, since it only scores fixed answers). That is why
91
+ **the word list always runs first and decides alone** whenever the review cannot (no binding, the
92
+ day's budget used, an error, a slow answer). `HOMIE_CHAT_MODEL` switches to Clef or Llama Guard.
93
+ Firewall for AI (now "AI Security for Apps") was ruled out: it reads HTTP requests at the zone, not
94
+ messages inside a Durable Object, and its detections are an Enterprise add-on.
95
+
96
+ **What it costs a studio.** Workers AI gives every account 10,000 neurons a day free, on the free
97
+ Workers plan too, with no payment method. The review's day is 2,000 neurons by default (about
98
+ 850 typed messages, measured), leaving the AI guides their 8,000; `homie-studio chat budget <n>` changes it. On the free plan, past the allocation
99
+ Workers AI refuses (the floor decides); on Workers Paid, past it is $0.011 per 1,000 neurons, which
100
+ is why the review stops at its own budget. `homie-studio deploy` binds Workers AI when a game lets
101
+ players type; `homie-studio dev --remote-ai` uses the real one (billed to the signed-in account).
package/chat/OWNERS.md ADDED
@@ -0,0 +1,78 @@
1
+ # Room chat: a note for studio owners
2
+
3
+ **This is not legal advice.** It says what Homie's tooling does and does not do, so you can decide
4
+ what your studio needs. Laws about online chat and children's data differ by country and change;
5
+ if your games are for children, or many children play them, talk to someone who advises on that
6
+ where you and your players are.
7
+
8
+ ## Your studio, your rooms, your responsibility
9
+
10
+ A Homie studio runs on **your own Cloudflare account**: your Worker, your Durable Objects, your D1
11
+ database, your Workers AI. Homie (and homie.rocks) does not run your rooms, does not see your
12
+ players' chat, and does not moderate it for you. You choose the rules, you hold the owner's
13
+ controls, and you are the one responsible for what happens in your rooms, as the operator of the
14
+ service. Homie gives you the tools to do that well; it cannot do it for you.
15
+
16
+ What that means in practice:
17
+
18
+ - **Choose rules that fit your players.** Each game sets its defaults in game.json, and you change
19
+ them per game and per server in the office (`/_studio/office`) or with `homie-studio chat rules`.
20
+ If your players are young, keep chat to emoji and quick lines, or put them on a kids server (a
21
+ beginner server with `kids` on): typing is then off whatever the game says.
22
+ - **Watch your rooms, and act.** The office shows every live room's last minutes. Remove a message,
23
+ mute or kick its sender (their messages come down with them), and read what players report.
24
+ - **Answer reports.** Players can report a message. You see it in the office and with
25
+ `homie-studio chat`; dismiss it once you have dealt with it.
26
+ - **Say who your game is for, and what chat it has,** on your game's page and in your own terms or
27
+ rules, if you have them.
28
+
29
+ ## What the tooling does
30
+
31
+ | | |
32
+ |---|---|
33
+ | **Who can say what** | By default: everyone can send emoji and the game's quick lines; only **signed-in players** (an account with a passkey on your studio) can type. You can open typing to guests, keep it to members of a server, keep a room to emoji or quick lines, or turn chat off. |
34
+ | **Before anyone sees a typed message** | 1) a built-in word list and patterns (slurs, sexual words, telling someone to hurt themselves, asking a player's age or where they live, asking for pictures, moving a player to another app, email addresses and phone numbers, links, swears), plus your own words; 2) a review on your own Workers AI (Cloudflare's Clef decision model) that holds insults, hate, sexual content, grooming, threats and spam. The list always runs; the review runs within a daily budget. Neither is perfect: both are English-first, and a determined person can get past any filter. |
35
+ | **Emoji and quick lines** | Fixed words you (the game) chose. They are never reviewed and reach every screen at once. |
36
+ | **Children** | A kids server keeps chat to emoji and quick lines, gives players handles instead of names, and never asks anyone's age. Homie never asks for a player's age, birthday or address anywhere. |
37
+ | **AI players** | An AI never types in room chat. |
38
+ | **Slow mode and limits** | One line every 2 s by default (you choose), and message length, repeats and floods are capped. |
39
+
40
+ ## What is stored, and for how long
41
+
42
+ | What | Where | How long |
43
+ |---|---|---|
44
+ | The chat itself (every message and reaction) | Only in the room's memory, on your Cloudflare account | The last 50 messages of the last 15 minutes, for someone who just opened the room. **Never written to storage.** An empty room forgets them a minute after the last person leaves. |
45
+ | Who said what | The same memory, for your office's Mute and Kick | As long as the message is in that memory. Never an IP address. |
46
+ | A **report** | Your D1 (`chat_reports`) | The one message reported (its words, its sender's name and account id if they had one, the room, when, the reason picked), for **30 days** or until you dismiss it. Never who reported it. |
47
+ | Your chat rules and your words | Your D1 (`chat_rules`) | Until you change them. |
48
+ | Counts (messages sent, held and why, reviews) | Your D1 (`stats_daily`) | Daily numbers, never a message and never a person. |
49
+ | Player accounts | Your D1 (the players tables, `saves/SAVES.md`) | A random id, a display name, a passkey's public key. No email unless a player adds one; players can download and delete everything of theirs at `/account/`. |
50
+
51
+ **The review** sends a typed message's words (and nothing about who wrote it) to Workers AI on your
52
+ own Cloudflare account. Cloudflare says it does not store or train on Clef's requests or responses.
53
+ **homie.rocks** stores nothing of your chat: when your rules allow it, a visitor's own browser opens
54
+ your room's socket to show its chat on your room's card there. Turn that off with `hub` off.
55
+
56
+ ## Children's data, in plain words
57
+
58
+ Laws in many places (for example COPPA in the United States, GDPR and the UK's Children's Code in
59
+ Europe and the UK) set rules for online services that collect personal information from children,
60
+ or that are directed at children. A typed message can contain personal information. What helps:
61
+
62
+ - **Keep chat to emoji and quick lines for games aimed at children** (or a kids server). Then no
63
+ child types anything, and there is nothing personal in your chat to keep or lose.
64
+ - **Typing for signed-in players only** (the default) means a person behind each typed line has an
65
+ account you can act on.
66
+ - **Little is stored**, and only briefly (see above). Reports are the exception, kept 30 days.
67
+ - **You can delete**: a report with Dismiss, a message with Remove, a player's whole account in
68
+ their own account page (or `players` in the office).
69
+
70
+ Homie does not know your players' ages and does not verify them. Whether your game is "directed at
71
+ children", and what that requires of you, is for you to decide with advice where you need it.
72
+
73
+ ## What the tooling cannot promise
74
+
75
+ No filter catches everything, and a review model can be wrong both ways. The word list is English;
76
+ other languages rely on the review. A studio past its daily review budget, or whose Worker has no
77
+ Workers AI binding yet, relies on the word list alone until the next day (the office says so). Your
78
+ attention, your rules and your owner's controls are the real safety net.