@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.
- package/CHANGELOG.md +68 -1
- package/README.md +92 -0
- package/assets/assets.ts +500 -0
- package/assets/render-page.ts +629 -0
- package/assets/safety.mjs +194 -0
- package/bin/homie-studio.mjs +81 -2
- package/chat/CHAT.md +101 -0
- package/chat/OWNERS.md +78 -0
- package/lib/art-cli.mjs +296 -0
- package/lib/art-tools.mjs +331 -0
- package/lib/asset-check.mjs +189 -0
- package/lib/asset-import.mjs +136 -0
- package/lib/asset-manifest.mjs +318 -0
- package/lib/build.mjs +32 -2
- package/lib/chat-cli.mjs +89 -0
- package/lib/cloudflare.mjs +5 -2
- package/lib/codex.mjs +43 -1
- package/lib/colour.mjs +160 -0
- package/lib/decisions.mjs +800 -0
- package/lib/directory.mjs +9 -1
- package/lib/library.mjs +169 -0
- package/lib/mcp-tools.mjs +22 -4
- package/lib/mcp.mjs +6 -3
- package/lib/optimise.mjs +380 -0
- package/lib/remix-assets.mjs +84 -0
- package/lib/render3d.mjs +81 -0
- package/lib/scaffold.mjs +39 -2
- package/lib/site.mjs +6 -1
- package/lib/studio.mjs +136 -9
- package/lib/style-board.mjs +227 -0
- package/lib/style-presets.mjs +121 -0
- package/lib/template-history.json +76 -0
- package/mcp/ui/card.css +15 -0
- package/mcp/ui/cast.js +62 -0
- package/mcp/ui/decision.js +61 -0
- package/mcp/ui/lineup.js +68 -0
- package/mcp/ui/rights.js +30 -0
- package/mcp/ui/style.js +154 -0
- package/netplay/NETPLAY.md +191 -3
- package/netplay/netplay.ts +150 -3
- package/package.json +15 -2
- package/port/index.ts +3 -2
- package/port/view.ts +238 -0
- package/site/SITE.md +11 -1
- package/starters/ember-vale/src/main.ts +22 -2
- package/starters/gem-rush/src/main.ts +21 -1
- package/starters/gem-rush-3d/assets/RIGHTS.md +203 -0
- package/starters/gem-rush-3d/assets/manifest.json +2318 -0
- package/starters/gem-rush-3d/credits.json +31 -0
- package/starters/gem-rush-3d/game.json +11 -0
- package/starters/gem-rush-3d/index.html +20 -0
- package/starters/gem-rush-3d/lab.json +19 -0
- package/starters/gem-rush-3d/src/main.ts +2107 -0
- package/starters/gem-rush-3d/style.json +66 -0
- package/starters/gem-rush-3d/tunables.json +13 -0
- package/worker/agents.mjs +3 -3
- package/worker/chat-page.mjs +556 -0
- package/worker/chat-store.mjs +161 -0
- package/worker/chat-words.mjs +46 -0
- package/worker/chat.mjs +419 -0
- package/worker/index.mjs +153 -8
- package/worker/office-page.mjs +115 -0
- package/worker/office.mjs +137 -6
- package/worker/pages.mjs +32 -9
- package/worker/qr.mjs +11 -0
- package/worker/room.mjs +288 -10
- package/worker/servers.mjs +5 -1
- package/worker/site.mjs +9 -4
- package/worker/stats.mjs +4 -1
- 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
|
+
}
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|