@homie-rocks/studio 0.26.1 → 0.28.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/site.mjs CHANGED
@@ -378,6 +378,20 @@ export function landingOf(g, out, { videos = [], songs = [], log = () => {} } =
378
378
  }
379
379
  if (!hero.wideImage) hero.wideImage = (trailer && hero.fromTrailer ? fileUrl(trailer, 'poster') : null) ?? cover;
380
380
  for (const k of Object.keys(hero)) if (!hero[k]) delete hero[k];
381
+ // Screenshots (0.27.0): game.json landing.screenshots (paths in the game's folder), else the pictures in
382
+ // games/<id>/screenshots/, by name; at most eight. The landing shows them and its VideoGame names them.
383
+ let screenshots = [];
384
+ if (Array.isArray(L.screenshots)) {
385
+ screenshots = L.screenshots.slice(0, 8).map((rel) => {
386
+ const abs = typeof rel === 'string' && IMAGE.test(rel) ? inside(g.dir, rel) : null;
387
+ const url = abs ? serve(abs, `screenshot ${rel}`) : null;
388
+ if (!url) log(`warning: games/${g.id}/game.json landing.screenshots "${String(rel).slice(0, 60)}" is not a picture in the game's folder`);
389
+ return url;
390
+ }).filter(Boolean);
391
+ } else if (existsSync(join(g.dir, 'screenshots')) && statSync(join(g.dir, 'screenshots')).isDirectory()) {
392
+ screenshots = readdirSync(join(g.dir, 'screenshots')).filter((n) => IMAGE.test(n) && !n.startsWith('.')).sort().slice(0, 8)
393
+ .map((n) => serve(join(g.dir, 'screenshots', n), `screenshots/${n}`)).filter(Boolean);
394
+ }
381
395
  const focus = /^\d{1,3}% \d{1,3}%$/.test(String(H.focus ?? '')) ? String(H.focus) : null;
382
396
  const tint = Number.isFinite(Number(H.tint)) && H.tint !== null && H.tint !== '' ? Math.max(0, Math.min(80, Math.round(Number(H.tint)))) : null;
383
397
  const alt = str(H.alt, 300);
@@ -446,6 +460,7 @@ export function landingOf(g, out, { videos = [], songs = [], log = () => {} } =
446
460
  hero: { ...hero, ...(focus ? { focus } : {}), ...(alt ? { alt } : {}), ...(tint !== null ? { tint } : {}) },
447
461
  headline: str(L.headline, 120),
448
462
  cover,
463
+ ...(screenshots.length ? { screenshots } : {}),
449
464
  words: words && (words.one || words.many) ? words : null,
450
465
  credits: { people, original, parts, texts: texts.length },
451
466
  trailer: trailer ? trailer.slug : null,
@@ -1312,6 +1312,85 @@
1312
1312
  }
1313
1313
  }
1314
1314
  }
1315
+ },
1316
+ "0.27.0": {
1317
+ "files": {
1318
+ "CLAUDE.md": "ddf2b2a0376202f6",
1319
+ "README.md": "684b3cca8001673c",
1320
+ "HANDOFF.md": "ce7a8fd91c6bbee2",
1321
+ "games/README.md": "5ad32fe80c61f117",
1322
+ "music/README.md": "a3dc042dadd39df8",
1323
+ "videos/README.md": "16babc3437d5545b",
1324
+ "posts/README.md": "37841aae476bd540",
1325
+ "site/README.md": "2928ca4f079ab76b",
1326
+ "site/src/worker.mjs": "ca3f1630f2420ad3"
1327
+ },
1328
+ "sections": {
1329
+ "AGENTS.md": {
1330
+ "(intro)": "214aa46eb3a895d5",
1331
+ "## Layout": "8baf49e4291e7e88",
1332
+ "## Commands (all through the pinned CLI in node_modules)": "d80ec56e4b4fc4c6",
1333
+ "## The site": "366ecce1a424837a",
1334
+ "## Making games": "abf2f0d59341512d",
1335
+ "## The Game Codex and progress": "0738ef719cef3a4c",
1336
+ "## Art direction and models": "aaea65236db52126",
1337
+ "## Running live games (the back office)": "4aa5f513a7cd2c3b",
1338
+ "## Servers and AI seats": "98d08705087a33ec",
1339
+ "## Selling things (the shop)": "b04b0a2b25f524f2",
1340
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
1341
+ "## Rules": "7e8458adc1b81ece",
1342
+ "## Beta": "9d02638207a26e73"
1343
+ }
1344
+ },
1345
+ "named": {
1346
+ "Homie Arcade": {
1347
+ "sections": {
1348
+ "AGENTS.md": {
1349
+ "## Commands (all through the pinned CLI in node_modules)": "b35df2c19cd4b534"
1350
+ }
1351
+ }
1352
+ }
1353
+ }
1354
+ },
1355
+ "0.28.0": {
1356
+ "files": {
1357
+ "CLAUDE.md": "ddf2b2a0376202f6",
1358
+ "README.md": "684b3cca8001673c",
1359
+ "HANDOFF.md": "ce7a8fd91c6bbee2",
1360
+ "games/README.md": "5ad32fe80c61f117",
1361
+ "music/README.md": "a3dc042dadd39df8",
1362
+ "videos/README.md": "16babc3437d5545b",
1363
+ "posts/README.md": "37841aae476bd540",
1364
+ "site/README.md": "2928ca4f079ab76b",
1365
+ "site/src/worker.mjs": "ca3f1630f2420ad3"
1366
+ },
1367
+ "sections": {
1368
+ "AGENTS.md": {
1369
+ "(intro)": "214aa46eb3a895d5",
1370
+ "## Layout": "8baf49e4291e7e88",
1371
+ "## Commands (all through the pinned CLI in node_modules)": "d80ec56e4b4fc4c6",
1372
+ "## The site": "366ecce1a424837a",
1373
+ "## Making games": "abf2f0d59341512d",
1374
+ "## The Game Codex and progress": "0738ef719cef3a4c",
1375
+ "## Art direction and models": "aaea65236db52126",
1376
+ "## Running live games (the back office)": "4aa5f513a7cd2c3b",
1377
+ "## Servers and AI seats": "98d08705087a33ec",
1378
+ "## Selling things (the shop)": "b04b0a2b25f524f2",
1379
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
1380
+ "## Rules": "7e8458adc1b81ece",
1381
+ "## Telling Homie": "80b46fdd9e578db8",
1382
+ "## Beta": "9d02638207a26e73"
1383
+ }
1384
+ },
1385
+ "named": {
1386
+ "Homie Arcade": {
1387
+ "sections": {
1388
+ "AGENTS.md": {
1389
+ "## Commands (all through the pinned CLI in node_modules)": "b35df2c19cd4b534"
1390
+ }
1391
+ }
1392
+ }
1393
+ }
1315
1394
  }
1316
1395
  }
1317
1396
  }
package/mcp/ui/card.css CHANGED
@@ -92,3 +92,11 @@ details summary{cursor:pointer;color:var(--c-fg2);font-size:13px;margin-top:10px
92
92
  .clips{display:grid;grid-template-columns:repeat(auto-fill,minmax(104px,1fr));gap:8px}
93
93
  .clip{display:grid;gap:3px;align-content:start;padding:6px;border-radius:var(--r-sm);background:var(--c-bg)}.clip .art{aspect-ratio:1;border-radius:var(--r-sm);background:var(--c-bg3);display:grid;place-items:center;overflow:hidden;color:var(--c-fg3);font-size:20px}.clip .art img{width:100%;height:100%;object-fit:cover}
94
94
  .clip b{font-size:13px}.clip span{font-size:11.5px;color:var(--c-fg2);overflow-wrap:anywhere}.clip.missing{opacity:.8;box-shadow:inset 0 0 0 1px var(--c-line2)}.clip button{justify-self:start}
95
+ /* Tell Homie (feedback.js): the note as it would go, and the editor that rewords it. */
96
+ .quote{margin:8px 0 0;padding:12px 14px;border-radius:var(--r-md);background:var(--c-bg2);border-left:3px solid var(--accent);font:15px/1.5 var(--f-sans);color:var(--c-fg);white-space:normal;overflow-wrap:anywhere}
97
+ .kinds{display:flex;flex-wrap:wrap;gap:6px;margin:4px 0 10px}
98
+ textarea{display:block;width:100%;min-height:110px;resize:vertical;font:15px/1.45 var(--f-sans);padding:10px 12px;border-radius:var(--r-md);border:1px solid var(--c-line2);background:var(--c-bg);color:var(--c-fg)}
99
+ input[type=email]{display:block;width:100%;margin:8px 0 12px;font:14px/1.2 var(--f-sans);padding:9px 10px;border-radius:var(--r-sm);border:1px solid var(--c-line2);background:var(--c-bg);color:var(--c-fg)}
100
+ textarea:focus-visible,input[type=email]:focus-visible{outline:2px solid var(--c-fg);outline-offset:1px}
101
+ .actions.send{margin-top:14px}
102
+ @media (max-width:420px){.actions.send button{flex:1 1 auto}}
@@ -0,0 +1,112 @@
1
+ /* The Tell Homie card: a note to the people who make Homie, exactly as it would go, with Send, Edit and Don't send.
2
+ * Nothing leaves this computer until the person presses Send here (or says yes in the chat). Edit drafts the note
3
+ * again from the person's own words, through the same redaction, so what the card shows is always what would go.
4
+ * The model is told what the person chose, once. Every text is set as text, never as HTML. */
5
+ (function () {
6
+ var C = Card;
7
+ var KINDS = [['stuck', 'Stuck'], ['confusing', 'Confusing'], ['idea', 'Idea'], ['praise', 'Praise'], ['bug', 'Bug']];
8
+ var LABEL = { stuck: 'Stuck', confusing: 'Confusing', idea: 'Idea', praise: 'Praise', bug: 'Bug' };
9
+ var ui = { editing: false, busy: false, why: null, kind: null, text: null, email: null };
10
+
11
+ function fieldsOf(sc) {
12
+ var n = sc.note || {};
13
+ return { kind: n.kind, text: n.text, step: n.step || undefined, email: n.email || undefined, offered: n.offered === true,
14
+ studioVersion: n.studioVersion || undefined, pluginVersion: n.pluginVersion || undefined, app: n.app || undefined,
15
+ // The studio the draft was made for (its pinned version is in the note): a send is checked against the same one.
16
+ studio: sc.studio || undefined };
17
+ }
18
+
19
+ function act(sc, args, after) {
20
+ if (ui.busy) return;
21
+ ui.busy = true; ui.why = null; C.render();
22
+ C.call('homie_feedback', args).then(function (res) {
23
+ ui.busy = false;
24
+ // Refused (not sent, a reworded note that cannot go, the app said no): the note stays on screen with why.
25
+ var d = res && res.structuredContent;
26
+ if (!d || d.kind !== 'feedback' || res.isError) { ui.why = C.textOf(res) || 'That did not work. Nothing was sent.'; C.render(); return; }
27
+ if (after) after(res);
28
+ C.take(res);
29
+ }).catch(function () { ui.busy = false; ui.why = 'The app did not answer. Nothing was sent.'; C.render(); });
30
+ }
31
+
32
+ function quote(text) {
33
+ var q = C.el('blockquote', 'quote');
34
+ String(text || '').split('\n').forEach(function (line, i) { if (i) q.appendChild(C.el('br')); q.appendChild(document.createTextNode(line)); });
35
+ return q;
36
+ }
37
+
38
+ function editor(card, sc) {
39
+ var n = sc.note || {};
40
+ if (ui.kind === null) { ui.kind = n.kind || 'idea'; ui.text = n.text || ''; ui.email = n.email || ''; }
41
+ var kinds = C.el('div', 'kinds'); kinds.setAttribute('role', 'radiogroup'); kinds.setAttribute('aria-label', 'What kind of note');
42
+ KINDS.forEach(function (k) {
43
+ var b = C.btn(k[1], 'small ' + (ui.kind === k[0] ? '' : 'ghost'), function () { ui.kind = k[0]; C.render(); });
44
+ b.setAttribute('role', 'radio'); b.setAttribute('aria-checked', ui.kind === k[0] ? 'true' : 'false');
45
+ kinds.appendChild(b);
46
+ });
47
+ card.appendChild(kinds);
48
+ var area = C.el('textarea'); area.value = ui.text; area.rows = 5; area.maxLength = 1500; area.setAttribute('aria-label', 'Your note, in your words');
49
+ area.oninput = function () { ui.text = area.value; };
50
+ card.appendChild(area);
51
+ var mail = C.el('input'); mail.type = 'email'; mail.placeholder = 'Your email, if you want a reply (optional)'; mail.value = ui.email; mail.setAttribute('aria-label', 'Reply address (optional)');
52
+ mail.oninput = function () { ui.email = mail.value; };
53
+ card.appendChild(mail);
54
+ var acts = C.el('div', 'actions');
55
+ acts.appendChild(C.btn(ui.busy ? 'Saving…' : 'Use this', '', function () {
56
+ var f = fieldsOf(sc);
57
+ act(sc, { action: 'draft', kind: ui.kind, text: ui.text, step: f.step, email: (ui.email || '').trim() || undefined, offered: f.offered, studioVersion: f.studioVersion, pluginVersion: f.pluginVersion, app: f.app, studio: f.studio, by: 'card' }, function (res) {
58
+ ui.editing = false; ui.kind = null;
59
+ var d = res && res.structuredContent;
60
+ if (d && d.state === 'draft') C.tell('edit:' + d.draft, 'The person reworded the note on the Tell Homie card. Nothing is sent yet; the card shows the new text (draft ' + d.draft + ') with Send. Do not send it yourself unless they say yes.');
61
+ });
62
+ }));
63
+ acts.appendChild(C.btn('Cancel', 'ghost', function () { ui.editing = false; ui.kind = null; C.render(); }));
64
+ card.appendChild(acts);
65
+ }
66
+
67
+ function render(sc) {
68
+ var card = C.el('main', 'card');
69
+ var state = sc.state;
70
+ var head = state === 'sent' ? C.pill('Sent', 'ok') : state === 'declined' ? C.pill('Not sent', '') : state === 'draft' ? C.pill('Not sent yet', 'warn') : C.pill('Not sent', 'bad');
71
+ C.add(card, C.top('Note', head));
72
+ card.appendChild(C.el('h1', '', state === 'sent' ? 'Sent to Homie. Thank you.' : state === 'declined' ? 'Not sent' : 'A note to the people who make Homie'));
73
+ var n = sc.note;
74
+ if (state === 'draft' && ui.editing) { editor(card, sc); }
75
+ else if (n) {
76
+ card.appendChild(C.el('p', 'sub', state === 'sent' ? 'This is what they got:' : state === 'declined' ? 'Nothing left this computer. This was the note:' : 'This is exactly what would go:'));
77
+ card.appendChild(C.add(C.el('div', 'chips'), C.pill(LABEL[n.kind] || n.kind, 'live')));
78
+ card.appendChild(quote(n.text));
79
+ if (sc.with) card.appendChild(C.el('p', 'fine', 'With it: ' + sc.with));
80
+ if (sc.taken && sc.taken.length) card.appendChild(C.el('p', 'fine', 'Homie took out ' + sc.taken.join(', ') + ' before showing it here.'));
81
+ }
82
+ if (ui.why) card.appendChild(C.el('p', 'note bad', ui.why));
83
+ if (state === 'draft' && !ui.editing) {
84
+ var acts = C.el('div', 'actions send');
85
+ var send = C.btn(ui.busy ? 'Sending…' : 'Send', '', function () {
86
+ act(sc, Object.assign({ action: 'send', draft: sc.draft, by: 'card' }, fieldsOf(sc)), function (res) {
87
+ var d = res && res.structuredContent;
88
+ if (d && d.state === 'sent') C.tell('sent:' + sc.draft, 'The person pressed Send on the Tell Homie card: the note went to Homie (reference ' + String(d.reference || '').slice(0, 8) + '). Thank them in one line, and do not offer another note in this session.');
89
+ });
90
+ });
91
+ send.disabled = ui.busy;
92
+ acts.appendChild(send);
93
+ var edit = C.btn('Edit', 'ghost', function () { ui.editing = true; ui.kind = null; C.render(); }); edit.disabled = ui.busy;
94
+ acts.appendChild(edit);
95
+ var no = C.btn('Don’t send', 'ghost', function () {
96
+ act(sc, { action: 'decline', draft: sc.draft, by: 'card' }, function () {
97
+ C.tell('declined:' + sc.draft, 'The person pressed Don’t send on the Tell Homie card. Nothing was sent. Do not offer to send a note again in this session.');
98
+ });
99
+ });
100
+ no.disabled = ui.busy;
101
+ acts.appendChild(no);
102
+ card.appendChild(acts);
103
+ }
104
+ if (state === 'sent' && sc.reference) card.appendChild(C.el('p', 'fine', 'Reference ' + String(sc.reference).slice(0, 8) + '. Notes are private: only the people who make Homie read them.'));
105
+ var foot = C.el('div', 'foot');
106
+ foot.appendChild(C.el('span', 'when', state === 'sent' ? 'Sent to ' + (sc.to || 'homie.rocks') : state === 'declined' ? 'Nothing was sent.' : 'Nothing is sent until you press Send. Goes to ' + (sc.to || 'homie.rocks') + ', privately.'));
107
+ card.appendChild(foot);
108
+ return card;
109
+ }
110
+
111
+ C.start({ kind: 'feedback', label: 'Note', render: render, poll: null });
112
+ })();
package/media/MEDIA.md CHANGED
@@ -25,6 +25,7 @@ show and why anything is left out.
25
25
  "title": "Night Owls Theme",
26
26
  "blurb": "The studio's theme: a short sung hook over a driving synth line.",
27
27
  "published": true,
28
+ "date": "2026-09-30",
28
29
  "duration": 16.0,
29
30
  "bpm": 120,
30
31
  "key": "A minor",
@@ -57,6 +58,10 @@ show and why anything is left out.
57
58
  `poster`, `captions` (WebVTT), `master`. `"public": false` keeps a file off the site (masters,
58
59
  raw captures). Loops and stems are offered for download on the song page, so another game can
59
60
  use them.
61
+ - `date` (0.27.0): the day (or day and time, UTC) it came out; without one, `made.at`. A video's page gives it to
62
+ search engines as the video's upload date, and they show no video without one (the build says when a video has
63
+ neither). A music manifest's top-level `"album": "Night Route"` (or an entry's own `album`) makes its songs one
64
+ album in the pages' structured data (site/SITE.md, "Search engines and AI agents").
60
65
  - `rights` and `credits` are shown on the page, in plain words. Say what the provider's terms
61
66
  say for the plan the file was made on; nothing on the site claims more.
62
67
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.26.1",
3
+ "version": "0.28.0",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/site/SITE.md CHANGED
@@ -97,10 +97,17 @@ game.json's `landing` block, every key optional:
97
97
  "credits": [{ "role": "Music", "name": "Low Tide", "url": "https://example.com" }],
98
98
  "theme": { "accent": "#ff3bd4", "glow": "#00eaff" },
99
99
  "scheme": "light",
100
+ "screenshots": ["shots/rocks.jpg", "shots/finish.jpg"],
100
101
  "tv": true
101
102
  }
102
103
  ```
103
104
 
105
+ **Screenshots** (0.27.0): `landing.screenshots` (pictures in the game's folder), else every picture in
106
+ `games/<id>/screenshots/` by name, at most eight. The landing shows them in a band of their own, and its VideoGame
107
+ names them. Beside the `landing` block, game.json takes `"genre"` (a word, or up to three: `["Racing", "Party"]`,
108
+ shown with the room facts), `"released"` (the day it came out, else the commit that added its game.json) and
109
+ `"schema"` (structured data of the owner's own: [Search engines and AI agents](#search-engines-and-ai-agents)).
110
+
104
111
  ## Posts: `posts/*.md`
105
112
 
106
113
  One markdown file each. The name is the address, `posts/2026-09-30-we-are-live.md` is `/posts/we-are-live/`, dated
@@ -141,8 +148,8 @@ atproto later. `/.well-known/homie-studio.json` lists the latest posts for the h
141
148
  | `site/theme.json` | The tokens every page uses: `bg`, `fg`, `accent`, `glow`, `panel`, `accentInk` (colours: `#hex`, `rgb()`, `hsl()`, `oklch()`), `display`, `text`, `mono` (font lists), `fonts` (`[{ "family", "src": "/fonts/x.woff2", "weight": "400 800" }]`, files in `site/public`), `radius` (0 to 40), `mark` (a logo), `icon`, `social` (the share picture), `wordmark`, `uppercase: false`, `scheme: "light"`. Or `palette`: neon, dock, gold, acid, ember, orchid, tide, candy. A value that is not what it should be is dropped, with a warning. |
142
149
  | `site/theme.css` | CSS after the site's own, on every generated page: restyle anything. |
143
150
  | `site/partials/<name>.html` | A piece of every generated page: `head` (in `<head>`), `header` (the top line and its tabs), `footer` (the "Made with Homie" footer), `home` (a band on Home), `game` (a band on every landing), `game-<id>` (a band on one), `post` (after a post). `{{studio.name}}`, `{{game.name}}`, `{{game.id}}`, `{{game.play}}`, `{{year}}` are filled in. A band partial sits in one of the page's bands, unless it is its own `<section class="band">`. |
144
- | `site/pages/<path>/index.html` (or `site/pages/<path>.html`) | A whole page at `/<path>/`, served as it is, instead of the generated one (`site/pages/index.html` is Home, `site/pages/<id>/index.html` a game's landing) or beside them (`/about/`). It may borrow the site's parts: `<!-- homie:style -->`, `<!-- homie:header -->`, `<!-- homie:footer -->`, `<!-- homie:script -->`. The rooms, the game files, the API and the owner's pages are never a page's. |
145
- | `site/public/` | Files served as they are at the same path: fonts, a logo, hero footage, a page's pictures. |
151
+ | `site/pages/<path>/index.html` (or `site/pages/<path>.html`) | A whole page at `/<path>/`, served as it is, instead of the generated one (`site/pages/index.html` is Home, `site/pages/<id>/index.html` a game's landing) or beside them (`/about/`). It may borrow the site's parts: `<!-- homie:style -->`, `<!-- homie:header -->`, `<!-- homie:footer -->`, `<!-- homie:script -->`, and in its `<head>` `<!-- homie:schema -->` (the structured data the generated page there would carry: Home's, a landing's, else the studio's Organization). The rooms, the game files, the API and the owner's pages are never a page's. |
152
+ | `site/public/` | Files served as they are at the same path: fonts, a logo, hero footage, a page's pictures. A `robots.txt`, `sitemap.xml`, `llms.txt` or `llms-full.txt` here replaces the one the site makes. |
146
153
  | `site/src/worker.mjs` | The Worker. A studio that keeps a whole brand site of its own (with its own routes) wraps the template's Worker here, as the house brands do; everything it does not answer goes to the template. |
147
154
 
148
155
  A studio with its own landing in `site/` keeps it: the template never replaces what the studio made.
@@ -306,6 +313,64 @@ goes there as a POST. The site answers at its root and under every game with `{
306
313
  "not-a-homie" }` (JSON, CORS-open, 200, a preflight answered), so the game stops asking and plays on its own; no
307
314
  studio needs a Worker of its own for it.
308
315
 
316
+ ## Search engines and AI agents
317
+
318
+ From 0.27.0 the site tells search engines and AI agents what it is, from its own files, and nothing more
319
+ (worker/schema.mjs, worker/discover.mjs).
320
+
321
+ **Structured data.** Every generated page carries one `<script type="application/ld+json">` in its `<head>`, a
322
+ schema.org `@graph`:
323
+
324
+ | Page | Types |
325
+ | --- | --- |
326
+ | Home | `Organization` (the studio: name, address, `site/theme.json` `mark` as its logo, `social` as its image, the tagline) and `WebSite` |
327
+ | `/games/`, `/rooms/` | `BreadcrumbList`, `ItemList` of the public games' landings (the Rooms page's data never carries a live count) |
328
+ | `/<id>/` | `BreadcrumbList`, `VideoGame` co-typed `WebApplication`: name, description, the pitch as `abstract`, its pictures and screenshots, `genre`, `numberOfPlayers` (min and max), `playMode`, `gamePlatform`, the studio as author and publisher, `license` (the source's licence, linked to its SPDX page), `isBasedOn` (a remix's original page, a port's original), `trailer` (a `VideoObject`), `datePublished` and `dateModified`, a `PlayAction`, and one free-to-play `Offer` (price 0) |
329
+ | `/<id>/credits`, `/<id>/servers/`, a server's page | `BreadcrumbList` |
330
+ | `/music/`, `/music/<slug>/` | `MusicAlbum` (every song names one album) or an `ItemList`; `MusicRecording` (`byArtist` the studio, its duration, its `AudioObject`, its cover, `inAlbum`, `musicalKey`, its date) |
331
+ | `/videos/`, `/videos/<slug>/` | `ItemList`; `VideoObject` (`thumbnailUrl`, `uploadDate`, `duration`, `contentUrl`: the file at its own address, from R2 or the site) |
332
+ | `/posts/`, `/posts/<slug>/` | `Blog`; `BlogPosting` (headline, dates, the author it names or the studio, the publisher, its picture, the game it is about) |
333
+
334
+ Never: a rating or a review (the page shows none), a price for anything the shop does not sell, or a number that is
335
+ only true this minute. While the studio's shop really sells (shop/SHOP.md: shop.json checked, its key, webhook
336
+ secret and tables in place), each item sold in a game is an `addOn` of that game's free offer, in real money; a tip
337
+ has no price and is left out. A game that is not public yet (private, or an invite-only beta) offers nothing, and its
338
+ landing answers `x-robots-tag: noindex`.
339
+
340
+ A video's `uploadDate` is its manifest entry's `"date"`, else `made.at`; a video with neither is said by the build,
341
+ because search engines show no video without it. A song's date is the same, and a music manifest's `"album"` (or an
342
+ entry's own) makes the songs a `MusicAlbum`. A game's `datePublished` is game.json `"released"`, else the commit that
343
+ added its game.json; `dateModified` is the last commit that touched its folder (nothing from a shallow clone).
344
+
345
+ **The owner's own.** game.json `"schema"` adds schema.org properties to the game's `VideoGame`, and studio.json
346
+ `"site": { "schema": { … } }` to the studio's `Organization` (its `sameAs`, say):
347
+
348
+ ```json
349
+ "site": { "schema": { "sameAs": ["https://github.com/night-owls", "https://www.youtube.com/@nightowls"], "foundingDate": "2026" } }
350
+ ```
351
+
352
+ Homie's own properties always win; `aggregateRating`, `review` and `offers` are refused, and the build says so, with
353
+ any property schema.org does not put on that type. A whole block of the owner's own still goes in
354
+ `site/partials/head.html`.
355
+
356
+ **Files for crawlers and agents**, made from the public catalogue: a private or invite-only game is in none of them.
357
+
358
+ - `/robots.txt`: every public page may be crawled; `/_studio/`, `/api/`, `/account/`, the shop's private steps
359
+ and each game's play, TV, watch, live, invite, API and socket doors may not; then the sitemap's address. A
360
+ Preview answers `Disallow: /`.
361
+ - `/sitemap.xml`: Home, Games and each landing (and its credits), Rooms, Music, Videos, Posts and every page in
362
+ `site/pages`, with `lastmod` only where a real date says when it changed.
363
+ - `/llms.txt`, following the llms.txt convention (an H1, a `>` summary, then `##` sections of links): what the
364
+ studio is; each game with its pitch, players, rounds, Play, Watch and the big screen; which games are open to remix,
365
+ with their licence, their `source.json` and the words to say to Claude Code or Codex with Homie (the plugin's
366
+ `game_remix` tool, or `homie-studio game remix <source> --id <new-id>`); songs; videos; posts with their Atom and
367
+ JSON feeds; the studio's own pages (a changelog among them); and homie.rocks's own llms.txt.
368
+ - `/llms-full.txt`: the same, with each game's whole description, how to play, controls and credits, and every
369
+ post's text.
370
+
371
+ A game is offered for remixing only when its source is shared, its owner's Remixable switch is on and its licence
372
+ allows it, as for the directory. They are cached for five minutes. A file of the studio's own in `site/public` wins.
373
+
309
374
  ## Headers
310
375
 
311
376
  Every HTML answer is `no-transform` (a custom domain's edge injects nothing: Web Analytics' automatic beacon
@@ -0,0 +1,232 @@
1
+ /**
2
+ * HOW SEARCH ENGINES AND AI AGENTS FIND A STUDIO (0.27.0): four plain files the site makes from its own catalogue,
3
+ * so they are never out of date and never name what is not public.
4
+ *
5
+ * /robots.txt every public page may be crawled; the owner's pages (/_studio/), the APIs, accounts, the shop's
6
+ * private steps and each game's play, TV, watch and socket doors may not; the sitemap's address.
7
+ * A Preview (a branch under review) asks not to be crawled at all.
8
+ * /sitemap.xml every public page (Home, Games and each landing, Music, Videos, Rooms, Posts, the studio's own
9
+ * pages), with lastmod only where a real date says when it changed
10
+ * /llms.txt the llms.txt convention (an H1, a > summary, then ## sections of links): what the studio is, each
11
+ * public game with its pitch, players, Play, Watch, the big screen and whether and how it can be
12
+ * remixed (the licence, the source, the words to say to Claude Code or Codex with Homie), songs,
13
+ * videos, posts with their feeds, the studio's own pages, and a link to homie.rocks's llms.txt
14
+ * /llms-full.txt the same, with each game's whole description, how to play, controls and credits, and every
15
+ * post's text
16
+ *
17
+ * Only public games are ever named (a private or invite-only game is in none of them), and a game is offered for
18
+ * remixing only when its owner's remix switch is on, its source is shared and its licence allows it. A file of the
19
+ * studio's own in site/public (robots.txt, sitemap.xml, llms.txt, llms-full.txt) wins over the made one.
20
+ */
21
+ import { licenseOf, LICENSE_KINDS, remixRow } from './license.mjs';
22
+
23
+ export const DISCOVERY_FILES = ['/robots.txt', '/sitemap.xml', '/llms.txt', '/llms-full.txt'];
24
+
25
+ const studioName = (cat) => cat?.studio?.name ?? 'Studio';
26
+ const one = (v, n = 400) => String(v ?? '').replace(/\s+/g, ' ').trim().slice(0, n);
27
+ /** Text for a markdown link's words: brackets escaped, one line. */
28
+ const label = (v) => one(v, 120).replace(/([[\]\\])/g, '\\$1');
29
+ const xml = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[c]);
30
+ const dateOf = (v) => (typeof v === 'string' && Number.isFinite(Date.parse(v)) ? v : null);
31
+ const newest = (list) => list.map(dateOf).filter(Boolean).sort((a, b) => Date.parse(b) - Date.parse(a))[0] ?? null;
32
+ const day = (v) => (dateOf(v) ? new Date(Date.parse(v)).toISOString().slice(0, 10) : null);
33
+ const clock = (s) => (Number.isFinite(s) && s > 0 ? `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, '0')}` : null);
34
+ const KIND_WORD = { song: 'Song', score: 'Score', loop: 'Loop', stem: 'Stem', sfx: 'Sound', trailer: 'Trailer', 'music-video': 'Music video', cutscene: 'Cutscene', clip: 'Clip' };
35
+ const players = (g) => { const min = g.players?.min ?? 1; const max = g.players?.max ?? 8; return max <= 1 ? '1 player' : min === max ? `${max} players` : `${min}–${max} players`; };
36
+ const rounds = (s) => (Number.isFinite(s) && s > 0 ? (s % 60 ? `${s}-second rounds` : `${s / 60}-minute rounds`) : null);
37
+ const watchable = (g) => !(g.watch === false || g.watch === 'off');
38
+ const tv = (g) => g.landing?.tv !== false;
39
+ const gameDate = (g) => dateOf(g.dates?.modified) ?? dateOf(g.dates?.published);
40
+ const mediaDate = (e) => dateOf(e.date) ?? dateOf(e.made?.at);
41
+ const postDate = (p) => dateOf(p.updated) ?? dateOf(p.date);
42
+
43
+ /** The studio's own pages (site/pages) the sitemap and llms.txt name: not Home, not a game's landing, never the site's own places. */
44
+ function ownPages(cat, all) {
45
+ const ids = new Set((all?.games ?? cat.games ?? []).map((g) => g.id));
46
+ return (cat.site?.pages ?? []).filter((p) => typeof p === 'string' && /^\/[a-z0-9][a-z0-9/_-]*\/$/i.test(p) && p !== '/')
47
+ .filter((p) => !ids.has(p.split('/')[1]) && !/^\/(?:games|music|videos|rooms|posts|account|shop|api|_studio|_homie|_site|media)\//.test(p));
48
+ }
49
+ const pageName = (p) => p.split('/').filter(Boolean).map((s) => s.replace(/[-_]+/g, ' ')).join(' · ').replace(/^./, (c) => c.toUpperCase());
50
+
51
+ /* ------------------------------------------------------------------ robots.txt */
52
+
53
+ export function robotsTxt(cat, origin, { preview = false } = {}) {
54
+ if (preview) {
55
+ return `# ${one(studioName(cat), 80)}: a Preview (a branch under review), not the studio's site. Nothing here is for search.\nUser-agent: *\nDisallow: /\n`;
56
+ }
57
+ const games = (cat.games ?? []).map((g) => g.id);
58
+ const lines = [
59
+ `# ${one(studioName(cat), 80)}, made with Homie (https://homie.rocks/studio/).`,
60
+ '# Every public page may be crawled. The owner\'s pages, the APIs, accounts, the shop\'s private steps and each game\'s',
61
+ '# play, TV, watch and socket doors may not (they open live rooms). AI agents: /llms.txt says what is here.',
62
+ 'User-agent: *',
63
+ 'Allow: /',
64
+ 'Disallow: /_studio/',
65
+ 'Disallow: /api/',
66
+ 'Disallow: /account/',
67
+ 'Disallow: /shop/thanks',
68
+ 'Disallow: /shop/parent/',
69
+ 'Disallow: /__homie',
70
+ ...games.flatMap((id) => [`Disallow: /${id}/play`, `Disallow: /${id}/tv`, `Disallow: /${id}/watch`, `Disallow: /${id}/live`, `Disallow: /${id}/invite`, `Disallow: /${id}/api/`, `Disallow: /${id}/__`, `Disallow: /${id}/s/*/play`, `Disallow: /${id}/s/*/tv`, `Disallow: /${id}/s/*/home`]),
71
+ '',
72
+ `Sitemap: ${origin}/sitemap.xml`,
73
+ ];
74
+ return `${lines.join('\n')}\n`;
75
+ }
76
+
77
+ /* ------------------------------------------------------------------ sitemap.xml */
78
+
79
+ /** [{ path, lastmod }] for every public page, Home first. */
80
+ export function sitemapEntries(cat, { all = null } = {}) {
81
+ const games = cat.games ?? [];
82
+ const songs = cat.songs ?? [];
83
+ const videos = cat.videos ?? [];
84
+ const posts = cat.posts ?? [];
85
+ const out = [];
86
+ const add = (path, lastmod = null) => { if (!out.some((x) => x.path === path)) out.push({ path, lastmod: dateOf(lastmod) }); };
87
+ add('/', newest([...games.map(gameDate), ...songs.map(mediaDate), ...videos.map(mediaDate), ...posts.map(postDate)]));
88
+ if (games.length) {
89
+ add('/games/', newest(games.map(gameDate)));
90
+ for (const g of games) {
91
+ add(`/${g.id}/`, gameDate(g));
92
+ if (g.landing?.credits?.texts) add(`/${g.id}/credits`, gameDate(g));
93
+ }
94
+ add('/rooms/');
95
+ }
96
+ if (songs.length) { add('/music/', newest(songs.map(mediaDate))); for (const e of songs) add(`/music/${e.slug}/`, mediaDate(e)); }
97
+ if (videos.length) { add('/videos/', newest(videos.map(mediaDate))); for (const e of videos) add(`/videos/${e.slug}/`, mediaDate(e)); }
98
+ if (posts.length) { add('/posts/', newest(posts.map(postDate))); for (const p of posts) add(`/posts/${p.slug}/`, postDate(p)); }
99
+ for (const p of ownPages(cat, all)) add(p);
100
+ return out;
101
+ }
102
+
103
+ export function sitemapXml(cat, origin, opts = {}) {
104
+ const urls = sitemapEntries(cat, opts).map((u) => ` <url><loc>${xml(`${origin}${u.path}`)}</loc>${u.lastmod ? `<lastmod>${xml(new Date(Date.parse(u.lastmod)).toISOString().replace(/\.\d{3}Z$/, 'Z'))}</lastmod>` : ''}</url>`);
105
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join('\n')}\n</urlset>\n`;
106
+ }
107
+
108
+ /* ------------------------------------------------------------------ llms.txt */
109
+
110
+ const HOMIE_INSTALL = 'in Claude Code, `/plugin marketplace add homie-rocks/homie` and then `/plugin install homie@homie` (Codex takes the same marketplace)';
111
+
112
+ function licenceWords(g) {
113
+ const l = licenseOf(g.license);
114
+ const name = `${LICENSE_KINDS[l.kind]}${l.spdx ? `, ${l.spdx}` : ''}`;
115
+ if (l.kind === 'remix-freely') return `${name}: a credit is welcome, not asked for`;
116
+ return `${name}: a remix says "Remix of ${one(g.name, 80)} by ${one(g.studio, 80)}" with a link back`;
117
+ }
118
+
119
+ function summaryOf(cat, origin) {
120
+ const name = studioName(cat);
121
+ const tag = one(cat.studio?.tagline, 140);
122
+ const games = (cat.games ?? []).length;
123
+ // What it has, never what it might: a studio with no game says only what it publishes (a music studio makes no game).
124
+ const has = [(cat.songs ?? []).length ? 'songs' : null, (cat.videos ?? []).length ? 'videos' : null, (cat.posts ?? []).length ? 'posts' : null].filter(Boolean);
125
+ const kinds = has.length > 1 ? `${has.slice(0, -1).join(', ')} and ${has.at(-1)}` : has[0];
126
+ const what = games
127
+ ? 'It makes free multiplayer games that play in a web browser on a phone, a computer or a TV; press Play and you are in a live public room with whoever is playing, and bots hold the empty seats.'
128
+ : has.length ? `It publishes ${kinds}.` : 'Its first game is coming soon.';
129
+ return [
130
+ `# ${one(name, 120)}`,
131
+ '',
132
+ `> ${one(name, 120)} is a studio made with Homie${tag ? ` (${tag})` : ''}. ${what} Its site is ${origin}/.`,
133
+ ];
134
+ }
135
+
136
+ /** /llms.txt. `remixable(g)`: the owner's remix switch is on, the source is shared, and the licence allows it. */
137
+ export function llmsTxt(cat, origin, { remixable = () => false, directory = 'https://homie.rocks', full = false, posts = null, all = null } = {}) {
138
+ const name = studioName(cat);
139
+ const games = (cat.games ?? []).map((g) => ({ ...g, studio: name }));
140
+ const songs = cat.songs ?? [];
141
+ const videos = cat.videos ?? [];
142
+ const list = cat.posts ?? [];
143
+ const out = summaryOf(cat, origin);
144
+ out.push('', full
145
+ ? `Everything ${one(name, 120)} has published, in full: each game's whole description, how to play, controls and credits, every song and video, and every post's text. The short version with links only is ${origin}/llms.txt.`
146
+ : `${games.length ? 'Every game below is free and needs no download or account. A game\'s page has its trailer, how to play and credits; its Play link puts a visitor straight into a live public room. ' : ''}Whole descriptions and every post's text: ${origin}/llms-full.txt.`);
147
+ if (games.length) {
148
+ out.push('', '## Games', '');
149
+ for (const g of games) {
150
+ const L = g.landing ?? {};
151
+ const doors = [`Play: ${origin}/${g.id}/play`, ...(watchable(g) ? [`Watch a live room: ${origin}/${g.id}/watch`] : []), ...(tv(g) ? [`On a TV: ${origin}/${g.id}/tv`] : [])];
152
+ const facts = [players(g), rounds(g.roundSeconds), ...(Array.isArray(g.genre) ? g.genre : g.genre ? [g.genre] : [])].filter(Boolean).join(', ');
153
+ const lineage = remixRow(g.remixOf);
154
+ const open = remixable(g);
155
+ out.push(`- [${label(g.name)}](${origin}/${g.id}/): ${one(L.pitch || g.blurb, 240)}${/[.!?]$/.test(one(L.pitch || g.blurb)) ? '' : '.'} ${facts}.${lineage ? ` A remix of ${one(lineage.name, 80)}${lineage.studio ? ` by ${one(lineage.studio, 80)}` : ''}.` : ''} ${doors.join('. ')}. ${open ? `Open to remix (${LICENSE_KINDS[licenseOf(g.license).kind]}${licenseOf(g.license).spdx ? `, ${licenseOf(g.license).spdx}` : ''}): see Remix below.` : 'Not open to remix.'}`);
156
+ if (full) {
157
+ const C = L.credits ?? {};
158
+ const lines = [];
159
+ if (g.blurb && one(g.blurb) !== one(L.pitch)) lines.push(one(g.blurb, 600));
160
+ if (L.about) lines.push(one(L.about, 1200));
161
+ if ((L.how ?? []).length) lines.push(`How to play: ${L.how.map((x) => one(x, 240)).join(' ')}`);
162
+ const ctl = ['phone', 'computer', 'tv'].filter((k) => L.controls?.[k]).map((k) => `${k === 'tv' ? 'TV' : k}: ${one(L.controls[k], 300)}`);
163
+ if (ctl.length) lines.push(`Controls. ${ctl.join('. ')}.`);
164
+ const credits = [`Made by ${one(name, 80)}`, ...(C.people ?? []).map((c) => `${c.role ? `${one(c.role, 60)}: ` : ''}${one(c.name, 80)}`)];
165
+ if (C.original && !C.original.house && C.original.title) credits.push(`Based on ${one(C.original.title, 120)}${C.original.author ? ` by ${one(C.original.author, 120)}` : ''}${C.original.year ? ` (${one(C.original.year, 12)})` : ''}${C.original.licence ? `, ${one(C.original.licence, 60)} licence` : ''}${C.original.url ? ` (${C.original.url})` : ''}`);
166
+ for (const p of C.parts ?? []) credits.push(`${one(p.what, 160)}${p.author ? `: ${one(p.author, 120)}` : ''}${p.licence ? `, ${one(p.licence, 60)}` : ''}`);
167
+ lines.push(`Credits: ${credits.join('; ')}.`);
168
+ if (C.texts) lines.push(`Licence texts: ${origin}/${g.id}/credits`);
169
+ for (const l of lines) out.push(` ${l}`);
170
+ }
171
+ }
172
+ const open = games.filter((g) => remixable(g));
173
+ if (open.length) {
174
+ out.push('', '## Remix', '');
175
+ out.push(`Homie is a plugin for Claude Code and Codex that makes multiplayer games in a studio of your own, on your own free Cloudflare account. To remix one of these games, ${HOMIE_INSTALL}, then say "Remix <game> from <its source address> into a game of my own in my Homie studio". The plugin's \`game_remix\` tool (or \`npx --no-install homie-studio game remix <source address> --id <new-id>\` in a studio's folder) copies it in and writes the credit its licence asks for.`, '');
176
+ for (const g of open) out.push(`- [${label(g.name)} source](${origin}/games/${g.id}/source.json): ${licenceWords(g)}. Say: "Remix ${one(g.name, 80)} from ${origin}/games/${g.id}/source.json into a game of my own in my Homie studio".`);
177
+ }
178
+ }
179
+ if (songs.length) {
180
+ out.push('', '## Music', '');
181
+ for (const e of songs) {
182
+ out.push(`- [${label(e.title)}](${origin}/music/${e.slug}/): ${[KIND_WORD[e.kind] ?? 'Music', clock(e.duration), e.bpm ? `${e.bpm} BPM` : null, e.key, e.album ? `from ${one(e.album, 120)}` : null].filter(Boolean).join(', ')}.${e.blurb ? ` ${one(e.blurb, 300)}` : ''}`);
183
+ if (full && e.credits) out.push(` ${one(e.credits, 600)}`);
184
+ }
185
+ }
186
+ if (videos.length) {
187
+ out.push('', '## Videos', '');
188
+ for (const e of videos) {
189
+ out.push(`- [${label(e.title)}](${origin}/videos/${e.slug}/): ${[KIND_WORD[e.kind] ?? 'Video', clock(e.duration)].filter(Boolean).join(', ')}.${e.blurb ? ` ${one(e.blurb, 300)}` : ''}`);
190
+ if (full && e.credits) out.push(` ${one(e.credits, 600)}`);
191
+ }
192
+ }
193
+ if (list.length) {
194
+ out.push('', '## Posts', '');
195
+ out.push(`- [Atom feed](${origin}/posts/feed.xml): every post, newest first, with its full text`);
196
+ out.push(`- [JSON Feed](${origin}/posts/feed.json): the same, as JSON`);
197
+ for (const p of list.slice(0, full ? 200 : 20)) out.push(`- [${label(p.title)}](${origin}/posts/${p.slug}/): ${day(p.date) ?? ''}${p.summary ? `. ${one(p.summary, 300)}` : ''}`);
198
+ if (full && posts?.length) {
199
+ for (const p of posts) {
200
+ const textOf = p.record?.text;
201
+ if (!textOf) continue;
202
+ out.push('', `### ${one(p.title, 140)} (${day(p.date) ?? ''})`, '', `${origin}/posts/${p.slug}/`, '', String(textOf).trim().slice(0, 30000));
203
+ }
204
+ }
205
+ }
206
+ const pages = ownPages(cat, all);
207
+ if (pages.length) {
208
+ out.push('', '## Pages', '');
209
+ for (const p of pages) out.push(`- [${label(pageName(p))}](${origin}${p})`);
210
+ }
211
+ out.push('', '## Optional', '');
212
+ if (games.length) out.push(`- [Rooms](${origin}/rooms/): every public room playing now (${origin}/api/rooms is the same as JSON)`);
213
+ out.push(`- [Studio manifest](${origin}/.well-known/homie-studio.json): the games, songs, videos and posts as JSON`);
214
+ out.push(`- [Sitemap](${origin}/sitemap.xml): every public page`);
215
+ if (!full) out.push(`- [Everything, in full](${origin}/llms-full.txt): each game's whole description, how to play and credits, and every post's text`);
216
+ const hub = /^https:\/\/[a-z0-9.-]+$/i.test(String(directory ?? '')) ? directory : 'https://homie.rocks';
217
+ out.push(`- [Homie](${hub}/llms.txt): what Homie is, and how to make a studio like this one`);
218
+ return `${out.join('\n')}\n`;
219
+ }
220
+
221
+ /** The plain-text answer every one of these files is sent with. */
222
+ export function discoveryResponse(body, type, { method = 'GET' } = {}) {
223
+ return new Response(method === 'HEAD' ? null : body, {
224
+ headers: {
225
+ 'content-type': `${type}; charset=utf-8`,
226
+ // A game the owner makes private leaves these within five minutes.
227
+ 'cache-control': 'public, max-age=300',
228
+ 'x-content-type-options': 'nosniff',
229
+ 'access-control-allow-origin': '*',
230
+ },
231
+ });
232
+ }