@homie-rocks/studio 0.8.0 → 0.10.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/README.md +90 -8
- package/bin/homie-studio.mjs +90 -13
- package/lib/build.mjs +38 -1
- package/lib/check.mjs +109 -48
- package/lib/chrome.mjs +126 -0
- package/lib/cloudflare.mjs +85 -16
- package/lib/look.mjs +8 -12
- package/lib/media.mjs +8 -1
- package/lib/port-check.mjs +8 -13
- package/lib/progress.mjs +115 -6
- package/lib/scaffold.mjs +131 -22
- package/lib/setup.mjs +90 -0
- package/lib/site.mjs +8 -1
- package/lib/studio.mjs +11 -0
- package/lib/template-history.json +180 -0
- package/lib/upgrade.mjs +285 -0
- package/lib/version.mjs +7 -3
- package/media/MEDIA.md +13 -0
- package/package.json +1 -1
- package/site/SITE.md +51 -6
- package/worker/index.mjs +125 -25
- package/worker/pages.mjs +67 -11
- package/worker/site.mjs +64 -19
- package/worker/stats.mjs +17 -0
- package/worker/version.mjs +1 -1
package/lib/studio.mjs
CHANGED
|
@@ -26,6 +26,17 @@ export function requireStudio(from) {
|
|
|
26
26
|
return root;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/*
|
|
30
|
+
* WHERE THE WORKER'S CONFIG LIVES. From 0.10.0 a new studio keeps `wrangler.jsonc` at its root, which is what
|
|
31
|
+
* Cloudflare's Workers Builds and its "Deploy to Cloudflare" button read (they run the studio's own `npm run build`
|
|
32
|
+
* and `npm run deploy` at the repository's root). A studio made before 0.10.0 keeps `site/wrangler.jsonc`; every
|
|
33
|
+
* command runs Wrangler next to whichever the studio has, so both keep working.
|
|
34
|
+
*/
|
|
35
|
+
export function workerDir(root) { return existsSync(join(root, 'wrangler.jsonc')) || !existsSync(join(root, 'site', 'wrangler.jsonc')) ? root : join(root, 'site'); }
|
|
36
|
+
export function configPath(root) { return join(workerDir(root), 'wrangler.jsonc'); }
|
|
37
|
+
/** 'root' (0.10.0 and later: Workers Builds ready) or 'site' (older studios). */
|
|
38
|
+
export function layoutOf(root) { return workerDir(root) === root ? 'root' : 'site'; }
|
|
39
|
+
|
|
29
40
|
export function readStudio(root) { return JSON.parse(readFileSync(join(root, 'studio.json'), 'utf8')); }
|
|
30
41
|
export function writeStudio(root, studio) { writeFileSync(join(root, 'studio.json'), `${JSON.stringify(studio, null, 2)}\n`); }
|
|
31
42
|
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
{
|
|
2
|
+
"v": 1,
|
|
3
|
+
"about": "Fingerprints of every studio template version (scripts/studio-template-history.mjs): homie-studio upgrade replaces a file or an AGENTS.md section only when it is exactly one of these.",
|
|
4
|
+
"versions": {
|
|
5
|
+
"0.2.0": {
|
|
6
|
+
"files": {
|
|
7
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
8
|
+
"README.md": "684b3cca8001673c",
|
|
9
|
+
"games/README.md": "5e5e942baa7e0372",
|
|
10
|
+
"music/README.md": "fd3d904cfa5b39d9",
|
|
11
|
+
"videos/README.md": "0546814c9382b601",
|
|
12
|
+
"posts/README.md": "ef1a30b0d7aa77bd",
|
|
13
|
+
"site/src/worker.mjs": "70148970cf81d685"
|
|
14
|
+
},
|
|
15
|
+
"sections": {
|
|
16
|
+
"AGENTS.md": {
|
|
17
|
+
"(intro)": "9ecc7809a3b9b4f7",
|
|
18
|
+
"## Layout": "1829a9bfb106455b",
|
|
19
|
+
"## Commands (all through the pinned CLI in node_modules)": "000952727c257f96",
|
|
20
|
+
"## Making games": "a4c11a533a03391f",
|
|
21
|
+
"## Rules": "d9da1f3e572199f6"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"0.3.0": {
|
|
26
|
+
"files": {
|
|
27
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
28
|
+
"README.md": "684b3cca8001673c",
|
|
29
|
+
"games/README.md": "5e5e942baa7e0372",
|
|
30
|
+
"music/README.md": "fd3d904cfa5b39d9",
|
|
31
|
+
"videos/README.md": "0546814c9382b601",
|
|
32
|
+
"posts/README.md": "ef1a30b0d7aa77bd",
|
|
33
|
+
"site/src/worker.mjs": "70148970cf81d685"
|
|
34
|
+
},
|
|
35
|
+
"sections": {
|
|
36
|
+
"AGENTS.md": {
|
|
37
|
+
"(intro)": "9ecc7809a3b9b4f7",
|
|
38
|
+
"## Layout": "1829a9bfb106455b",
|
|
39
|
+
"## Commands (all through the pinned CLI in node_modules)": "583e8fd0eece6f76",
|
|
40
|
+
"## Making games": "a4c11a533a03391f",
|
|
41
|
+
"## Rules": "d9da1f3e572199f6"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"0.5.0": {
|
|
46
|
+
"files": {
|
|
47
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
48
|
+
"README.md": "684b3cca8001673c",
|
|
49
|
+
"games/README.md": "5e5e942baa7e0372",
|
|
50
|
+
"music/README.md": "b8a828de9311bd28",
|
|
51
|
+
"videos/README.md": "70614388cbb203c7",
|
|
52
|
+
"posts/README.md": "ef1a30b0d7aa77bd",
|
|
53
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
54
|
+
},
|
|
55
|
+
"sections": {
|
|
56
|
+
"AGENTS.md": {
|
|
57
|
+
"(intro)": "214aa46eb3a895d5",
|
|
58
|
+
"## Layout": "f8e01e41892420d3",
|
|
59
|
+
"## Commands (all through the pinned CLI in node_modules)": "dff7046dac113123",
|
|
60
|
+
"## Making games": "a4c11a533a03391f",
|
|
61
|
+
"## Rules": "d9da1f3e572199f6",
|
|
62
|
+
"## Beta": "9d02638207a26e73"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
"0.6.0": {
|
|
67
|
+
"files": {
|
|
68
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
69
|
+
"README.md": "684b3cca8001673c",
|
|
70
|
+
"games/README.md": "5e5e942baa7e0372",
|
|
71
|
+
"music/README.md": "b8a828de9311bd28",
|
|
72
|
+
"videos/README.md": "70614388cbb203c7",
|
|
73
|
+
"posts/README.md": "ef1a30b0d7aa77bd",
|
|
74
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
75
|
+
},
|
|
76
|
+
"sections": {
|
|
77
|
+
"AGENTS.md": {
|
|
78
|
+
"(intro)": "214aa46eb3a895d5",
|
|
79
|
+
"## Layout": "b2af4e6842972cbd",
|
|
80
|
+
"## Commands (all through the pinned CLI in node_modules)": "3849a47e757f544a",
|
|
81
|
+
"## Making games": "a4c11a533a03391f",
|
|
82
|
+
"## Rules": "969318425c99f5a5",
|
|
83
|
+
"## Beta": "9d02638207a26e73"
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
"0.7.0": {
|
|
88
|
+
"files": {
|
|
89
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
90
|
+
"README.md": "684b3cca8001673c",
|
|
91
|
+
"games/README.md": "5e5e942baa7e0372",
|
|
92
|
+
"music/README.md": "b8a828de9311bd28",
|
|
93
|
+
"videos/README.md": "70614388cbb203c7",
|
|
94
|
+
"posts/README.md": "37841aae476bd540",
|
|
95
|
+
"site/README.md": "0fd152a20a3eb55f",
|
|
96
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
97
|
+
},
|
|
98
|
+
"sections": {
|
|
99
|
+
"AGENTS.md": {
|
|
100
|
+
"(intro)": "214aa46eb3a895d5",
|
|
101
|
+
"## Layout": "b5f801e112d163a9",
|
|
102
|
+
"## Commands (all through the pinned CLI in node_modules)": "3849a47e757f544a",
|
|
103
|
+
"## The site": "e174cd30932df888",
|
|
104
|
+
"## Making games": "a4c11a533a03391f",
|
|
105
|
+
"## Rules": "969318425c99f5a5",
|
|
106
|
+
"## Beta": "9d02638207a26e73"
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
"0.8.0": {
|
|
111
|
+
"files": {
|
|
112
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
113
|
+
"README.md": "684b3cca8001673c",
|
|
114
|
+
"games/README.md": "5e5e942baa7e0372",
|
|
115
|
+
"music/README.md": "b8a828de9311bd28",
|
|
116
|
+
"videos/README.md": "70614388cbb203c7",
|
|
117
|
+
"posts/README.md": "37841aae476bd540",
|
|
118
|
+
"site/README.md": "0fd152a20a3eb55f",
|
|
119
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
120
|
+
},
|
|
121
|
+
"sections": {
|
|
122
|
+
"AGENTS.md": {
|
|
123
|
+
"(intro)": "214aa46eb3a895d5",
|
|
124
|
+
"## Layout": "b5f801e112d163a9",
|
|
125
|
+
"## Commands (all through the pinned CLI in node_modules)": "3849a47e757f544a",
|
|
126
|
+
"## The site": "e174cd30932df888",
|
|
127
|
+
"## Making games": "a4c11a533a03391f",
|
|
128
|
+
"## Rules": "969318425c99f5a5",
|
|
129
|
+
"## Beta": "9d02638207a26e73"
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
"0.9.0": {
|
|
134
|
+
"files": {
|
|
135
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
136
|
+
"README.md": "684b3cca8001673c",
|
|
137
|
+
"games/README.md": "5e5e942baa7e0372",
|
|
138
|
+
"music/README.md": "b8a828de9311bd28",
|
|
139
|
+
"videos/README.md": "70614388cbb203c7",
|
|
140
|
+
"posts/README.md": "37841aae476bd540",
|
|
141
|
+
"site/README.md": "0fd152a20a3eb55f",
|
|
142
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
143
|
+
},
|
|
144
|
+
"sections": {
|
|
145
|
+
"AGENTS.md": {
|
|
146
|
+
"(intro)": "214aa46eb3a895d5",
|
|
147
|
+
"## Layout": "b5f801e112d163a9",
|
|
148
|
+
"## Commands (all through the pinned CLI in node_modules)": "63833b7006efdef4",
|
|
149
|
+
"## The site": "7d3525ba39b7f262",
|
|
150
|
+
"## Making games": "c204473d9b468f49",
|
|
151
|
+
"## Rules": "969318425c99f5a5",
|
|
152
|
+
"## Beta": "9d02638207a26e73"
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
"0.10.0": {
|
|
157
|
+
"files": {
|
|
158
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
159
|
+
"README.md": "684b3cca8001673c",
|
|
160
|
+
"games/README.md": "5e5e942baa7e0372",
|
|
161
|
+
"music/README.md": "b8a828de9311bd28",
|
|
162
|
+
"videos/README.md": "70614388cbb203c7",
|
|
163
|
+
"posts/README.md": "37841aae476bd540",
|
|
164
|
+
"site/README.md": "ca131cd0472464fb",
|
|
165
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
166
|
+
},
|
|
167
|
+
"sections": {
|
|
168
|
+
"AGENTS.md": {
|
|
169
|
+
"(intro)": "214aa46eb3a895d5",
|
|
170
|
+
"## Layout": "f14d641159480e6c",
|
|
171
|
+
"## Commands (all through the pinned CLI in node_modules)": "2af7c804c1e81d86",
|
|
172
|
+
"## The site": "7d3525ba39b7f262",
|
|
173
|
+
"## Making games": "c204473d9b468f49",
|
|
174
|
+
"## Rules": "969318425c99f5a5",
|
|
175
|
+
"## Beta": "9d02638207a26e73"
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
package/lib/upgrade.mjs
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio upgrade [--apply] [--diff]` — an existing studio takes what this version's template adds, and
|
|
3
|
+
* nothing it made its own is ever overwritten.
|
|
4
|
+
*
|
|
5
|
+
* `homie-studio new` writes a studio's first files (lib/scaffold.mjs). A studio made on an older version keeps its
|
|
6
|
+
* old ones: its AGENTS.md lacks the sections the template grew since (the site, the room button, how to upgrade),
|
|
7
|
+
* a README it never touched is out of date, its .gitignore misses lines. This command compares the studio with
|
|
8
|
+
* what this version would write for it, and sorts every difference:
|
|
9
|
+
*
|
|
10
|
+
* added a file, an AGENTS.md section, a .gitignore line or a package.json script the studio does not have;
|
|
11
|
+
* updated a file or an AGENTS.md section the studio never changed since an older template wrote it (its text
|
|
12
|
+
* is exactly an earlier version's: lib/template-history.json keeps a fingerprint of every one), so the
|
|
13
|
+
* template's newer text replaces it;
|
|
14
|
+
* kept anything the studio changed, or wrote itself: never touched. The plan says it differs, and
|
|
15
|
+
* `--diff` prints the difference, so the person can take the template's words by hand.
|
|
16
|
+
*
|
|
17
|
+
* Plus the pin: when this CLI is newer than the version package.json pins (run through
|
|
18
|
+
* `npx -y --package=<the new tarball> homie-studio upgrade`), --apply pins this version (then `npm install`), and
|
|
19
|
+
* studio.json's `homie.studio` says the version the studio is on.
|
|
20
|
+
*
|
|
21
|
+
* Without --apply it changes nothing and says what --apply would do: the person agrees first. It never touches
|
|
22
|
+
* studio.json beyond `homie.studio`, the studio's look (site/theme.json), its Worker config (deploy's), its games,
|
|
23
|
+
* media or posts.
|
|
24
|
+
*/
|
|
25
|
+
import { createHash } from 'node:crypto';
|
|
26
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
27
|
+
import { spawnSync } from 'node:child_process';
|
|
28
|
+
import { dirname, join } from 'node:path';
|
|
29
|
+
import { studioFiles } from './scaffold.mjs';
|
|
30
|
+
import { readStudio } from './studio.mjs';
|
|
31
|
+
import { STUDIO_VERSION, packageSpec } from './version.mjs';
|
|
32
|
+
|
|
33
|
+
/** Whole files the template writes and a studio may keep as they are: updated only while the studio never changed them. */
|
|
34
|
+
export const TEMPLATE_FILES = ['AGENTS.md', 'CLAUDE.md', 'README.md', 'games/README.md', 'music/README.md', 'videos/README.md', 'posts/README.md', 'site/README.md', 'site/src/worker.mjs'];
|
|
35
|
+
/** Files the template adds when missing and never changes once they exist (a migration is applied; a manifest is the studio's). */
|
|
36
|
+
export const ADD_ONLY = ['music/manifest.json', 'videos/manifest.json', '.claude/skills/.gitkeep'];
|
|
37
|
+
const MIGRATIONS = /^site\/migrations\/[^/]+\.sql$/;
|
|
38
|
+
/** The name and slug stand-ins the history is fingerprinted with. */
|
|
39
|
+
export const STAND_IN = { name: '{{studio.name}}', slug: '{{studio.slug}}' };
|
|
40
|
+
const INTRO = '(intro)';
|
|
41
|
+
|
|
42
|
+
/** A text as the template compares it: line ends, trailing spaces and the studio's own name and slug made neutral. */
|
|
43
|
+
export function normalize(text, { name = null, slug = null } = {}) {
|
|
44
|
+
let t = String(text ?? '').replace(/\r\n?/g, '\n').split('\n').map((l) => l.replace(/[ \t]+$/, '')).join('\n').trim();
|
|
45
|
+
if (name && name !== STAND_IN.name) t = t.split(name).join(STAND_IN.name);
|
|
46
|
+
if (slug && slug !== STAND_IN.slug) t = t.split(slug).join(STAND_IN.slug);
|
|
47
|
+
return t;
|
|
48
|
+
}
|
|
49
|
+
export const fingerprint = (text, who = {}) => createHash('sha256').update(normalize(text, who)).digest('hex').slice(0, 16);
|
|
50
|
+
|
|
51
|
+
/** AGENTS.md as its sections: the intro (the title and the lines before the first `## `), then each `## ` heading's. */
|
|
52
|
+
export function sections(text) {
|
|
53
|
+
const out = [];
|
|
54
|
+
let cur = { heading: INTRO, lines: [] };
|
|
55
|
+
for (const line of String(text ?? '').replace(/\r\n?/g, '\n').split('\n')) {
|
|
56
|
+
if (/^## /.test(line)) { out.push(cur); cur = { heading: line.trim(), lines: [line] }; } else cur.lines.push(line);
|
|
57
|
+
}
|
|
58
|
+
out.push(cur);
|
|
59
|
+
return out.map((s) => ({ heading: s.heading, text: s.lines.join('\n') })).filter((s, i) => i > 0 || s.text.trim());
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** What the history keeps of one template: a fingerprint of every whole file and every AGENTS.md section. */
|
|
63
|
+
export function templatePrint(files) {
|
|
64
|
+
const who = STAND_IN;
|
|
65
|
+
return {
|
|
66
|
+
files: Object.fromEntries(TEMPLATE_FILES.filter((f) => f !== 'AGENTS.md' && files[f] !== undefined).map((f) => [f, fingerprint(files[f], who)])),
|
|
67
|
+
sections: { 'AGENTS.md': Object.fromEntries(sections(files['AGENTS.md']).map((s) => [s.heading, fingerprint(s.text, who)])) },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function readHistory() {
|
|
72
|
+
try { return JSON.parse(readFileSync(new URL('./template-history.json', import.meta.url), 'utf8')); } catch { return { v: 1, versions: {} }; }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Every fingerprint an older template wrote, per file and per AGENTS.md section. */
|
|
76
|
+
function known(history) {
|
|
77
|
+
const files = new Map();
|
|
78
|
+
const secs = new Map();
|
|
79
|
+
for (const v of Object.values(history.versions ?? {})) {
|
|
80
|
+
// A version may hold more than one hash for a file (two templates published under it): a list.
|
|
81
|
+
for (const [f, h] of Object.entries(v.files ?? {})) for (const x of [h].flat()) (files.get(f) ?? files.set(f, new Set()).get(f)).add(x);
|
|
82
|
+
for (const [heading, h] of Object.entries(v.sections?.['AGENTS.md'] ?? {})) for (const x of [h].flat()) (secs.get(heading) ?? secs.set(heading, new Set()).get(heading)).add(x);
|
|
83
|
+
}
|
|
84
|
+
return { files, secs };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const semver = (v) => (/^(\d+)\.(\d+)\.(\d+)$/.exec(String(v ?? '')) ?? []).slice(1).map(Number);
|
|
88
|
+
const newer = (a, b) => { const x = semver(a); const y = semver(b); for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0); return false; };
|
|
89
|
+
|
|
90
|
+
/** The @homie-rocks/studio version a package.json spec pins: a homie.rocks tarball, an exact version, or npm:… of one. */
|
|
91
|
+
export function pinnedVersion(spec) {
|
|
92
|
+
const s = String(spec ?? '');
|
|
93
|
+
return /homie-studio-(\d+\.\d+\.\d+)\.tgz(?:[?#].*)?$/.exec(s)?.[1] ?? /^(?:npm:@homie-rocks\/studio@)?=?(\d+\.\d+\.\d+)$/.exec(s)?.[1] ?? null;
|
|
94
|
+
}
|
|
95
|
+
/** The same kind of spec, for this version. */
|
|
96
|
+
function specFor(spec, directory) {
|
|
97
|
+
const s = String(spec ?? '');
|
|
98
|
+
const tar = /^(https?:\/\/.+\/)homie-studio-\d+\.\d+\.\d+\.tgz$/.exec(s);
|
|
99
|
+
if (tar) return `${tar[1]}homie-studio-${STUDIO_VERSION}.tgz`;
|
|
100
|
+
if (/^npm:@homie-rocks\/studio@/.test(s)) return `npm:@homie-rocks/studio@${STUDIO_VERSION}`;
|
|
101
|
+
if (/^=?\d+\.\d+\.\d+$/.test(s)) return STUDIO_VERSION;
|
|
102
|
+
return packageSpec(directory);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A line diff (the longest common run kept), for the plan: ` ` same, `- ` the studio's, `+ ` the template's. */
|
|
106
|
+
export function lineDiff(a, b, { context = 2, max = 60 } = {}) {
|
|
107
|
+
const x = String(a ?? '').split('\n');
|
|
108
|
+
const y = String(b ?? '').split('\n');
|
|
109
|
+
const n = x.length; const m = y.length;
|
|
110
|
+
const L = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1));
|
|
111
|
+
for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) L[i][j] = x[i] === y[j] ? L[i + 1][j + 1] + 1 : Math.max(L[i + 1][j], L[i][j + 1]);
|
|
112
|
+
const rows = [];
|
|
113
|
+
let i = 0; let j = 0;
|
|
114
|
+
while (i < n || j < m) {
|
|
115
|
+
if (i < n && j < m && x[i] === y[j]) { rows.push([' ', x[i]]); i++; j++; } else if (i < n && (j >= m || L[i + 1][j] >= L[i][j + 1])) { rows.push(['- ', x[i]]); i++; } else { rows.push(['+ ', y[j]]); j++; }
|
|
116
|
+
}
|
|
117
|
+
// Only the changed lines and a little context around them.
|
|
118
|
+
const keep = rows.map((r, k) => r[0] !== ' ' || rows.slice(Math.max(0, k - context), k + context + 1).some((q) => q[0] !== ' '));
|
|
119
|
+
const out = [];
|
|
120
|
+
let skipped = false;
|
|
121
|
+
rows.forEach((r, k) => { if (keep[k]) { out.push(`${r[0]}${r[1]}`); skipped = false; } else if (!skipped) { out.push(' …'); skipped = true; } });
|
|
122
|
+
return out.length > max ? [...out.slice(0, max), ` … (${out.length - max} more lines)`] : out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const read = (root, rel) => { try { return readFileSync(join(root, rel), 'utf8'); } catch { return null; } };
|
|
126
|
+
|
|
127
|
+
/** The plan: every change --apply would make, and everything it keeps as the studio wrote it. */
|
|
128
|
+
export function upgradePlan(root, { history = readHistory() } = {}) {
|
|
129
|
+
const studio = readStudio(root);
|
|
130
|
+
const name = String(studio.name ?? '').trim();
|
|
131
|
+
const slug = String(studio.slug ?? '').trim();
|
|
132
|
+
if (!name || !slug) return { ok: false, command: 'upgrade', why: 'studio.json has no name or slug; this does not look like a studio made with homie-studio new' };
|
|
133
|
+
const who = { name, slug };
|
|
134
|
+
const tmpl = studioFiles({ name, slug, homie: studio.homie?.directory ?? 'https://homie.rocks' });
|
|
135
|
+
const { files: oldFiles, secs: oldSecs } = known(history);
|
|
136
|
+
const changes = [];
|
|
137
|
+
const kept = [];
|
|
138
|
+
|
|
139
|
+
// package.json: the pin, and the scripts the template's studios have.
|
|
140
|
+
let pkg = null;
|
|
141
|
+
try { pkg = JSON.parse(read(root, 'package.json') ?? 'null'); } catch { pkg = null; }
|
|
142
|
+
const where = pkg?.devDependencies?.['@homie-rocks/studio'] !== undefined ? 'devDependencies' : pkg?.dependencies?.['@homie-rocks/studio'] !== undefined ? 'dependencies' : null;
|
|
143
|
+
const spec = where ? pkg[where]['@homie-rocks/studio'] : null;
|
|
144
|
+
const pinned = pinnedVersion(spec);
|
|
145
|
+
const from = pinned ?? studio.homie?.studio ?? null;
|
|
146
|
+
if (pinned && newer(pinned, STUDIO_VERSION)) {
|
|
147
|
+
return { ok: false, command: 'upgrade', from: pinned, to: STUDIO_VERSION, why: `this studio pins @homie-rocks/studio ${pinned}, newer than this ${STUDIO_VERSION}: run its own copy (npx --no-install homie-studio upgrade)` };
|
|
148
|
+
}
|
|
149
|
+
if (pkg && where && pinned && newer(STUDIO_VERSION, pinned)) {
|
|
150
|
+
changes.push({ file: 'package.json', kind: 'pin', what: `pin @homie-rocks/studio ${STUDIO_VERSION} (was ${pinned}); then npm install`, from: spec, to: specFor(spec, studio.homie?.directory) });
|
|
151
|
+
} else if (pkg && where && !pinned) {
|
|
152
|
+
kept.push({ file: 'package.json', what: `@homie-rocks/studio is "${String(spec).slice(0, 80)}" (not a published version): left as it is` });
|
|
153
|
+
}
|
|
154
|
+
const wantScripts = JSON.parse(tmpl['package.json']).scripts ?? {};
|
|
155
|
+
const missingScripts = pkg ? Object.fromEntries(Object.entries(wantScripts).filter(([k]) => pkg.scripts?.[k] === undefined)) : {};
|
|
156
|
+
if (Object.keys(missingScripts).length) changes.push({ file: 'package.json', kind: 'scripts', what: `add scripts: ${Object.keys(missingScripts).join(', ')}`, scripts: missingScripts });
|
|
157
|
+
if (studio.homie?.studio !== STUDIO_VERSION) changes.push({ file: 'studio.json', kind: 'version', what: `homie.studio ${studio.homie?.studio ?? '(none)'} → ${STUDIO_VERSION}`, to: STUDIO_VERSION });
|
|
158
|
+
|
|
159
|
+
// AGENTS.md, section by section; the other template files whole.
|
|
160
|
+
for (const rel of TEMPLATE_FILES) {
|
|
161
|
+
const mine = read(root, rel);
|
|
162
|
+
const want = tmpl[rel];
|
|
163
|
+
if (want === undefined) continue;
|
|
164
|
+
if (mine === null) { changes.push({ file: rel, kind: 'add-file', what: 'new file', text: want }); continue; }
|
|
165
|
+
if (normalize(mine, who) === normalize(want, who)) continue;
|
|
166
|
+
if (rel !== 'AGENTS.md') {
|
|
167
|
+
if (oldFiles.get(rel)?.has(fingerprint(mine, who))) changes.push({ file: rel, kind: 'update-file', what: 'the template\'s newer text (the studio never changed it)', from: mine, text: want });
|
|
168
|
+
else kept.push({ file: rel, what: rel === 'site/src/worker.mjs' ? 'the studio\'s own Worker' : 'changed by the studio', mine, template: want });
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const have = sections(mine);
|
|
172
|
+
const tsecs = sections(want);
|
|
173
|
+
for (const [k, t] of tsecs.entries()) {
|
|
174
|
+
const at = have.find((s) => s.heading === t.heading);
|
|
175
|
+
if (!at) {
|
|
176
|
+
// After the nearest template section before it that the studio has; else before the first one after it.
|
|
177
|
+
const before = tsecs.slice(0, k).reverse().find((p) => have.some((s) => s.heading === p.heading));
|
|
178
|
+
const after = before ? null : tsecs.slice(k + 1).find((p) => have.some((s) => s.heading === p.heading));
|
|
179
|
+
changes.push({ file: rel, kind: 'add-section', section: t.heading, what: `new section ${t.heading === INTRO ? 'intro' : `"${t.heading}"`}`, after: before?.heading ?? null, before: after?.heading ?? null, text: t.text });
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (normalize(at.text, who) === normalize(t.text, who)) continue;
|
|
183
|
+
if (oldSecs.get(t.heading)?.has(fingerprint(at.text, who))) changes.push({ file: rel, kind: 'update-section', section: t.heading, what: `section "${t.heading}": the template's newer text (the studio never changed it)`, from: at.text, text: t.text });
|
|
184
|
+
else kept.push({ file: rel, section: t.heading, what: `section "${t.heading}": changed by the studio`, mine: at.text, template: t.text });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Files added only when missing: the empty manifests, the skills folder, and any D1 migration the template has.
|
|
189
|
+
for (const rel of [...ADD_ONLY, ...Object.keys(tmpl).filter((f) => MIGRATIONS.test(f))]) {
|
|
190
|
+
if (tmpl[rel] !== undefined && read(root, rel) === null) changes.push({ file: rel, kind: 'add-file', what: MIGRATIONS.test(rel) ? 'D1 migration (the next deploy applies it)' : 'new file', text: tmpl[rel] });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// .gitignore: every pattern the template ignores and the studio does not, with the comment lines above it.
|
|
194
|
+
const gi = read(root, '.gitignore');
|
|
195
|
+
const bare = (l) => l.trim().replace(/^\//, '').replace(/\/$/, '');
|
|
196
|
+
const have = new Set((gi ?? '').split('\n').map(bare).filter((l) => l && !l.startsWith('#')));
|
|
197
|
+
const add = [];
|
|
198
|
+
let notes = [];
|
|
199
|
+
for (const line of tmpl['.gitignore'].split('\n')) {
|
|
200
|
+
if (!line.trim()) { notes = []; continue; }
|
|
201
|
+
if (line.trim().startsWith('#')) { notes.push(line); continue; }
|
|
202
|
+
// A pattern's comment lines are the ones right above it (they go with the first missing pattern below them).
|
|
203
|
+
if (!have.has(bare(line))) add.push(...notes, line);
|
|
204
|
+
notes = [];
|
|
205
|
+
}
|
|
206
|
+
if (add.length) changes.push({ file: '.gitignore', kind: 'add-lines', what: `${add.filter((l) => !l.startsWith('#')).length} line(s): ${add.filter((l) => !l.startsWith('#')).join(' ')}`, lines: add });
|
|
207
|
+
|
|
208
|
+
const git = spawnSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' });
|
|
209
|
+
const dirty = git.status === 0 ? git.stdout.split('\n').filter(Boolean).length : null;
|
|
210
|
+
return {
|
|
211
|
+
ok: true, command: 'upgrade', applied: false, root, studio: name, from, to: STUDIO_VERSION,
|
|
212
|
+
changes, kept,
|
|
213
|
+
...(dirty ? { uncommitted: dirty } : {}),
|
|
214
|
+
next: changes.length ? ['npx --no-install homie-studio upgrade --apply (after the person agrees)'] : [],
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function writeAtomic(path, text) {
|
|
219
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
220
|
+
const tmp = `${path}.upgrade-${process.pid}`;
|
|
221
|
+
writeFileSync(tmp, text);
|
|
222
|
+
renameSync(tmp, path);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Apply a plan made a moment ago on the same files (each change checks that what it replaces is still there). */
|
|
226
|
+
export function upgradeApply(root, plan) {
|
|
227
|
+
if (!plan?.ok) return plan;
|
|
228
|
+
const done = [];
|
|
229
|
+
const skipped = [];
|
|
230
|
+
const byFile = new Map();
|
|
231
|
+
for (const c of plan.changes) (byFile.get(c.file) ?? byFile.set(c.file, []).get(c.file)).push(c);
|
|
232
|
+
for (const [rel, list] of byFile) {
|
|
233
|
+
const path = join(root, rel);
|
|
234
|
+
let text = existsSync(path) ? readFileSync(path, 'utf8') : null;
|
|
235
|
+
for (const c of list) {
|
|
236
|
+
if (c.kind === 'add-file') { if (text === null) { text = c.text; done.push(c); } else skipped.push({ ...c, why: 'it exists now' }); continue; }
|
|
237
|
+
if (c.kind === 'update-file') { if (text === c.from) { text = c.text; done.push(c); } else skipped.push({ ...c, why: 'it changed since the plan' }); continue; }
|
|
238
|
+
if (c.kind === 'add-lines') { text = `${text ?? ''}${text && !text.endsWith('\n') ? '\n' : ''}${text ? '\n' : ''}${c.lines.join('\n')}\n`; done.push(c); continue; }
|
|
239
|
+
if (c.kind === 'update-section' || c.kind === 'add-section') {
|
|
240
|
+
// Sections joined by line ends are the file byte for byte: only the section that changes changes.
|
|
241
|
+
const secs = sections(text ?? '');
|
|
242
|
+
if (c.kind === 'update-section') {
|
|
243
|
+
const at = secs.find((s) => s.heading === c.section);
|
|
244
|
+
if (!at || at.text !== c.from) { skipped.push({ ...c, why: 'the section changed since the plan' }); continue; }
|
|
245
|
+
at.text = `${c.text.replace(/\n+$/, '')}${/\n*$/.exec(at.text)[0]}`;
|
|
246
|
+
} else {
|
|
247
|
+
if (secs.some((s) => s.heading === c.section)) { skipped.push({ ...c, why: 'it exists now' }); continue; }
|
|
248
|
+
// A new section ends with a blank line (before the next heading), and the one above it ends its last line.
|
|
249
|
+
const item = { heading: c.section, text: `${c.text.replace(/\n+$/, '')}\n` };
|
|
250
|
+
const i = c.after ? secs.findIndex((s) => s.heading === c.after) : -1;
|
|
251
|
+
const j = !c.after && c.before ? secs.findIndex((s) => s.heading === c.before) : -1;
|
|
252
|
+
const at = i >= 0 ? i + 1 : j >= 0 ? j : secs.length;
|
|
253
|
+
secs.splice(at, 0, item);
|
|
254
|
+
if (at > 0 && !secs[at - 1].text.endsWith('\n')) secs[at - 1].text += '\n';
|
|
255
|
+
}
|
|
256
|
+
text = secs.map((s) => s.text).join('\n');
|
|
257
|
+
done.push(c);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (c.kind === 'pin' || c.kind === 'scripts') {
|
|
261
|
+
const pkg = JSON.parse(text);
|
|
262
|
+
if (c.kind === 'pin') {
|
|
263
|
+
const where = pkg.devDependencies?.['@homie-rocks/studio'] !== undefined ? 'devDependencies' : 'dependencies';
|
|
264
|
+
if (pkg[where]?.['@homie-rocks/studio'] !== c.from) { skipped.push({ ...c, why: 'the pin changed since the plan' }); continue; }
|
|
265
|
+
pkg[where]['@homie-rocks/studio'] = c.to;
|
|
266
|
+
} else pkg.scripts = { ...(pkg.scripts ?? {}), ...c.scripts };
|
|
267
|
+
text = `${JSON.stringify(pkg, null, 2)}\n`;
|
|
268
|
+
done.push(c);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (c.kind === 'version') {
|
|
272
|
+
const s = JSON.parse(text);
|
|
273
|
+
s.homie = { ...(s.homie ?? {}), studio: c.to };
|
|
274
|
+
text = `${JSON.stringify(s, null, 2)}\n`;
|
|
275
|
+
done.push(c);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (text !== null && (!existsSync(path) || readFileSync(path, 'utf8') !== text)) writeAtomic(path, text);
|
|
279
|
+
}
|
|
280
|
+
const pinned = done.some((c) => c.kind === 'pin');
|
|
281
|
+
return {
|
|
282
|
+
...plan, applied: true, done, skipped,
|
|
283
|
+
next: [...(pinned ? [`npm install (fetches @homie-rocks/studio ${STUDIO_VERSION})`] : []), 'npm run build, then look at the site (npm run dev) before the next deploy', 'git diff, then commit the upgrade on its own'],
|
|
284
|
+
};
|
|
285
|
+
}
|
package/lib/version.mjs
CHANGED
|
@@ -8,10 +8,14 @@ import { readFileSync } from 'node:fs';
|
|
|
8
8
|
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
9
9
|
export const STUDIO_VERSION = pkg.version;
|
|
10
10
|
|
|
11
|
-
/** The pinned tarball of this version, on a Homie directory (homie.rocks by default). */
|
|
11
|
+
/** The pinned tarball of this version, on a Homie directory (homie.rocks by default). Kept for older studios. */
|
|
12
12
|
export function tarballUrl(base = 'https://homie.rocks', version = STUDIO_VERSION) {
|
|
13
13
|
return `${String(base).replace(/\/+$/, '')}/npm/homie-studio-${version}.tgz`;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
/**
|
|
17
|
-
|
|
16
|
+
/**
|
|
17
|
+
* What a studio's package.json names: this exact version, from registry.npmjs.org (package-lock.json keeps its
|
|
18
|
+
* integrity). The public registry is what Cloudflare's Workers Builds and a Claude Code cloud session reach with
|
|
19
|
+
* their default network settings; a directory's /npm/ tarball is not.
|
|
20
|
+
*/
|
|
21
|
+
export function packageSpec() { return STUDIO_VERSION; }
|
package/media/MEDIA.md
CHANGED
|
@@ -60,6 +60,19 @@ show and why anything is left out.
|
|
|
60
60
|
- `rights` and `credits` are shown on the page, in plain words. Say what the provider's terms
|
|
61
61
|
say for the plan the file was made on; nothing on the site claims more.
|
|
62
62
|
|
|
63
|
+
## Covers and posters
|
|
64
|
+
|
|
65
|
+
Every song's page, its card and the directory's manifest show a cover (a video: a poster), the first of:
|
|
66
|
+
|
|
67
|
+
1. its own `cover` file (a video's `poster`);
|
|
68
|
+
2. the manifest's own, for every entry without one: `"cover": "music/night-route/cover.jpg"` at the top of
|
|
69
|
+
`music/manifest.json` (an album's cover; `"poster"` in `videos/manifest.json`), a path in the studio or an
|
|
70
|
+
`https://` address, served like any file;
|
|
71
|
+
3. the landing still of the game the entry was made for (`for.game`);
|
|
72
|
+
4. the studio's share picture (`site/theme.json` `social`).
|
|
73
|
+
|
|
74
|
+
homie.rocks shows a cover only from the studio's own site, as a `.jpg`, `.png`, `.webp`, `.avif` or `.gif`.
|
|
75
|
+
|
|
63
76
|
## Where the bytes come from
|
|
64
77
|
|
|
65
78
|
A studio needs no storage for songs and videos: the site serves them itself. For every public
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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, optional R2 storage for large media, deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
package/site/SITE.md
CHANGED
|
@@ -11,19 +11,34 @@ and every game gets a landing page. Anything the studio puts in `site/` wins.
|
|
|
11
11
|
| `/` | Home: the featured game as a full-bleed hero with Play, the live rooms, the games, the latest posts, videos and music | always |
|
|
12
12
|
| `/games/` | Every game, with its cover, pitch, players playing now, Play and About | the studio has a game |
|
|
13
13
|
| `/music/`, `/videos/` | Songs and videos (`media/MEDIA.md`) | the studio has one published |
|
|
14
|
-
| `/rooms/` | Every public room playing now across the studio's games, each with Join (`/api/rooms` is the same as JSON) | the studio has a game |
|
|
14
|
+
| `/rooms/` | Every public room playing now across the studio's games, each with Join (`/api/rooms` is the same as JSON, cacheable for 15 s) | the studio has a game |
|
|
15
15
|
| `/posts/` | The studio's posts, newest first, with `/posts/feed.xml` (Atom) and `/posts/feed.json` (JSON Feed) | the studio has a post |
|
|
16
16
|
|
|
17
17
|
A section with nothing in it has no tab, and its address answers 404. Every page ends with a
|
|
18
18
|
"Made with Homie" link to https://homie.rocks/studio/. studio.json may add `"tagline"` (one line, on Home and in
|
|
19
19
|
the feeds) and `"site": { "featured": "<game id>" }` (the game on Home; otherwise the first with its own footage).
|
|
20
20
|
|
|
21
|
+
Every card (Home, Games, Rooms, a post's), every live room's row and the directory's manifest show a game's
|
|
22
|
+
**landing still**: `hero/wide.jpg` (or `landing.hero.image`), else a trailer's poster, else game.json's `cover`.
|
|
23
|
+
|
|
24
|
+
## What the homie.rocks hub reads
|
|
25
|
+
|
|
26
|
+
`/.well-known/homie-studio.json` is the studio's manifest: its games (each with its landing still as `cover`),
|
|
27
|
+
songs (each with a `cover`), videos (each with a `poster`), the latest posts, and `rooms`, the address of
|
|
28
|
+
`/api/rooms`, so the hub's Rooms page lists this studio's public rooms. With studio.json `"stats": { "share": true }`
|
|
29
|
+
(`homie-studio stats share on`) it also says "played this week": `played` for the whole studio and each game's own
|
|
30
|
+
(`games[].played`, `{ "days": 7, "plays", "rounds" }`). To keep the studio's rooms off the hub, studio.json takes
|
|
31
|
+
`"rooms": { "share": false }`: the manifest then names no `rooms` (the studio's own pages still list them).
|
|
32
|
+
|
|
21
33
|
## A game's landing: `/<id>/`
|
|
22
34
|
|
|
23
35
|
Made from the game's own files, nothing invented:
|
|
24
36
|
|
|
25
37
|
- **The hero**, full-bleed: the game's footage when it has some, else its cover (or key art) moving slowly, tinted
|
|
26
|
-
with the studio's colour so the words read.
|
|
38
|
+
with the studio's colour so the words read. A game whose picture is white or cream (a light arena) takes
|
|
39
|
+
`"scheme": "light"` in its `landing` block: its landing is drawn light, the hero tinted and shaded with a light
|
|
40
|
+
background and the words dark, where a dark studio's tint would turn it grey (`"dark"` is the other way round,
|
|
41
|
+
for a dark game in a light studio). `landing.theme` colours still win, and the rest of the site keeps its look. Footage is looked for in this order: `landing.hero` in game.json;
|
|
27
42
|
`games/<id>/hero/` by the house brands' names (`wide.mp4` and `tall.mp4`, their AV1 cuts `wide.av1.mp4` and
|
|
28
43
|
`tall.av1.mp4`, stills `wide.jpg` and `tall.jpg`); a trailer in `videos/manifest.json` whose `for.game` is the
|
|
29
44
|
game (its 16:9 `video`, 9:16 `vertical` and `poster`). A loop of 8 to 15 s under 3 MB each, muted, plays best.
|
|
@@ -55,6 +70,7 @@ game.json's `landing` block, every key optional:
|
|
|
55
70
|
"alt": "What the footage shows", "focus": "50% 40%", "tint": 30 },
|
|
56
71
|
"credits": [{ "role": "Music", "name": "Low Tide", "url": "https://example.com" }],
|
|
57
72
|
"theme": { "accent": "#ff3bd4", "glow": "#00eaff" },
|
|
73
|
+
"scheme": "light",
|
|
58
74
|
"tv": true
|
|
59
75
|
}
|
|
60
76
|
```
|
|
@@ -108,16 +124,45 @@ A studio with its own landing in `site/` keeps it: the template never replaces w
|
|
|
108
124
|
## The play page
|
|
109
125
|
|
|
110
126
|
`/<id>/play` puts the visitor in a public room at once and writes the room into the address, so a reload comes
|
|
111
|
-
back to it and a copied address brings a friend into it. A small room button at the edge (
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
127
|
+
back to it and a copied address brings a friend into it. A small room button at the edge (top right by default)
|
|
128
|
+
opens: **Invite** (the phone's share sheet, or the link copied), **Big screen** (`/<id>/tv` of this room, on
|
|
129
|
+
another screen) and the room's code. It must never cover the game's own HUD, so game.json's `screen.share` puts it
|
|
130
|
+
where the game has room, per device:
|
|
131
|
+
|
|
132
|
+
```json
|
|
133
|
+
"screen": {
|
|
134
|
+
"share": {
|
|
135
|
+
"desk": "bottom-left",
|
|
136
|
+
"phone": { "at": "top-left", "y": 56 },
|
|
137
|
+
"sideways": { "at": "top-center", "label": false }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
- A place is `top-left`, `top-center`, `top-right`, `bottom-left` or `bottom-right`; a plain string (`"share":
|
|
143
|
+
"top-center"`) or an object with `at` is every device, and `desk` (a computer), `phone` (held upright) and
|
|
144
|
+
`sideways` (a phone turned sideways; else as `phone`) each take their own.
|
|
145
|
+
- `x` and `y` move it in from its side and its edge, in CSS pixels (0 to 600): `"y": 56` puts it under a game's
|
|
146
|
+
own top line. At `top-center`, `x` moves it right (or left, negative).
|
|
147
|
+
- It shows the room's name ("Room 7") for a few seconds, then shrinks to a round icon until it is touched;
|
|
148
|
+
`"label": false` keeps it the icon always (the code is in its sheet), for a corner with little room.
|
|
149
|
+
- A button at the bottom left sends the "finding a room" chip to the bottom right.
|
|
150
|
+
|
|
151
|
+
Look at the play page on a computer and a phone (both ways up) once a round is on: the button must not sit on a
|
|
152
|
+
score, a timer or a bar. A room code the relay cannot use (1 to 32 letters, digits, `-` or `_`) is refused
|
|
115
153
|
on the page, never swapped for a public room. A player who typed no name gets a two-word handle.
|
|
116
154
|
|
|
117
155
|
The game runs in a sandboxed frame with an opaque origin, which is why its allow list delegates with `*`
|
|
118
156
|
(`fullscreen *; autoplay *; gamepad *`): Safari refuses a bare `gamepad` there (`getGamepads()` throws a
|
|
119
157
|
SecurityError), so a controller would not work.
|
|
120
158
|
|
|
159
|
+
## The arcade knock
|
|
160
|
+
|
|
161
|
+
A game made with Homie's arcade controls asks its page's origin for a Homie box (`GET /__homie/call`), and a score
|
|
162
|
+
goes there as a POST. The site answers at its root and under every game with `{ "ok": false, "error":
|
|
163
|
+
"not-a-homie" }` (JSON, CORS-open, 200, a preflight answered), so the game stops asking and plays on its own; no
|
|
164
|
+
studio needs a Worker of its own for it.
|
|
165
|
+
|
|
121
166
|
## Headers
|
|
122
167
|
|
|
123
168
|
Every HTML answer is `no-transform` (a custom domain's edge injects nothing: Web Analytics' automatic beacon
|