@homie-rocks/studio 0.10.0 → 0.11.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/lib/codex.mjs ADDED
@@ -0,0 +1,664 @@
1
+ /**
2
+ * THE GAME CODEX: one game's plan, as a page in the game's own look that anyone can read and steer, coder or not.
3
+ *
4
+ * games/<id>/CODEX.md the source of truth, in the studio's repository (plain Markdown)
5
+ * .studio/codex/<id>.html the page on this computer (git-ignored); it redraws itself whenever the
6
+ * build's progress feed changes, and refreshes in the browser while it runs
7
+ * site/dist/_studio/codex/<id>/ the same page on the studio's site, for its owner only (the stats sign-in;
8
+ * `homie-studio codex link <id>`), never listed, never indexed
9
+ * `codex <id> --artifact` a copy made to be published as a Claude artifact (one self-contained file)
10
+ *
11
+ * CODEX.md is ordinary Markdown with a small frontmatter for the look, and a few conventions that turn into cards:
12
+ *
13
+ * ---
14
+ * eyebrow: The Ashen Reach a small label over the title
15
+ * tagline: Outrun the fire. one line under it
16
+ * cover: hero/wide.jpg a picture from the game's folder, behind the title
17
+ * palette: { bg, panel, ink, accent, accent2, danger, good } colours (#hex); missing ones come from the
18
+ * game's landing theme, then site/theme.json
19
+ * fonts: { display, body, mono } a Google Fonts family name, or a font file in the game's folder
20
+ * pixel: true pictures keep hard pixel edges when scaled (pixel art)
21
+ * try: Arrows to run, Space to dash. how to play it, for the build status "Ready to try" box
22
+ * ---
23
+ * # Game Name the title (else game.json's name); the paragraph after it is the pitch
24
+ * ## Section one tab each: Latest, Concept, World, Characters, Art direction, Controls,
25
+ * Rooms and players, Music and sound, Milestones, Open questions, or any other
26
+ * ### Name a card: its first picture is the card's art; a line of `code` chips is its
27
+ * id (`M-01`) and tags (`danger: Aggressive`, `good: Passive`, `gold: Rare`);
28
+ * an italic line is its subtitle; a list of `**Key:** value` is its stats
29
+ * | a | table | a table (controls per device: Phone, Computer, TV)
30
+ * - [x] done / - [ ] not yet a checklist (milestones)
31
+ * - 2026-10-01: a decision under Latest: the decision log, newest first
32
+ *
33
+ * Text is escaped (lib/markdown.mjs): a codex can never run a script. Pictures and fonts come only from inside the
34
+ * studio's folder and are embedded in the page, so it is one file that works anywhere (a browser, an artifact, the
35
+ * site) and never loads anything but Google Fonts.
36
+ */
37
+ import { createHash } from 'node:crypto';
38
+ import { CODEX_SCRIPT } from '../worker/codex-script.mjs';
39
+ import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
40
+ import { extname, join, relative, resolve, sep } from 'node:path';
41
+ import { latestFeedFor, summarize } from './feed-summary.mjs';
42
+ import { escapeHtml as esc, renderMarkdown } from './markdown.mjs';
43
+ import { readTheme } from './site.mjs';
44
+ import { GAME_ID, listGames, readStudio } from './studio.mjs';
45
+
46
+ export const CODEX_FILE = 'CODEX.md';
47
+ export const CODEX_DIR = join('.studio', 'codex');
48
+ const IMAGE_TYPES = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif', '.svg': 'image/svg+xml' };
49
+ const FONT_TYPES = { '.woff2': 'font/woff2', '.woff': 'font/woff', '.ttf': 'font/ttf', '.otf': 'font/otf' };
50
+ const LIMITS = Object.freeze({ image: 2.5 * 1024 * 1024, images: 10 * 1024 * 1024, font: 1024 * 1024, source: 512 * 1024 });
51
+ const COLOR = /^(#[0-9a-fA-F]{3,8}|(rgb|hsl)a?\([0-9.,%\s/-]{3,60}\))$/;
52
+ const FAMILY = /^[A-Za-z0-9][A-Za-z0-9 ]{0,39}$/;
53
+
54
+ /**
55
+ * The sections a full plan has (the `plan` skill's interview fills them). A section counts under any of these
56
+ * headings; `missing` lists the ones a codex does not have yet.
57
+ */
58
+ export const SECTIONS = [
59
+ { key: 'concept', title: 'Concept', match: /^(concept|the game|pitch|overview|core loop)\b/i },
60
+ { key: 'world', title: 'World', match: /^(world|setting|story|lore|zones?|maps?|levels?)\b/i },
61
+ { key: 'characters', title: 'Characters', match: /^(characters|cast|heroes|classes|creatures|monsters|enemies|units|pieces)\b/i },
62
+ { key: 'art', title: 'Art direction', match: /^(art|look|style|visual)/i },
63
+ { key: 'controls', title: 'Controls', match: /^(controls|devices|input)\b/i },
64
+ { key: 'rooms', title: 'Rooms and players', match: /^(rooms|players|multiplayer|netplay)\b/i },
65
+ { key: 'sound', title: 'Music and sound', match: /^(music|sound|audio)\b/i },
66
+ { key: 'milestones', title: 'Milestones', match: /^(milestones?|plan|roadmap|scope|steps)\b/i },
67
+ { key: 'questions', title: 'Open questions', match: /^(open questions|questions|undecided|to decide)\b/i },
68
+ ];
69
+
70
+ /* ------------------------------------------------------------------ parsing */
71
+
72
+ /** A small YAML subset: `key: value`, quoted or bare, and one level of nested `key:` blocks or `{ a: b, c: d }`. */
73
+ export function parseFrontmatter(text) {
74
+ const meta = {};
75
+ let parent = null;
76
+ const value = (raw) => {
77
+ const t = String(raw).trim();
78
+ const dq = /^"((?:[^"\\]|\\.)*)"/.exec(t);
79
+ if (dq) { try { return JSON.parse(`"${dq[1]}"`); } catch { return dq[1]; } }
80
+ const sq = /^'([^']*)'/.exec(t);
81
+ if (sq) return sq[1];
82
+ const v = t.replace(/\s+#.*$/, '').trim();
83
+ if (v === 'true') return true;
84
+ if (v === 'false') return false;
85
+ if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v);
86
+ return v;
87
+ };
88
+ for (const line of String(text).split('\n')) {
89
+ if (!line.trim() || /^\s*#/.test(line)) continue;
90
+ const nested = /^\s{2,}([A-Za-z][\w-]*):\s*(.*)$/.exec(line);
91
+ if (nested && parent) { meta[parent][nested[1]] = value(nested[2]); continue; }
92
+ const top = /^([A-Za-z][\w-]*):\s*(.*)$/.exec(line);
93
+ if (!top) continue;
94
+ const [, key, raw] = top;
95
+ const inline = /^\{(.*)\}\s*$/.exec(raw.trim());
96
+ if (inline) {
97
+ meta[key] = {};
98
+ for (const pair of inline[1].split(',')) { const m = /^\s*([A-Za-z][\w-]*)\s*:\s*(.+?)\s*$/.exec(pair); if (m) meta[key][m[1]] = value(m[2]); }
99
+ parent = null;
100
+ } else if (!raw.trim()) { meta[key] = {}; parent = key; }
101
+ else { meta[key] = value(raw); parent = null; }
102
+ }
103
+ return meta;
104
+ }
105
+
106
+ /** CODEX.md as { meta, title, pitch (Markdown), sections: [{ title, id, body (lines) }] }. HTML comments are notes for the AI and are dropped. */
107
+ export function parseCodex(source) {
108
+ let text = String(source ?? '').replace(/\r\n?/g, '\n').replace(/<!--[\s\S]*?-->/g, '');
109
+ let meta = {};
110
+ const fm = /^---\n([\s\S]*?)\n---\n?/.exec(text);
111
+ if (fm) { meta = parseFrontmatter(fm[1]); text = text.slice(fm[0].length); }
112
+ const lines = text.split('\n');
113
+ let title = null;
114
+ const pitch = [];
115
+ const sections = [];
116
+ let fence = false;
117
+ for (const line of lines) {
118
+ if (/^(```|~~~)/.test(line)) fence = !fence;
119
+ const h1 = !fence && /^#\s+(.+?)\s*#*\s*$/.exec(line);
120
+ const h2 = !fence && /^##\s+(.+?)\s*#*\s*$/.exec(line);
121
+ if (h1 && title === null && !sections.length) { title = h1[1]; continue; }
122
+ if (h2) { sections.push({ title: h2[1].trim(), body: [] }); continue; }
123
+ if (sections.length) sections[sections.length - 1].body.push(line);
124
+ else pitch.push(line);
125
+ }
126
+ const seen = new Set();
127
+ for (const s of sections) {
128
+ let id = s.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'section';
129
+ while (seen.has(id)) id = `${id}-2`;
130
+ seen.add(id);
131
+ s.id = id;
132
+ s.key = SECTIONS.find((k) => k.match.test(s.title))?.key ?? (/^latest|^decisions|^changelog|^news/i.test(s.title) ? 'latest' : null);
133
+ }
134
+ return { meta, title, pitch: pitch.join('\n').trim(), sections };
135
+ }
136
+
137
+ /** What a codex still lacks, for the AI's next questions: sections not there, or there with nothing in them. */
138
+ export function codexGaps(parsed) {
139
+ const has = (key) => parsed.sections.some((s) => s.key === key && s.body.join('\n').replace(/<!--[\s\S]*?-->/g, '').trim());
140
+ const questions = parsed.sections.filter((s) => s.key === 'questions').flatMap((s) => s.body.filter((l) => /^\s*([-*+]|\d+[.)])\s+\S/.test(l)));
141
+ return { missing: SECTIONS.filter((s) => s.key !== 'questions' && !has(s.key)).map((s) => s.title), openQuestions: questions.length };
142
+ }
143
+
144
+ /* ------------------------------------------------------------------ files from the studio */
145
+
146
+ /** A file a codex names, if it is inside the studio (the game's folder first, then the studio's root). */
147
+ function studioFile(root, gameDir, rel) {
148
+ const clean = String(rel ?? '').trim();
149
+ if (!clean || /^[a-z]+:/i.test(clean) || clean.includes('\0')) return null;
150
+ let realRoot;
151
+ try { realRoot = realpathSync(root); } catch { return null; }
152
+ for (const base of [gameDir, root]) {
153
+ const abs = resolve(base, clean.replace(/^\/+/, ''));
154
+ if (!existsSync(abs)) continue;
155
+ let real;
156
+ try { real = realpathSync(abs); } catch { continue; }
157
+ if ((real === realRoot || real.startsWith(realRoot + sep)) && statSync(real).isFile()) return real;
158
+ }
159
+ return null;
160
+ }
161
+
162
+ class Embedder {
163
+ constructor(root, gameDir) { this.root = root; this.gameDir = gameDir; this.used = 0; this.cache = new Map(); this.warnings = []; }
164
+ /** A picture as a data: URL, or null (with a warning) when it is missing, not a picture, or too big. */
165
+ image(rel) {
166
+ if (this.cache.has(rel)) return this.cache.get(rel);
167
+ const file = studioFile(this.root, this.gameDir, rel);
168
+ const type = IMAGE_TYPES[extname(String(rel)).toLowerCase()];
169
+ let out = null;
170
+ if (!file || !type) this.warnings.push(`picture ${rel}: ${!type ? 'not a .png, .jpg, .webp, .gif or .svg' : 'not found inside the studio'}`);
171
+ else {
172
+ const size = statSync(file).size;
173
+ if (size > LIMITS.image) this.warnings.push(`picture ${rel}: ${Math.round(size / 1024)} KB is over ${Math.round(LIMITS.image / 1024)} KB; use a smaller copy`);
174
+ else if (this.used + size > LIMITS.images) this.warnings.push(`picture ${rel}: the page already holds ${Math.round(LIMITS.images / 1024 / 1024)} MB of pictures`);
175
+ else { this.used += size; out = `data:${type};base64,${readFileSync(file).toString('base64')}`; }
176
+ }
177
+ this.cache.set(rel, out);
178
+ return out;
179
+ }
180
+ font(rel) {
181
+ const file = studioFile(this.root, this.gameDir, rel);
182
+ const type = FONT_TYPES[extname(String(rel)).toLowerCase()];
183
+ if (!file || !type) { this.warnings.push(`font ${rel}: not a font file inside the studio`); return null; }
184
+ if (statSync(file).size > LIMITS.font) { this.warnings.push(`font ${rel}: over ${LIMITS.font / 1024} KB`); return null; }
185
+ return `data:${type};base64,${readFileSync(file).toString('base64')}`;
186
+ }
187
+ }
188
+
189
+ /* ------------------------------------------------------------------ blocks */
190
+
191
+ const IMAGE_LINE = /^\s*!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)\s*$/;
192
+ const CHIP_LINE = /^\s*(`[^`\n]+`[\s·,]*)+$/;
193
+ const TASK = /^\s*[-*+]\s+\[( |x|X)\]\s+(.*)$/;
194
+ const STAT = /^\s*[-*+]\s+(?:\*\*([^*:]{1,32}):?\*\*:?|([A-Z][A-Za-z0-9 /'&-]{0,31}):)\s+(.+)$/;
195
+ const DATED = /^\s*[-*+]\s+(\d{4}-\d{2}-\d{2})\s*(?:[-–—:·]\s*)?(.+)$/;
196
+ const TONES = { good: /passive|friendly|ally|player|safe|heal|support|calm/i, danger: /aggress|hostile|boss|danger|enemy|deadly|hazard|trap|attack/i, gold: /rare|elite|legend|epic|unique|treasure|gold|boss/i };
197
+
198
+ function chip(raw) {
199
+ const m = /^(good|danger|gold|info|dim):\s*(.+)$/i.exec(raw.trim());
200
+ const text = m ? m[2] : raw.trim();
201
+ const tone = m ? m[1].toLowerCase() : /^[A-Z]{1,4}-?\d{1,4}$/.test(text) ? 'id' : TONES.danger.test(text) ? 'danger' : TONES.gold.test(text) ? 'gold' : TONES.good.test(text) ? 'good' : 'plain';
202
+ return `<span class="chip ${tone}">${esc(text)}</span>`;
203
+ }
204
+ const chipsOf = (line) => [...line.matchAll(/`([^`\n]+)`/g)].map((m) => chip(m[1])).join('');
205
+
206
+ function table(lines) {
207
+ const cells = (line) => line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
208
+ const head = cells(lines[0]);
209
+ const rows = lines.slice(2).map(cells);
210
+ const inline = (t) => renderMarkdown(t).html.replace(/^<p>|<\/p>$/g, '');
211
+ return `<div class="scroll"><table><thead><tr>${head.map((h) => `<th>${inline(h)}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr>${head.map((_, i) => `<td>${inline(r[i] ?? '')}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
212
+ }
213
+
214
+ function figure(emb, alt, src, caption, cls = '') {
215
+ const data = emb.image(src);
216
+ if (!data) return `<figure class="missing ${cls}"><span>${esc(alt || src)}</span></figure>`;
217
+ return `<figure class="${cls}"><img src="${data}" alt="${esc(alt)}" loading="lazy" decoding="async">${caption ? `<figcaption>${esc(caption)}</figcaption>` : ''}</figure>`;
218
+ }
219
+
220
+ /**
221
+ * A run of lines into HTML: tables, checklists, picture runs (a gallery), dated decisions and stats lists are drawn
222
+ * here; everything else goes through the posts' safe Markdown.
223
+ */
224
+ function blocks(lines, emb, { dated = false } = {}) {
225
+ const out = [];
226
+ let prose = [];
227
+ const flush = () => { const t = prose.join('\n').trim(); if (t) out.push(renderMarkdown(t).html); prose = []; };
228
+ for (let i = 0; i < lines.length; i++) {
229
+ const line = lines[i];
230
+ if (/^\s*\|.*\|\s*$/.test(line) && /^\s*\|?\s*:?-{3,}/.test(lines[i + 1] ?? '')) {
231
+ flush();
232
+ const t = [];
233
+ for (; i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i]); i++) t.push(lines[i]);
234
+ i -= 1;
235
+ out.push(table(t));
236
+ continue;
237
+ }
238
+ if (TASK.test(line)) {
239
+ flush();
240
+ const items = [];
241
+ for (; i < lines.length && TASK.test(lines[i]); i++) { const m = TASK.exec(lines[i]); items.push({ done: m[1] !== ' ', text: m[2] }); }
242
+ i -= 1;
243
+ out.push(`<ul class="checklist">${items.map((t) => `<li class="${t.done ? 'done' : ''}"><span class="tick">${t.done ? '✓' : ''}</span><span>${renderMarkdown(t.text).html.replace(/^<p>|<\/p>$/g, '')}</span></li>`).join('')}</ul>`);
244
+ continue;
245
+ }
246
+ if (dated && DATED.test(line)) {
247
+ flush();
248
+ const items = [];
249
+ for (; i < lines.length && (DATED.test(lines[i]) || /^\s{2,}\S/.test(lines[i])); i++) {
250
+ const m = DATED.exec(lines[i]);
251
+ if (m) items.push({ day: m[1], text: m[2] }); else if (items.length) items[items.length - 1].text += ` ${lines[i].trim()}`;
252
+ }
253
+ i -= 1;
254
+ items.sort((a, b) => b.day.localeCompare(a.day));
255
+ out.push(`<ol class="timeline">${items.map((t) => `<li><time>${esc(t.day)}</time><span>${renderMarkdown(t.text).html.replace(/^<p>|<\/p>$/g, '')}</span></li>`).join('')}</ol>`);
256
+ continue;
257
+ }
258
+ if (IMAGE_LINE.test(line)) {
259
+ flush();
260
+ const pics = [];
261
+ for (; i < lines.length && (IMAGE_LINE.test(lines[i]) || (!lines[i].trim() && IMAGE_LINE.test(lines[i + 1] ?? ''))); i++) {
262
+ const m = IMAGE_LINE.exec(lines[i]);
263
+ if (m) pics.push(m);
264
+ }
265
+ i -= 1;
266
+ out.push(`<div class="${pics.length > 1 ? 'gallery' : 'single'}">${pics.map((m) => figure(emb, m[1], m[2], m[3] ?? (pics.length > 1 ? m[1] : ''))).join('')}</div>`);
267
+ continue;
268
+ }
269
+ prose.push(line);
270
+ }
271
+ flush();
272
+ return out.join('\n');
273
+ }
274
+
275
+ /** A `### Name` subsection as a card. */
276
+ function card(title, lines, emb, { pixel }) {
277
+ let art = null;
278
+ let chips = '';
279
+ let subtitle = '';
280
+ const stats = [];
281
+ const rest = [];
282
+ for (const line of lines) {
283
+ const img = IMAGE_LINE.exec(line);
284
+ if (img && !art) { art = img; continue; }
285
+ if (!chips && CHIP_LINE.test(line)) { chips = chipsOf(line); continue; }
286
+ if (!subtitle && /^\s*([*_])[^*_].*\1\s*$/.test(line) && !/^\s*[-*+]\s/.test(line)) { subtitle = line.trim().slice(1, -1); continue; }
287
+ const st = STAT.exec(line);
288
+ if (st) { stats.push([st[1] ?? st[2], st[3]]); continue; }
289
+ rest.push(line);
290
+ }
291
+ const idChip = /<span class="chip id">[^<]*<\/span>/.exec(chips)?.[0] ?? '';
292
+ const tags = chips.replace(idChip, '');
293
+ const inline = (t) => renderMarkdown(t).html.replace(/^<p>|<\/p>$/g, '');
294
+ const pic = art ? emb.image(art[2]) : null;
295
+ // No picture yet: the card's initial, in the game's display font, holds its place (never a made-up picture).
296
+ const initial = [...String(title).trim()][0]?.toUpperCase() ?? '';
297
+ return `<article class="card${art ? ' has-art' : ''}">
298
+ <header>${art ? `<div class="art${pixel ? ' pixel' : ''}">${pic ? `<img src="${pic}" alt="${esc(art[1])}" loading="lazy" decoding="async">` : `<span>${esc(art[1] || '')}</span>`}</div>` : `<div class="art mono" aria-hidden="true"><span>${esc(initial)}</span></div>`}<div class="who"><div class="name"><h3>${esc(title)}</h3>${idChip}</div>${subtitle ? `<p class="meta">${inline(subtitle)}</p>` : ''}${tags ? `<p class="tags">${tags}</p>` : ''}</div></header>
299
+ ${blocks(rest, emb)}
300
+ ${stats.length ? `<dl class="stats">${stats.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${inline(v)}</dd>`).join('')}</dl>` : ''}
301
+ </article>`;
302
+ }
303
+
304
+ function sectionHtml(section, emb, ctx) {
305
+ const intro = [];
306
+ const cards = [];
307
+ let current = null;
308
+ let fence = false;
309
+ for (const line of section.body) {
310
+ if (/^(```|~~~)/.test(line)) fence = !fence;
311
+ const h3 = !fence && /^###\s+(.+?)\s*#*\s*$/.exec(line);
312
+ if (h3) { current = { title: h3[1], lines: [] }; cards.push(current); continue; }
313
+ (current ? current.lines : intro).push(line);
314
+ }
315
+ const questions = section.key === 'questions';
316
+ let body = blocks(intro, emb, { dated: section.key === 'latest' });
317
+ if (questions) body = body.replace(/^<(ul|ol)>/, '<ol class="questions">').replace(/<\/(ul|ol)>$/, '</ol>');
318
+ const empty = !body && !cards.length;
319
+ return `<section class="tab" id="${esc(section.id)}" data-key="${esc(section.key ?? '')}">
320
+ <p class="eyebrow">${esc(ctx.eyebrow)}</p>
321
+ <h2>${esc(section.title)}</h2>
322
+ ${body ? `<div class="prose">${body}</div>` : ''}${empty ? '<p class="empty">Not decided yet. Your AI asks about it in the plan, or tell it what you want here.</p>' : ''}
323
+ ${cards.length ? `<div class="cards">${cards.map((c) => card(c.title, c.lines, emb, ctx)).join('\n')}</div>` : ''}
324
+ </section>`;
325
+ }
326
+
327
+ /* ------------------------------------------------------------------ the build status tab */
328
+
329
+ const STATE_MARK = { done: '✓', pass: '✓', skipped: '–', skip: '–', running: '◌', failed: '✗', fail: '✗', stopped: '■', pending: '' };
330
+
331
+ function statusHtml(s, ctx) {
332
+ const ago = (iso) => esc(iso ? new Date(iso).toISOString().replace('T', ' ').slice(0, 16) + ' UTC' : '');
333
+ const intro = ctx.mode === 'site'
334
+ ? 'As of the site\'s last deploy. Your AI updates the codex as the work moves along.'
335
+ : ctx.mode === 'file' ? 'Updated by your AI as the work moves along. Leave this open; it refreshes by itself while a build runs.'
336
+ : 'Updated by your AI as the work moves along.';
337
+ if (!s) {
338
+ return `<section class="tab status" id="build-status" data-key="status"><p class="eyebrow">${esc(ctx.eyebrow)}</p><h2>Build status</h2><p class="sub">${esc(intro)}</p>
339
+ <div class="panel"><p class="summary">No build has run for this game yet. When one runs, its steps, its checks going green, a picture of the game and what it spent show here.</p></div></section>`;
340
+ }
341
+ const summary = s.state === 'passed' ? `Done${s.preview ? `: ${s.preview}` : ''}.`
342
+ : s.state === 'failed' ? `Stopped by a problem${s.stage ? ` in ${s.stage.label}` : ''}: ${s.error ?? s.last}`
343
+ : s.state === 'stopped' ? 'Stopped, as asked.'
344
+ : `${s.stage ? `${s.stage.label}${s.stage.note ? `: ${s.stage.note}` : (() => { const own = s.stages.find((x) => x.id === s.stage.id)?.checks ?? []; return own.length ? `: ${own.filter((c) => c.state === 'pass' || c.state === 'skip').length} of ${own.length} passed so far` : ''; })()}` : 'Starting'}${s.stopping ? ' (stopping at the next safe point)' : ''}`;
345
+ const segs = s.stages.map((st) => {
346
+ const own = st.checks;
347
+ const fill = st.state === 'done' || st.state === 'skipped' ? 100 : st.state === 'running' ? (own.length ? Math.round(100 * own.filter((c) => c.state === 'pass' || c.state === 'skip').length / own.length) : 35) : 0;
348
+ return `<span class="seg ${esc(st.state)}" title="${esc(st.label)}: ${esc(st.state)}"><i style="width:${fill}%"></i></span>`;
349
+ }).join('');
350
+ const steps = s.stages.map((st) => `<li class="${esc(st.state)}"><span class="tick">${STATE_MARK[st.state] ?? ''}</span><span><b>${esc(st.label)}</b>${st.note ? ` <em>${esc(st.note)}</em>` : ''}${st.checks.length ? `<ul>${st.checks.map((c) => `<li class="${esc(c.state)}"><span class="tick">${STATE_MARK[c.state] ?? ''}</span><span>${esc(c.label)}${c.note ? ` <em>${esc(c.note)}</em>` : ''}</span></li>`).join('')}</ul>` : ''}</span></li>`).join('');
351
+ // Only a web address is a link (a feed is written by commands, but a page never links to anything else).
352
+ if (s.preview && !/^https?:\/\//i.test(s.preview)) s = { ...s, preview: null };
353
+ const local = s.preview && /^http:\/\/(127\.0\.0\.1|localhost)/.test(s.preview);
354
+ const tryBox = s.preview ? `<div class="try"><p class="eyebrow">Ready to try</p><p>Open <a href="${esc(s.preview)}">${esc(s.preview)}</a>${local ? ' on this computer (the studio\'s dev site must be running)' : ''}. ${esc(ctx.try || 'Open it in a second tab, or on a phone, to see two players in the same room.')}</p></div>` : '';
355
+ return `<section class="tab status" id="build-status" data-key="status" data-running="${s.state === 'running' ? '1' : '0'}">
356
+ <p class="eyebrow">${esc(ctx.eyebrow)}</p><h2>Build status</h2><p class="sub">${esc(intro)}</p>
357
+ <div class="panel big"><div class="pct">${s.percent}%</div><p class="upd"><span class="dot ${esc(s.state)}"></span><span data-ago="${esc(s.updatedAt ?? '')}">Updated ${ago(s.updatedAt)}</span> · ${esc(s.title)}</p>
358
+ <div class="segbar">${segs}</div><p class="summary">${esc(summary)}</p>${s.spend.text ? `<p class="spend">Spent ${esc(s.spend.text)}</p>` : ''}</div>
359
+ ${tryBox}
360
+ <div class="panel"><h3>Steps</h3><ul class="steps">${steps}</ul></div>
361
+ ${s.previewImage && /^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(s.previewImage) ? `<figure class="shot"><img src="${s.previewImage}" alt="${esc(s.previewCaption || 'The game, as the last check saw it')}">${s.previewCaption ? `<figcaption>${esc(s.previewCaption)}</figcaption>` : ''}</figure>` : ''}
362
+ </section>`;
363
+ }
364
+
365
+ /* ------------------------------------------------------------------ the page */
366
+
367
+ /** The one script the page has (worker/codex-script.mjs); the site's CSP allows it by its hash. */
368
+ export { CODEX_SCRIPT };
369
+ export const CODEX_SCRIPT_HASH = `sha256-${createHash('sha256').update(CODEX_SCRIPT).digest('base64')}`;
370
+
371
+ function css(p, f, pixel) {
372
+ return `:root{color-scheme:${p.scheme};--bg:${p.bg};--ink:${p.ink};--accent:${p.accent};--accent2:${p.accent2};--danger:${p.danger};--good:${p.good};--gold:${p.gold};
373
+ --panel:${p.panel ?? 'color-mix(in srgb, var(--bg) 90%, var(--ink))'};--line:color-mix(in srgb, var(--ink) 14%, transparent);--dim:color-mix(in srgb, var(--ink) 62%, var(--bg));
374
+ --display:${f.display};--body:${f.body};--mono:${f.mono}}
375
+ *{box-sizing:border-box}html{-webkit-text-size-adjust:100%}
376
+ body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.6 var(--body)}
377
+ a{color:var(--accent2)}img{max-width:100%}
378
+ .hero{position:relative;padding:56px 0 28px;border-bottom:1px solid var(--line);overflow:hidden}
379
+ .hero .bgimg{position:absolute;inset:0;background-size:cover;background-position:center;opacity:.32;${pixel ? 'image-rendering:pixelated;' : ''}}
380
+ .hero .bgimg:after{content:"";position:absolute;inset:0;background:linear-gradient(180deg,color-mix(in srgb,var(--bg) 30%,transparent),var(--bg))}
381
+ .wrap{position:relative;max-width:1080px;margin:0 auto;padding:0 16px}
382
+ .kicker,.eyebrow{margin:0 0 6px;color:var(--accent2);font:600 12px/1.4 var(--mono);letter-spacing:.14em;text-transform:uppercase}
383
+ h1{margin:0;font:400 clamp(28px,6vw,52px)/1.15 var(--display);color:var(--accent);letter-spacing:.01em;overflow-wrap:anywhere}
384
+ .tagline{margin:10px 0 0;font-size:18px;color:var(--ink)}.pitch{max-width:72ch;color:var(--dim)}
385
+ nav.tabs{position:sticky;top:0;z-index:5;background:color-mix(in srgb,var(--bg) 92%,transparent);backdrop-filter:blur(8px);border-bottom:1px solid var(--line)}
386
+ nav.tabs .wrap{display:flex;gap:2px;overflow-x:auto;padding:0 6px;scrollbar-width:none}nav.tabs .wrap::-webkit-scrollbar{display:none}
387
+ nav.tabs a{flex:none;padding:12px 10px;color:var(--dim);text-decoration:none;font:600 14px/1 var(--body);border-bottom:2px solid transparent;white-space:nowrap}
388
+ nav.tabs a.on,nav.tabs a:hover{color:var(--ink);border-bottom-color:var(--accent)}
389
+ nav.tabs a.status{color:var(--accent2)}
390
+ main{max-width:1080px;margin:0 auto;padding:28px 16px 64px}
391
+ .tab{padding:8px 0 36px;scroll-margin-top:56px}.js .tab{display:none}.js .tab.on{display:block}
392
+ h2{margin:0 0 14px;font:400 clamp(24px,4.5vw,36px)/1.2 var(--display);color:var(--accent)}
393
+ h3{margin:0;font:400 18px/1.3 var(--display);color:var(--ink)}
394
+ .prose>p,.prose>ul,.prose>ol,.prose>blockquote,.prose>h2,.prose>h3,.prose>h4{max-width:76ch}.prose h2,.prose h3,.prose h4{font-family:var(--body);color:var(--ink)}
395
+ .cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,420px),1fr));gap:14px;margin-top:18px}
396
+ .card,.panel{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:16px}
397
+ .card header{display:flex;gap:14px;align-items:flex-start;margin-bottom:8px}
398
+ .card .art{flex:none;width:96px;height:96px;border-radius:10px;background:color-mix(in srgb,var(--bg) 70%,var(--panel));border:1px solid var(--line);display:grid;place-items:center;overflow:hidden}
399
+ .card .art img{width:100%;height:100%;object-fit:contain}.card .art.mono{background:radial-gradient(circle at 30% 25%,color-mix(in srgb,var(--accent) 22%,transparent),transparent 70%),color-mix(in srgb,var(--bg) 70%,var(--panel))}.card .art.mono span{font:400 40px/1 var(--display);color:var(--accent)}.pixel img{image-rendering:pixelated}body.pixel figure img{background:color-mix(in srgb,var(--bg) 70%,var(--panel))}
400
+ .card .who{min-width:0}.card .name{display:flex;flex-wrap:wrap;align-items:center;gap:6px 10px}.card .meta{margin:4px 0 0;color:var(--dim);font-size:14px}.card .tags{margin:8px 0 0;display:flex;flex-wrap:wrap;gap:6px}
401
+ .card p{margin:8px 0}
402
+ .chip{display:inline-block;padding:2px 8px;border-radius:999px;font:600 12px/1.6 var(--mono);border:1px solid var(--line);color:var(--dim)}
403
+ .chip.id{color:var(--ink);background:color-mix(in srgb,var(--ink) 8%,transparent)}
404
+ .chip.good{color:var(--good);border-color:color-mix(in srgb,var(--good) 45%,transparent);background:color-mix(in srgb,var(--good) 12%,transparent)}
405
+ .chip.danger{color:var(--danger);border-color:color-mix(in srgb,var(--danger) 45%,transparent);background:color-mix(in srgb,var(--danger) 12%,transparent)}
406
+ .chip.gold{color:var(--gold);border-color:color-mix(in srgb,var(--gold) 45%,transparent);background:color-mix(in srgb,var(--gold) 12%,transparent)}
407
+ .chip.info{color:var(--accent2);border-color:color-mix(in srgb,var(--accent2) 45%,transparent)}
408
+ dl.stats{display:grid;grid-template-columns:auto 1fr;gap:4px 12px;margin:10px 0 0;font:13px/1.5 var(--mono)}dl.stats dt{color:var(--dim)}dl.stats dd{margin:0}
409
+ .scroll{overflow-x:auto;margin:14px 0;background:var(--panel);border:1px solid var(--line);border-radius:14px}table{border-collapse:collapse;min-width:100%;font-size:15px}tr:last-child td{border-bottom:0}
410
+ th,td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:top}th{font:600 12px/1.4 var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--accent2)}
411
+ ul.checklist,ul.steps,ul.steps ul,ol.timeline{list-style:none;padding:0;margin:12px 0}
412
+ ul.checklist li,ul.steps li{display:flex;gap:10px;align-items:baseline;padding:6px 0}
413
+ .tick{flex:none;display:inline-grid;place-items:center;width:20px;height:20px;border-radius:6px;border:1px solid var(--line);font:700 13px/1 var(--mono);color:var(--good)}
414
+ li.done>.tick,li.pass>.tick{background:color-mix(in srgb,var(--good) 18%,transparent);border-color:color-mix(in srgb,var(--good) 50%,transparent)}
415
+ li.failed>.tick,li.fail>.tick{color:var(--danger);border-color:var(--danger)}li.running>.tick{color:var(--accent2)}
416
+ ul.steps ul{margin:4px 0 0}ul.steps em{color:var(--dim);font-style:normal}
417
+ ol.timeline li{display:grid;grid-template-columns:110px 1fr;gap:12px;padding:10px 0;border-bottom:1px solid var(--line)}ol.timeline time{font:600 13px/1.6 var(--mono);color:var(--accent2)}
418
+ ol.questions{counter-reset:q;list-style:none;padding:0;display:grid;gap:10px}ol.questions li{counter-increment:q;background:var(--panel);border:1px solid var(--line);border-left:3px solid var(--gold);border-radius:12px;padding:12px 14px 12px 48px;position:relative}
419
+ ol.questions li:before{content:"?" counter(q);position:absolute;left:14px;top:12px;font:700 13px/1.6 var(--mono);color:var(--gold)}
420
+ .gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr));gap:12px;margin:14px 0}
421
+ figure{margin:14px 0}figure img{display:block;width:100%;border-radius:12px;border:1px solid var(--line)}figcaption{margin-top:6px;color:var(--dim);font-size:14px}
422
+ figure.missing{display:grid;place-items:center;min-height:120px;border:1px dashed var(--line);border-radius:12px;color:var(--dim);font-size:14px}
423
+ blockquote{margin:14px 0;padding:10px 16px;border-left:3px solid var(--accent2);background:var(--panel);border-radius:0 12px 12px 0}
424
+ code{font-family:var(--mono);font-size:.92em}pre{overflow-x:auto;background:var(--panel);padding:12px;border-radius:12px}
425
+ .sub{color:var(--dim);margin:-6px 0 18px}.empty{color:var(--dim);font-style:italic}
426
+ .panel.big .pct{font:400 clamp(44px,10vw,72px)/1 var(--display);color:var(--accent)}
427
+ .upd{display:flex;align-items:center;gap:8px;color:var(--dim);font-size:14px;margin:10px 0}
428
+ .dot{width:9px;height:9px;border-radius:50%;background:var(--accent2)}.dot.running{animation:pulse 1.4s infinite}.dot.failed{background:var(--danger)}.dot.passed{background:var(--good)}.dot.stopped{background:var(--gold)}
429
+ @keyframes pulse{50%{opacity:.35}}@media (prefers-reduced-motion:reduce){.dot.running{animation:none}}
430
+ .segbar{display:flex;gap:6px;margin:14px 0}.seg{flex:1;height:12px;border-radius:4px;background:color-mix(in srgb,var(--ink) 10%,transparent);overflow:hidden}.seg i{display:block;height:100%;background:var(--good)}.seg.failed i{background:var(--danger);width:100%!important}.seg.skipped i{background:color-mix(in srgb,var(--good) 35%,transparent)}li.skipped>span>b{color:var(--dim)}
431
+ .summary{margin:6px 0 0}.spend{margin:6px 0 0;color:var(--dim);font:13px/1.5 var(--mono)}
432
+ .try{margin:16px 0;padding:14px 16px;border:1px solid var(--gold);border-radius:14px;background:color-mix(in srgb,var(--gold) 8%,transparent)}.try .eyebrow{color:var(--gold)}.try p{margin:0}
433
+ .panel h3{margin-bottom:6px}.status .panel{margin-top:14px}
434
+ footer{max-width:1080px;margin:0 auto;padding:18px 16px 40px;color:var(--dim);font-size:13px;border-top:1px solid var(--line)}
435
+ @media (max-width:560px){.card .art{width:72px;height:72px}ol.timeline li{grid-template-columns:1fr;gap:2px}}`;
436
+ }
437
+
438
+ /** The game's look: CODEX.md's palette and fonts, else the game's landing theme, else the studio's site/theme.json. */
439
+ function lookOf(root, game, meta, emb) {
440
+ const theme = readTheme(root);
441
+ const land = game.landing?.theme && typeof game.landing.theme === 'object' ? game.landing.theme : {};
442
+ const mine = meta.palette && typeof meta.palette === 'object' ? meta.palette : {};
443
+ const pick = (...vals) => vals.find((v) => typeof v === 'string' && COLOR.test(v.trim()))?.trim();
444
+ const bg = pick(mine.bg, theme.bg, '#0b0c12');
445
+ const lum = (() => { const m = /^#([0-9a-f]{6}|[0-9a-f]{3})$/i.exec(bg); if (!m) return 0; const h = m[1].length === 3 ? m[1].replace(/./g, '$&$&') : m[1]; const [r, g, b] = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255); return 0.2126 * r + 0.7152 * g + 0.0722 * b; })();
446
+ const scheme = meta.scheme === 'light' || (meta.scheme !== 'dark' && lum > 0.55) ? 'light' : 'dark';
447
+ const palette = {
448
+ scheme, bg,
449
+ ink: pick(mine.ink, mine.fg, theme.fg, scheme === 'light' ? '#16161d' : '#f1f3f9'),
450
+ accent: pick(mine.accent, land.accent, theme.accent, '#ffcf5a'),
451
+ accent2: pick(mine.accent2, mine.glow, land.glow, theme.glow, '#7dffb0'),
452
+ danger: pick(mine.danger, '#ff5d5d'), good: pick(mine.good, '#5fdc8b'), gold: pick(mine.gold, mine.accent, '#f2c14e'),
453
+ panel: pick(mine.panel),
454
+ };
455
+ const fonts = meta.fonts && typeof meta.fonts === 'object' ? meta.fonts : {};
456
+ const links = [];
457
+ const faces = [];
458
+ const stack = { display: 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif', body: 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif', mono: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace' };
459
+ const fam = {};
460
+ for (const role of ['display', 'body', 'mono']) {
461
+ const v = typeof fonts[role] === 'string' ? fonts[role].trim() : '';
462
+ if (v && FONT_TYPES[extname(v).toLowerCase()]) {
463
+ const data = emb.font(v);
464
+ if (data) { const name = `codex-${role}`; faces.push(`@font-face{font-family:"${name}";src:url(${data});font-display:swap}`); fam[role] = `"${name}", ${stack[role]}`; }
465
+ } else if (v && FAMILY.test(v)) {
466
+ links.push(`https://fonts.googleapis.com/css2?family=${encodeURIComponent(v).replace(/%20/g, '+')}&display=swap`);
467
+ fam[role] = `"${v}", ${stack[role]}`;
468
+ }
469
+ }
470
+ if (!fam.display && theme.display) fam.display = theme.display;
471
+ return { palette, fonts: { display: fam.display ?? stack.display, body: fam.body ?? stack.body, mono: fam.mono ?? stack.mono }, links: [...new Set(links)], faces };
472
+ }
473
+
474
+ /**
475
+ * The page for one game. `mode`: 'file' (this computer; reloads while a build runs), 'artifact' (one file to publish
476
+ * as a Claude artifact), 'site' (the studio site's private page). `feed`: a progress feed document, else the game's
477
+ * newest one.
478
+ */
479
+ export function renderCodex(root, id, { mode = 'file', feed, now = new Date() } = {}) {
480
+ if (!GAME_ID.test(String(id ?? ''))) throw new Error(`no game id ${JSON.stringify(id)}`);
481
+ const game = listGames(root).find((g) => g.id === id);
482
+ if (!game) throw new Error(`no game "${id}" in games/`);
483
+ const file = join(game.dir, CODEX_FILE);
484
+ if (!existsSync(file)) throw new Error(`games/${id}/${CODEX_FILE} does not exist yet: npx --no-install homie-studio codex new ${id}`);
485
+ if (statSync(file).size > LIMITS.source) throw new Error(`games/${id}/${CODEX_FILE} is over ${LIMITS.source / 1024} KB`);
486
+ const parsed = parseCodex(readFileSync(file, 'utf8'));
487
+ const { meta } = parsed;
488
+ let studio = {};
489
+ try { studio = readStudio(root); } catch { studio = {}; }
490
+ const emb = new Embedder(root, game.dir);
491
+ const look = lookOf(root, game, meta, emb);
492
+ const title = parsed.title ?? game.name ?? id;
493
+ const eyebrow = typeof meta.eyebrow === 'string' && meta.eyebrow.trim() ? meta.eyebrow.trim() : `${studio.name ?? 'Studio'} · ${title}`;
494
+ const ctx = { eyebrow: String(eyebrow).slice(0, 80), pixel: meta.pixel === true, try: typeof meta.try === 'string' ? meta.try.slice(0, 300) : '', mode };
495
+ const s = summarize(feed === undefined ? latestFeedFor(root, id) : feed);
496
+ const sections = parsed.sections.filter((x) => x.body.join('\n').trim() || x.key);
497
+ const tabs = sections.map((x) => `<a href="#${esc(x.id)}">${esc(x.title)}</a>`);
498
+ // The build's tab comes right after Latest (or first), so it is never the one a narrow screen hides.
499
+ const statusTab = `<a class="status" href="#build-status">${s?.state === 'running' ? `Build · ${s.percent}%` : 'Build status'}</a>`;
500
+ tabs.splice(sections[0]?.key === 'latest' ? 1 : 0, 0, statusTab);
501
+ const cover = typeof meta.cover === 'string' ? emb.image(meta.cover) : null;
502
+ const pitch = parsed.pitch ? renderMarkdown(parsed.pitch).html : (game.blurb ? `<p>${esc(game.blurb)}</p>` : '');
503
+ const body = sections.map((x) => sectionHtml(x, emb, ctx)).join('\n');
504
+ const status = statusHtml(s, ctx);
505
+ const start = s?.state === 'running' ? 'build-status' : (sections[0]?.id ?? 'build-status');
506
+ const tagline = typeof meta.tagline === 'string' ? meta.tagline : '';
507
+ const gaps = codexGaps(parsed);
508
+ const html = `<!doctype html>
509
+ <html lang="en">
510
+ <head>
511
+ <meta charset="utf-8">
512
+ <meta name="viewport" content="width=device-width, initial-scale=1">
513
+ <meta name="robots" content="noindex, nofollow">
514
+ <title>${esc(title)} · Game Codex</title>
515
+ ${look.links.map((href) => `<link rel="stylesheet" href="${esc(href)}">`).join('\n')}
516
+ <style>${look.faces.join('')}${css(look.palette, look.fonts, ctx.pixel)}</style>
517
+ </head>
518
+ <body data-mode="${esc(mode)}" data-start="${esc(start)}"${ctx.pixel ? ' class="pixel"' : ''}>
519
+ <header class="hero">${cover ? `<div class="bgimg" style="background-image:url(${cover})"></div>` : ''}<div class="wrap">
520
+ <p class="kicker">Game Codex · ${esc(studio.name ?? '')}</p>
521
+ <h1>${esc(title)}</h1>
522
+ ${tagline ? `<p class="tagline">${esc(tagline)}</p>` : ''}
523
+ ${pitch ? `<div class="pitch">${pitch}</div>` : ''}
524
+ </div></header>
525
+ <nav class="tabs" aria-label="Codex sections"><div class="wrap">${tabs.join('')}</div></nav>
526
+ <main>
527
+ ${body}
528
+ ${status}
529
+ </main>
530
+ <footer>Game Codex for ${esc(title)}: the plan your AI keeps true as decisions change. Source: games/${esc(id)}/${CODEX_FILE}. Drawn ${esc(now.toISOString().slice(0, 16).replace('T', ' '))} UTC. Made with Homie.</footer>
531
+ <script>${CODEX_SCRIPT}</script>
532
+ </body>
533
+ </html>
534
+ `;
535
+ return { html, title, sections: sections.map((x) => ({ id: x.id, title: x.title, key: x.key })), missing: gaps.missing, openQuestions: gaps.openQuestions, warnings: emb.warnings, build: s ? { state: s.state, percent: s.percent } : null };
536
+ }
537
+
538
+ /** `homie-studio codex <id>`: the page on this computer (or the artifact copy). */
539
+ export function writeCodexPage(root, id, { mode = 'file' } = {}) {
540
+ const r = renderCodex(root, id, { mode });
541
+ const dir = join(root, CODEX_DIR);
542
+ mkdirSync(dir, { recursive: true });
543
+ const file = join(dir, mode === 'artifact' ? `${id}.artifact.html` : `${id}.html`);
544
+ writeFileSync(file, r.html);
545
+ return { ok: true, command: 'codex', id, mode, file: relative(root, file), path: file, bytes: Buffer.byteLength(r.html), title: r.title, sections: r.sections, missing: r.missing, openQuestions: r.openQuestions, warnings: r.warnings, build: r.build };
546
+ }
547
+
548
+ /** Called whenever a progress feed changes: the local page redraws itself (only when it exists already). */
549
+ export function refreshCodexFile(root, id) {
550
+ if (!id || !GAME_ID.test(String(id))) return;
551
+ try {
552
+ const page = join(root, CODEX_DIR, `${id}.html`);
553
+ if (!existsSync(page)) return;
554
+ writeFileSync(page, renderCodex(root, id, { mode: 'file' }).html);
555
+ } catch { /* the page stays as it was */ }
556
+ }
557
+
558
+ /** Every game's codex, into the built site (lib/build.mjs): the owner-only pages at /_studio/codex/<id>/. */
559
+ export function buildCodexPages(root, dist, ids, { log = () => {} } = {}) {
560
+ const done = [];
561
+ for (const id of ids) {
562
+ const game = listGames(root).find((g) => g.id === id);
563
+ if (!game || !existsSync(join(game.dir, CODEX_FILE))) continue;
564
+ try {
565
+ const r = renderCodex(root, id, { mode: 'site' });
566
+ const dir = join(dist, '_studio', 'codex', id);
567
+ mkdirSync(dir, { recursive: true });
568
+ writeFileSync(join(dir, 'index.html'), r.html);
569
+ for (const w of r.warnings) log(`codex ${id}: ${w}`);
570
+ done.push(id);
571
+ } catch (error) { log(`codex ${id}: not drawn (${error.message})`); }
572
+ }
573
+ return done;
574
+ }
575
+
576
+ /* ------------------------------------------------------------------ a new codex */
577
+
578
+ /** `homie-studio codex new <id>`: CODEX.md with every section, its look taken from the game and the studio. */
579
+ export function newCodex(root, id) {
580
+ const game = listGames(root).find((g) => g.id === id);
581
+ if (!game) return { ok: false, command: 'codex new', why: `no game "${id}" in games/ (make it first: npx --no-install homie-studio game new ${id} --from gem-rush)` };
582
+ const file = join(game.dir, CODEX_FILE);
583
+ if (existsSync(file)) return { ok: false, command: 'codex new', why: `games/${id}/${CODEX_FILE} exists already: change it, never replace it` };
584
+ const theme = readTheme(root);
585
+ const land = game.landing?.theme ?? {};
586
+ const q = (v) => JSON.stringify(String(v));
587
+ const cover = ['hero/wide.jpg', 'cover.jpg', 'cover.png', 'public/cover.jpg', 'public/cover.png'].find((p) => existsSync(join(game.dir, p)));
588
+ const today = new Date().toISOString().slice(0, 10);
589
+ const players = game.players ? `${game.players.min ?? 1} to ${game.players.max ?? 8}` : '1 to 8';
590
+ const text = `---
591
+ tagline: ${q(game.blurb ?? '')}
592
+ ${cover ? `cover: ${cover}\n` : ''}palette:
593
+ bg: ${q(theme.bg ?? '#0b0c12')}
594
+ ink: ${q(theme.fg ?? '#f1f3f9')}
595
+ accent: ${q(land.accent ?? theme.accent ?? '#ffcf5a')}
596
+ accent2: ${q(land.glow ?? theme.glow ?? '#7dffb0')}
597
+ danger: "#ff5d5d"
598
+ fonts:
599
+ display: ""
600
+ body: ""
601
+ pixel: false
602
+ try: "Open it in a second tab, or on a phone, to see two players in the same room."
603
+ ---
604
+
605
+ # ${game.name ?? id}
606
+
607
+ ${game.blurb ?? ''}
608
+
609
+ ## Latest
610
+
611
+ - ${today}: The codex starts. The game is a copy of the ${game.from?.starter ?? 'starter'} starter for now.
612
+
613
+ ## Concept
614
+
615
+ <!-- Genre and type, the one-line pitch, and what a player does in the first ten seconds. Why is it fun with strangers? -->
616
+
617
+ ## World
618
+
619
+ <!-- Where it happens: places, mood, a story in a few lines. Places as ### cards when there are several. -->
620
+
621
+ ## Characters
622
+
623
+ <!-- One ### card each: a picture line, a chip line (\`C-01\` \`good: Player\`), a one-line description, then **Key:** value stats. -->
624
+
625
+ ## Art direction
626
+
627
+ <!-- The style in words (pixel, painterly, low-poly, neon…), the palette, the fonts, two or three reference pictures from the game's folder. -->
628
+
629
+ ## Controls
630
+
631
+ | Action | Phone | Computer | TV and phones |
632
+ | --- | --- | --- | --- |
633
+ | Move | drag from the lower left | WASD or arrow keys | each phone is a pad |
634
+
635
+ ## Rooms and players
636
+
637
+ - **Players in a room:** ${players}
638
+ - **Round:** ${game.roundSeconds ? `${game.roundSeconds} s` : 'how long, and how it ends'}
639
+ - **Bots:** fill empty seats; a person who arrives takes a bot's place
640
+
641
+ ## Music and sound
642
+
643
+ <!-- The theme's mood and tempo, the sounds that matter (pick-up, hit, win), and whether songs come from the free synth or ElevenLabs. -->
644
+
645
+ ## Milestones
646
+
647
+ - [x] Step 1: a working copy of the starter plays in two browsers
648
+ - [ ] Step 2: one small change from one sentence
649
+ - [ ] Step 3: the plan (this codex)
650
+ - [ ] Step 4: the first playable version of the plan, checked with two browsers
651
+ - [ ] Step 5: art, sound and its landing page; online
652
+
653
+ ## Open questions
654
+
655
+ -
656
+ `;
657
+ writeFileSync(file, text);
658
+ return { ok: true, command: 'codex new', id, file: relative(root, file), next: [`fill it from the plan interview, then: npx --no-install homie-studio codex ${id}`] };
659
+ }
660
+
661
+ /** A sign-in link that opens one game's codex on the live site (the stats page's one-time sign-in, then the codex). */
662
+ export function codexTarget(id) { return `/_studio/codex/${id}/`; }
663
+
664
+ export const _test = { studioFile, chip };