@homie-rocks/studio 0.11.0 → 0.12.1
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 +51 -6
- package/bin/homie-studio.mjs +25 -4
- package/lib/build.mjs +3 -0
- package/lib/cloudflare.mjs +19 -7
- package/lib/codex.mjs +1 -0
- package/lib/directory.mjs +5 -4
- package/lib/doctor.mjs +11 -5
- package/lib/net.mjs +137 -0
- package/lib/players.mjs +38 -0
- package/lib/progress.mjs +39 -43
- package/lib/repo.mjs +51 -0
- package/lib/scaffold.mjs +31 -1
- package/lib/setup.mjs +15 -23
- package/lib/site.mjs +2 -2
- package/lib/stats.mjs +3 -3
- package/lib/studio.mjs +1 -1
- package/lib/template-history.json +48 -0
- package/netplay/NETPLAY.md +5 -0
- package/package.json +6 -1
- package/port/index.ts +2 -0
- package/port/keys.ts +3 -0
- package/saves/SAVES.md +236 -0
- package/saves/saves.ts +326 -0
- package/site/SITE.md +9 -0
- package/starters/ember-vale/game.json +20 -0
- package/starters/ember-vale/index.html +46 -0
- package/starters/ember-vale/src/main.ts +429 -0
- package/worker/account-page.mjs +350 -0
- package/worker/index.mjs +36 -8
- package/worker/pages.mjs +10 -3
- package/worker/players.mjs +817 -0
- package/worker/saves-shell.mjs +388 -0
- package/worker/saves.mjs +287 -0
- package/worker/site.mjs +1 -1
- package/worker/stats-page.mjs +1 -0
- package/worker/stats.mjs +16 -1
- package/worker/version.mjs +1 -1
- package/worker/webauthn.mjs +259 -0
package/lib/progress.mjs
CHANGED
|
@@ -57,6 +57,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, sta
|
|
|
57
57
|
import { extname, join } from 'node:path';
|
|
58
58
|
import { refreshCodexFile } from './codex.mjs';
|
|
59
59
|
import { readStudio, siteUrl } from './studio.mjs';
|
|
60
|
+
import { request, whyRefused } from './net.mjs';
|
|
60
61
|
|
|
61
62
|
export const PROGRESS_KIND = 'homie-studio-progress';
|
|
62
63
|
export const PROGRESS_DIR = join('.studio', 'progress');
|
|
@@ -89,18 +90,10 @@ const keyPath = (root, id) => join(dir(root), `${id}.key`);
|
|
|
89
90
|
const currentPath = (root) => join(dir(root), 'current');
|
|
90
91
|
const loopback = (url) => { try { return ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(url).hostname); } catch { return false; } };
|
|
91
92
|
|
|
92
|
-
|
|
93
|
-
* A CLAUDE CODE CLOUD SESSION ON THE DEFAULT NETWORK. Its "Trusted" network reaches package registries and GitHub,
|
|
94
|
-
* not homie.rocks: the session's proxy answers 403 with `x-deny-reason: host_not_allowed`. Say which setting lets
|
|
95
|
-
* the card follow the build, instead of "did not answer"; the build itself goes on with its local feed.
|
|
96
|
-
*/
|
|
93
|
+
/** Kept for callers of 0.11.0: whether an answer is the proxy refusing the host (lib/net.mjs says the rest). */
|
|
97
94
|
export function networkWhy(res, at) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
try { host = new URL(at).host; } catch { /* as given */ }
|
|
101
|
-
return `this Claude Code cloud environment's network does not reach ${host}. In claude.ai/code, open the environment's settings, set Network access to Custom, add ${host} (keep "Also include default list of common package managers"), and start a new session; until then the build goes on with its local feed only`;
|
|
102
|
-
}
|
|
103
|
-
return null;
|
|
95
|
+
const r = whyRefused(res, null, '', at);
|
|
96
|
+
return r.needs === 'network' ? r.why : null;
|
|
104
97
|
}
|
|
105
98
|
|
|
106
99
|
/** The studio's live address for the card: an https origin (or this computer's own dev site), else null. */
|
|
@@ -153,23 +146,24 @@ async function pushDoc(root, id) {
|
|
|
153
146
|
let key = null;
|
|
154
147
|
try { key = readFileSync(keyPath(root, id), 'utf8').trim(); } catch { return null; }
|
|
155
148
|
const { shared, ...body } = doc;
|
|
156
|
-
const
|
|
149
|
+
const sent = await request(`${shared.directory}/api/studio/progress/${shared.build}`, {
|
|
157
150
|
method: 'PUT',
|
|
158
151
|
headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json', 'user-agent': 'homie-studio' },
|
|
159
152
|
body: JSON.stringify({ ...body, build: shared.build }),
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
//
|
|
169
|
-
mutateDoc(root, id, (d) => { d.shared = { ...d.shared,
|
|
153
|
+
}, { timeout: 8000 });
|
|
154
|
+
if (!sent.ok) {
|
|
155
|
+
if (sent.status === 404 || sent.status === 410 || sent.needs === 'network') {
|
|
156
|
+
// The shared copy ended (24 hours, or the directory dropped it), or the proxy refuses the directory: keep the
|
|
157
|
+
// local feed, stop sending, and say why in its log.
|
|
158
|
+
mutateDoc(root, id, (d) => { d.shared = { ...d.shared, ended: true }; pushLog(d, `The Claude app's card stopped following this build: ${sent.why}`); });
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
// Anything else (a slow answer, a refused write): the next write tries again; the log keeps what happened once.
|
|
162
|
+
mutateDoc(root, id, (d) => { if (d.shared && d.shared.lastError !== sent.why) { d.shared = { ...d.shared, lastError: sent.why }; pushLog(d, `Sending to the card failed: ${sent.why}`); } });
|
|
170
163
|
return null;
|
|
171
164
|
}
|
|
172
|
-
const reply =
|
|
165
|
+
const reply = sent.body;
|
|
166
|
+
if (readFeed(root, id)?.shared?.lastError) mutateDoc(root, id, (d) => { delete d.shared.lastError; });
|
|
173
167
|
if (reply?.stop) {
|
|
174
168
|
mutateDoc(root, id, (d) => {
|
|
175
169
|
if (!d.stop?.requested) { d.stop = { requested: true, at: now(), by: 'person' }; pushLog(d, 'Stop pressed in the Claude app; stopping at the next safe point.'); }
|
|
@@ -467,36 +461,38 @@ export async function startProgress(root, { what = 'game', id, title, budget, un
|
|
|
467
461
|
writeAtomic(currentPath(root), `${build}\n`);
|
|
468
462
|
let shared = null;
|
|
469
463
|
let why = null;
|
|
464
|
+
let failure = {};
|
|
470
465
|
if (share) {
|
|
471
466
|
const at = String(directory || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
|
|
472
467
|
if (!/^https:\/\//.test(at) && !(/^http:\/\//.test(at) && loopback(at))) why = `the directory ${at} is not https`;
|
|
473
468
|
else {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
469
|
+
// A build the chat opened is attached (once); otherwise a new one is opened here.
|
|
470
|
+
const sent = await request(attach ? `${at}/api/studio/progress/${attach}/attach` : `${at}/api/studio/progress`, {
|
|
471
|
+
method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': 'homie-studio' },
|
|
472
|
+
body: JSON.stringify({ what, id: id ?? null, title: doc.title, studio: doc.studio }),
|
|
473
|
+
});
|
|
474
|
+
const body = sent.body;
|
|
475
|
+
if (sent.ok && REMOTE_ID.test(String(body?.build)) && /^hbk_[a-f0-9]{48}$/.test(String(body?.key))) {
|
|
476
|
+
// The chat named the build: its title is the one the person saw.
|
|
477
|
+
if (attach && body.title && !title) mutateDoc(root, build, (d) => { d.title = plain(body.title, LIMITS.title); });
|
|
478
|
+
writeFileSync(keyPath(root, build), `${body.key}\n`, { mode: 0o600 });
|
|
479
|
+
try { chmodSync(keyPath(root, build), 0o600); } catch { /* not on this filesystem */ }
|
|
480
|
+
shared = { build: body.build, directory: at, expiresAt: body.expiresAt ?? null };
|
|
481
|
+
mutateDoc(root, build, (d) => { d.shared = { build: body.build, directory: at }; });
|
|
482
|
+
schedule(root, build, { soon: true });
|
|
483
|
+
heartbeat(root, build, true);
|
|
484
|
+
} else {
|
|
485
|
+
// As it is: the directory's own answer (its status and message) or the connection's error, never a guess.
|
|
486
|
+
why = sent.ok ? `${at} answered without a build and key` : sent.why;
|
|
487
|
+
failure = { ...(sent.status ? { status: sent.status } : {}), ...(sent.code ? { code: sent.code } : {}), ...(sent.needs ? { needs: sent.needs } : {}) };
|
|
488
|
+
}
|
|
493
489
|
}
|
|
494
490
|
}
|
|
495
491
|
if (attach && !shared) {
|
|
496
492
|
// Attaching is the point of `progress attach`: without it, say why and leave no open feed behind.
|
|
497
493
|
rmSync(docPath(root, build), { force: true });
|
|
498
494
|
rmSync(currentPath(root), { force: true });
|
|
499
|
-
return { ok: false, command: 'progress attach', build: attach, why: `could not attach to ${attach}: ${why}` };
|
|
495
|
+
return { ok: false, command: 'progress attach', build: attach, ...failure, why: `could not attach to ${attach}: ${why}` };
|
|
500
496
|
}
|
|
501
497
|
return {
|
|
502
498
|
ok: true, command: attach ? 'progress attach' : 'progress start', build, file: join(PROGRESS_DIR, `${build}.json`), what, id: id ?? null, title: readFeed(root, build)?.title ?? doc.title,
|
package/lib/repo.mjs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHICH GITHUB REPOSITORY THIS STUDIO IS. The Claude app's hand-off opens Claude Code on it, so it must be the
|
|
3
|
+
* studio's own: never the engine and template repository (homie-rocks/homie) that Cloudflare's "Deploy to
|
|
4
|
+
* Cloudflare" copied the studio from, and never a guess.
|
|
5
|
+
*
|
|
6
|
+
* In order:
|
|
7
|
+
* 1. HOMIE_REPO, a build variable naming it;
|
|
8
|
+
* 2. studio.json `github` (`setup attach` writes it, and the person may);
|
|
9
|
+
* 3. the checkout's git remotes, origin first. A Workers Builds checkout, a Claude Code cloud session (its remote
|
|
10
|
+
* goes through the session's own git proxy) and a person's clone all carry one. Only owner/name is kept,
|
|
11
|
+
* never a token in the URL.
|
|
12
|
+
*
|
|
13
|
+
* A deploy hands it to the live Worker as a variable (HOMIE_REPO), never into a public file. The Worker tells its
|
|
14
|
+
* directory alongside its claim, so the setup card can open the right repository.
|
|
15
|
+
*/
|
|
16
|
+
import { spawnSync } from 'node:child_process';
|
|
17
|
+
import { readStudio } from './studio.mjs';
|
|
18
|
+
|
|
19
|
+
export const REPO = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]{1,100}$/;
|
|
20
|
+
/** The engine and template repository: a studio is never this. */
|
|
21
|
+
export const ENGINE_REPO = 'homie-rocks/homie';
|
|
22
|
+
|
|
23
|
+
/** owner/name in a git remote URL (https, ssh, a proxy's /git/owner/name path), without any credentials; or null. */
|
|
24
|
+
export function repoFromUrl(url) {
|
|
25
|
+
const s = String(url ?? '').trim().replace(/\.git$/, '').replace(/\/+$/, '');
|
|
26
|
+
if (!s) return null;
|
|
27
|
+
const m = /[/:]([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9._-]{1,100})$/.exec(s);
|
|
28
|
+
return m ? studioRepo(`${m[1]}/${m[2]}`) : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A studio repository slug, or null (not a slug, or the engine repository). */
|
|
32
|
+
export function studioRepo(value) {
|
|
33
|
+
const r = String(value ?? '').trim();
|
|
34
|
+
return REPO.test(r) && r.toLowerCase() !== ENGINE_REPO ? r : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** This studio's repository: HOMIE_REPO, studio.json `github`, else its git remotes (origin first). */
|
|
38
|
+
export function repoOf(root, { env = process.env } = {}) {
|
|
39
|
+
const named = studioRepo(env.HOMIE_REPO);
|
|
40
|
+
if (named) return named;
|
|
41
|
+
try { const s = studioRepo(readStudio(root).github); if (s) return s; } catch { /* no studio.json */ }
|
|
42
|
+
const git = (args) => spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 5000 });
|
|
43
|
+
const origin = git(['remote', 'get-url', 'origin']);
|
|
44
|
+
if (origin.status === 0) { const r = repoFromUrl(origin.stdout); if (r) return r; }
|
|
45
|
+
const all = git(['config', '--get-regexp', '^remote\\..*\\.url$']);
|
|
46
|
+
for (const line of (all.status === 0 ? all.stdout : '').split('\n')) {
|
|
47
|
+
const r = repoFromUrl(line.split(/\s+/)[1]);
|
|
48
|
+
if (r) return r;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
package/lib/scaffold.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import { homedir } from 'node:os';
|
|
|
24
24
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
25
25
|
import { STUDIO_VERSION, packageSpec } from './version.mjs';
|
|
26
26
|
import { STATS_MIGRATION, STATS_MIGRATION_FILE } from '../worker/stats.mjs';
|
|
27
|
+
import { PLAYERS_MIGRATION, PLAYERS_MIGRATION_FILE } from '../worker/players.mjs';
|
|
27
28
|
import { themeFile } from './site.mjs';
|
|
28
29
|
import { PACKAGE_ROOT } from './studio.mjs';
|
|
29
30
|
|
|
@@ -149,6 +150,10 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
149
150
|
\`studio_stats\` (never paste a key anywhere else). \`stats share on\` tells the directory two numbers
|
|
150
151
|
(played this week). The site counts and never tracks: no cookie on a visitor, no person identified,
|
|
151
152
|
nothing sent anywhere; house QA and \`check\` runs are not counted.
|
|
153
|
+
- \`npx --no-install homie-studio players\` — how many players have accounts here (and guests, and who played
|
|
154
|
+
this week): counts and names for the owner, never a passkey or an email. \`players owner\` gives the owner a
|
|
155
|
+
one-time link that marks their own player account (a passkey on this site) as the owner's, so their games and
|
|
156
|
+
the studio's back office recognise them.
|
|
152
157
|
- \`npx --no-install homie-studio upgrade\` — after pinning a newer \`@homie-rocks/studio\` (or through
|
|
153
158
|
\`npx -y --package=<its tarball> homie-studio upgrade\`): what the newer template adds to this studio (AGENTS.md
|
|
154
159
|
sections, READMEs, .gitignore lines) and what it keeps. It changes nothing until \`--apply\`, and never
|
|
@@ -191,6 +196,13 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
191
196
|
a small icon (\`node_modules/@homie-rocks/studio/site/SITE.md\`). Look at it on a phone and a computer.
|
|
192
197
|
- Change a game in small steps, build, and look at it (\`dev\`, then \`check\`).
|
|
193
198
|
- A game's id is its URL (\`/<id>/\`); keep it once published.
|
|
199
|
+
- **Progress that lasts** (a character, unlocks, a collection, days of play) goes in **saves**, never in the room:
|
|
200
|
+
a room forgets everything 60 s after its last player leaves. game.json \`"saves": true\` and
|
|
201
|
+
\`createSaves\` from \`@homie-rocks/studio/saves\` (\`node_modules/@homie-rocks/studio/saves/SAVES.md\`): per
|
|
202
|
+
player and game, versioned, offline-tolerant, in this studio's own D1. Pressing Play needs no account; a guest's
|
|
203
|
+
progress stays on that device until they make a passkey account (at \`/account/\`, or the game's own button), and
|
|
204
|
+
then it follows them to every device. Lifetime stats and a hardcore "hall of the fallen" are in it too. The
|
|
205
|
+
\`ember-vale\` starter shows the whole pattern.
|
|
194
206
|
|
|
195
207
|
## The Game Codex and progress
|
|
196
208
|
|
|
@@ -359,7 +371,7 @@ export function studioFiles({ name, slug, homie, template = false }) {
|
|
|
359
371
|
products: ['Workers', 'D1', 'Durable Objects'],
|
|
360
372
|
bindings: {
|
|
361
373
|
STUDIO_NAME: { description: 'Your studio\'s name, as its site shows it (for example **Night Owls**). Claude can change it later.' },
|
|
362
|
-
DB: { description: 'The studio\'s own database: finished rounds, the studio\'s own stats (counts, never a visitor) and its claim in the [homie.rocks](https://homie.rocks/studios/) directory. Free plan.' },
|
|
374
|
+
DB: { description: 'The studio\'s own database: finished rounds, the studio\'s own stats (counts, never a visitor), player accounts and cloud saves for games that keep them, and its claim in the [homie.rocks](https://homie.rocks/studios/) directory. Free plan.' },
|
|
363
375
|
},
|
|
364
376
|
},
|
|
365
377
|
};
|
|
@@ -384,6 +396,7 @@ export { default, Table, Lobby } from '@homie-rocks/studio/worker';
|
|
|
384
396
|
`,
|
|
385
397
|
'site/migrations/0001_studio.sql': MIGRATION,
|
|
386
398
|
[`site/migrations/${STATS_MIGRATION_FILE}`]: STATS_MIGRATION,
|
|
399
|
+
[`site/migrations/${PLAYERS_MIGRATION_FILE}`]: PLAYERS_MIGRATION,
|
|
387
400
|
'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
|
|
388
401
|
'.claude/skills/.gitkeep': '',
|
|
389
402
|
};
|
|
@@ -453,6 +466,23 @@ export function ensureStatsMigration(root) {
|
|
|
453
466
|
return `site/migrations/${STATS_MIGRATION_FILE}`;
|
|
454
467
|
}
|
|
455
468
|
|
|
469
|
+
/**
|
|
470
|
+
* A studio made before 0.12.0 has no players migration (player accounts and cloud saves, saves/SAVES.md): add it,
|
|
471
|
+
* so the next `d1 migrations apply` makes the tables. Returns the file it wrote, or null when it was there.
|
|
472
|
+
*/
|
|
473
|
+
export function ensurePlayersMigration(root) {
|
|
474
|
+
const file = join(root, 'site', 'migrations', PLAYERS_MIGRATION_FILE);
|
|
475
|
+
if (existsSync(file)) return null;
|
|
476
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
477
|
+
writeFileSync(file, PLAYERS_MIGRATION);
|
|
478
|
+
return `site/migrations/${PLAYERS_MIGRATION_FILE}`;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Every migration the template owns that this studio lacks, added: the files written (deploy and dev say so). */
|
|
482
|
+
export function ensureMigrations(root) {
|
|
483
|
+
return [ensureStatsMigration(root), ensurePlayersMigration(root)].filter(Boolean);
|
|
484
|
+
}
|
|
485
|
+
|
|
456
486
|
/**
|
|
457
487
|
* A studio made before 0.6.0 committed nothing under .studio/ yet: make sure git leaves it out before the first
|
|
458
488
|
* deploy writes the workers.dev address there. Returns true when it added the line.
|
package/lib/setup.mjs
CHANGED
|
@@ -13,43 +13,32 @@
|
|
|
13
13
|
* A setup id is single use per repository: a second attach from the same repository answers the same; another
|
|
14
14
|
* repository is refused.
|
|
15
15
|
*/
|
|
16
|
-
import { spawnSync } from 'node:child_process';
|
|
17
16
|
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
18
17
|
import { join } from 'node:path';
|
|
19
18
|
import { configNames } from './cloudflare.mjs';
|
|
20
|
-
import {
|
|
19
|
+
import { request } from './net.mjs';
|
|
20
|
+
import { ENGINE_REPO, repoOf } from './repo.mjs';
|
|
21
21
|
import { CONNECT_BAND, agentsMd, readme, slugify, templateReadme } from './scaffold.mjs';
|
|
22
22
|
import { configPath, readStudio, writeLocal, writeStudio } from './studio.mjs';
|
|
23
23
|
import { STUDIO_VERSION } from './version.mjs';
|
|
24
24
|
|
|
25
25
|
const SETUP_ID = /^hs_[a-f0-9]{32}$/;
|
|
26
26
|
|
|
27
|
-
/** owner/repo of this checkout's GitHub remote (a Claude Code cloud session's remote goes through its own proxy). */
|
|
28
|
-
export function repoOf(root) {
|
|
29
|
-
const r = spawnSync('git', ['remote', 'get-url', 'origin'], { cwd: root, encoding: 'utf8', timeout: 5000 });
|
|
30
|
-
if (r.status !== 0) return null;
|
|
31
|
-
const url = r.stdout.trim().replace(/\.git$/, '').replace(/\/+$/, '');
|
|
32
|
-
const m = /[/:]([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9._-]{1,100})$/.exec(url);
|
|
33
|
-
return m ? `${m[1]}/${m[2]}` : null;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
27
|
export async function setupAttach(root, id, { homie } = {}) {
|
|
37
28
|
if (!SETUP_ID.test(String(id ?? ''))) return { ok: false, command: 'setup attach', why: 'attach to the setup id the Claude app\'s setup card showed: hs_ and 32 hex digits' };
|
|
38
29
|
const studio = readStudio(root);
|
|
39
30
|
const directory = String(homie || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
|
|
40
31
|
const repo = repoOf(root);
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
if (blocked) return { ok: false, command: 'setup attach', needs: 'network', why: blocked };
|
|
52
|
-
if (!res.ok || !body?.ok) return { ok: false, command: 'setup attach', why: body?.message ?? `${directory} answered ${res.status}` };
|
|
32
|
+
if (!repo) {
|
|
33
|
+
return { ok: false, command: 'setup attach', needs: 'repository', why: `this checkout names no GitHub repository of its own (its git remote is missing, or is ${ENGINE_REPO}, Homie's engine and template, which a studio never is). Run this in the studio's own repository, the one Cloudflare's Deploy to Cloudflare made, or name it in studio.json as "github": "<owner>/<name>"` };
|
|
34
|
+
}
|
|
35
|
+
const sent = await request(`${directory}/api/studio/setup/${id}/attach`, {
|
|
36
|
+
method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': `homie-studio/${STUDIO_VERSION}` },
|
|
37
|
+
body: JSON.stringify({ repo, studio: { name: studio.name, slug: studio.slug, template: studio.template === true }, version: STUDIO_VERSION }),
|
|
38
|
+
});
|
|
39
|
+
// What went wrong, as it is: the directory's own answer, or the connection's error. Never a guess at the network.
|
|
40
|
+
if (!sent.ok) return { ok: false, command: 'setup attach', repo, ...(sent.status ? { status: sent.status } : {}), ...(sent.code ? { code: sent.code } : {}), ...(sent.needs ? { needs: sent.needs } : {}), why: sent.why };
|
|
41
|
+
const body = sent.body;
|
|
53
42
|
|
|
54
43
|
const renamed = [];
|
|
55
44
|
const name = typeof body.name === 'string' && body.name.trim() ? body.name.trim().slice(0, 60) : null;
|
|
@@ -78,6 +67,9 @@ export async function setupAttach(root, id, { homie } = {}) {
|
|
|
78
67
|
const band = join(root, 'site', 'partials', 'home.html');
|
|
79
68
|
if (existsSync(band) && readFileSync(band, 'utf8') === CONNECT_BAND) { rmSync(band); renamed.push('site/partials/home.html (removed)'); }
|
|
80
69
|
}
|
|
70
|
+
// The repository goes into studio.json, so the next deploy (and Workers Builds) tells the live Worker which it is.
|
|
71
|
+
const now = readStudio(root);
|
|
72
|
+
if (now.github !== repo) { writeStudio(root, { ...now, github: repo }); if (!renamed.includes('studio.json')) renamed.push('studio.json'); }
|
|
81
73
|
if (body.site) writeLocal(root, { url: body.site, connectedAt: new Date().toISOString() });
|
|
82
74
|
return {
|
|
83
75
|
ok: true, command: 'setup attach', setup: id, repo, site: body.site ?? null, name: name ?? studio.name, renamed,
|
package/lib/site.mjs
CHANGED
|
@@ -214,9 +214,9 @@ export function readPosts(root, { games = [], songs = [], videos = [], log = ()
|
|
|
214
214
|
|
|
215
215
|
export const PARTIALS = ['head', 'header', 'footer', 'home', 'game', 'post'];
|
|
216
216
|
/** Paths a page of the studio's own can never take: the rooms, the game files, the API and the owner's pages. */
|
|
217
|
-
const RESERVED_PAGE = /^\/(?:api|_studio|_homie|_site|media|\.well-known|games\/[^/]+)\/|^\/[a-z0-9][a-z0-9-]{0,39}\/(?:play|tv|live|api|__[a-z]+)(?:\/|$)/;
|
|
217
|
+
const RESERVED_PAGE = /^\/(?:api|account|_studio|_homie|_site|media|\.well-known|games\/[^/]+)\/|^\/[a-z0-9][a-z0-9-]{0,39}\/(?:play|tv|live|api|__[a-z]+)(?:\/|$)/;
|
|
218
218
|
/** Top-level names site/public must not take (the build's own output). */
|
|
219
|
-
const RESERVED_PUBLIC = new Set(['games', 'music', 'videos', 'games.json', '_site', '_homie', '_studio', 'api', 'media']);
|
|
219
|
+
const RESERVED_PUBLIC = new Set(['games', 'music', 'videos', 'games.json', '_site', '_homie', '_studio', 'api', 'media', 'account']);
|
|
220
220
|
|
|
221
221
|
function walk(dir, fn, rel = '') {
|
|
222
222
|
for (const e of readdirSync(join(dir, rel), { withFileTypes: true })) {
|
package/lib/stats.mjs
CHANGED
|
@@ -21,9 +21,9 @@ const HOUR = 3600_000;
|
|
|
21
21
|
const RANGES = new Set(['1d', '7d', '30d', '90d']);
|
|
22
22
|
const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
23
23
|
|
|
24
|
-
const loopback = (url) => { try { return ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(url).hostname); } catch { return false; } };
|
|
24
|
+
export const loopback = (url) => { try { return ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(url).hostname); } catch { return false; } };
|
|
25
25
|
|
|
26
|
-
function siteOf(root, url) {
|
|
26
|
+
export function siteOf(root, url) {
|
|
27
27
|
const studio = readStudio(root);
|
|
28
28
|
const site = String(url || siteUrl(root, studio) || '').replace(/\/+$/, '');
|
|
29
29
|
if (!/^https?:\/\//.test(site)) return { studio, site: null };
|
|
@@ -31,7 +31,7 @@ function siteOf(root, url) {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
/** Put a key's hash into the studio's D1 (remote, or local for a loopback site). The key never leaves this machine. */
|
|
34
|
-
function mint(root, studio, kind, ttlMs, { local }) {
|
|
34
|
+
export function mint(root, studio, kind, ttlMs, { local }) {
|
|
35
35
|
const key = `hsk_${randomBytes(24).toString('hex')}`;
|
|
36
36
|
const hash = createHash('sha256').update(key).digest('hex');
|
|
37
37
|
const now = Date.now();
|
package/lib/studio.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
8
8
|
|
|
9
9
|
export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
10
10
|
export const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
11
|
-
const RESERVED_IDS = new Set(['api', 'media', 'games', 'assets', '_homie', '_site', '_studio', 'well-known', 'index', 'play', 'studio', 'studios', 'music', 'videos', 'posts', 'rooms', 'feed']);
|
|
11
|
+
const RESERVED_IDS = new Set(['api', 'media', 'games', 'assets', '_homie', '_site', '_studio', 'well-known', 'index', 'play', 'studio', 'studios', 'music', 'videos', 'posts', 'rooms', 'feed', 'account']);
|
|
12
12
|
|
|
13
13
|
export function findStudio(from = process.cwd()) {
|
|
14
14
|
let at = resolve(from);
|
|
@@ -199,6 +199,54 @@
|
|
|
199
199
|
"## Beta": "9d02638207a26e73"
|
|
200
200
|
}
|
|
201
201
|
}
|
|
202
|
+
},
|
|
203
|
+
"0.12.0": {
|
|
204
|
+
"files": {
|
|
205
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
206
|
+
"README.md": "684b3cca8001673c",
|
|
207
|
+
"games/README.md": "5e5e942baa7e0372",
|
|
208
|
+
"music/README.md": "b8a828de9311bd28",
|
|
209
|
+
"videos/README.md": "70614388cbb203c7",
|
|
210
|
+
"posts/README.md": "37841aae476bd540",
|
|
211
|
+
"site/README.md": "ca131cd0472464fb",
|
|
212
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
213
|
+
},
|
|
214
|
+
"sections": {
|
|
215
|
+
"AGENTS.md": {
|
|
216
|
+
"(intro)": "214aa46eb3a895d5",
|
|
217
|
+
"## Layout": "f14d641159480e6c",
|
|
218
|
+
"## Commands (all through the pinned CLI in node_modules)": "3a3d3bef7e8d1a50",
|
|
219
|
+
"## The site": "7d3525ba39b7f262",
|
|
220
|
+
"## Making games": "63069b31c87a2aa2",
|
|
221
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
222
|
+
"## Rules": "969318425c99f5a5",
|
|
223
|
+
"## Beta": "9d02638207a26e73"
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
"0.12.1": {
|
|
228
|
+
"files": {
|
|
229
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
230
|
+
"README.md": "684b3cca8001673c",
|
|
231
|
+
"games/README.md": "5e5e942baa7e0372",
|
|
232
|
+
"music/README.md": "b8a828de9311bd28",
|
|
233
|
+
"videos/README.md": "70614388cbb203c7",
|
|
234
|
+
"posts/README.md": "37841aae476bd540",
|
|
235
|
+
"site/README.md": "ca131cd0472464fb",
|
|
236
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
237
|
+
},
|
|
238
|
+
"sections": {
|
|
239
|
+
"AGENTS.md": {
|
|
240
|
+
"(intro)": "214aa46eb3a895d5",
|
|
241
|
+
"## Layout": "f14d641159480e6c",
|
|
242
|
+
"## Commands (all through the pinned CLI in node_modules)": "3a3d3bef7e8d1a50",
|
|
243
|
+
"## The site": "7d3525ba39b7f262",
|
|
244
|
+
"## Making games": "63069b31c87a2aa2",
|
|
245
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
246
|
+
"## Rules": "969318425c99f5a5",
|
|
247
|
+
"## Beta": "9d02638207a26e73"
|
|
248
|
+
}
|
|
249
|
+
}
|
|
202
250
|
}
|
|
203
251
|
}
|
|
204
252
|
}
|
package/netplay/NETPLAY.md
CHANGED
|
@@ -194,6 +194,11 @@ it.
|
|
|
194
194
|
- If the old host does not return, the relay elects someone else. That browser restores
|
|
195
195
|
from the stored checkpoint (at most 30 s old) and from its own newest snapshot.
|
|
196
196
|
|
|
197
|
+
**What a room is not.** A room forgets everything 60 s after its last player leaves (`forgetMs`). Progress that
|
|
198
|
+
must last (a character, unlocks, days of play) is the player's, not the room's: it goes in cloud saves
|
|
199
|
+
(`@homie-rocks/studio/saves`, `../saves/SAVES.md`). The host decides what happened and tells that player's own
|
|
200
|
+
browser (an `ev` to its seat); only that browser changes and saves the player's progress.
|
|
201
|
+
|
|
197
202
|
## 5. Wire protocol v1
|
|
198
203
|
|
|
199
204
|
- **Transport:** one WebSocket per game document, carrying JSON text frames
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
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",
|
|
@@ -26,6 +26,10 @@
|
|
|
26
26
|
"types": "./port/early.ts",
|
|
27
27
|
"default": "./port/early.ts"
|
|
28
28
|
},
|
|
29
|
+
"./saves": {
|
|
30
|
+
"types": "./saves/saves.ts",
|
|
31
|
+
"default": "./saves/saves.ts"
|
|
32
|
+
},
|
|
29
33
|
"./worker": "./worker/index.mjs",
|
|
30
34
|
"./package.json": "./package.json"
|
|
31
35
|
},
|
|
@@ -35,6 +39,7 @@
|
|
|
35
39
|
"netplay/",
|
|
36
40
|
"port/",
|
|
37
41
|
"worker/",
|
|
42
|
+
"saves/",
|
|
38
43
|
"starters/",
|
|
39
44
|
"media/",
|
|
40
45
|
"site/",
|
package/port/index.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* createHud a minimal clock / scores / results overlay
|
|
13
13
|
* BotBrain, seek, nearest, rubberBand
|
|
14
14
|
* bot scaffolds
|
|
15
|
+
* createSaves player accounts and cloud saves (a character that lasts days, on every device; saves/SAVES.md)
|
|
15
16
|
* and everything from @homie-rocks/studio/netplay.
|
|
16
17
|
*/
|
|
17
18
|
import './early';
|
|
@@ -27,3 +28,4 @@ export { exposePort, type PortProbeOptions, type View } from './probe';
|
|
|
27
28
|
export { createRoom, type Room, type RoomOptions, type BodyBase, type RoomSnap, type RoomCkpt } from './room';
|
|
28
29
|
export { createHud, type HudOptions } from './hud';
|
|
29
30
|
export { BotBrain, seek, flee, wanderer, nearest, rubberBand, protectedNewcomer, type V2 } from './bots';
|
|
31
|
+
export { createSaves, SAVES_VERSION, type Saves, type SavesOptions, type Player, type SaveEntry, type SetResult, type Memorial, type SavesStatus, type Conflict } from '../saves/saves';
|
package/port/keys.ts
CHANGED
|
@@ -75,6 +75,9 @@ export function createKeys(target: Window | HTMLElement = window): Keys {
|
|
|
75
75
|
const edges = new Set<string>();
|
|
76
76
|
const kd = (e: Event): void => {
|
|
77
77
|
const k = e as KeyboardEvent;
|
|
78
|
+
// A key typed into the game's own text box (a hero's name, a chat line) is a word, never a move.
|
|
79
|
+
const el = k.target as HTMLElement | null;
|
|
80
|
+
if (el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName ?? ''))) return;
|
|
78
81
|
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space'].includes(k.code)) k.preventDefault();
|
|
79
82
|
if (!held.has(k.code)) edges.add(k.code);
|
|
80
83
|
held.add(k.code);
|