@homie-rocks/studio 0.11.0 → 0.12.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 +33 -6
- package/bin/homie-studio.mjs +17 -4
- package/lib/build.mjs +3 -0
- package/lib/cloudflare.mjs +4 -5
- package/lib/codex.mjs +1 -0
- package/lib/players.mjs +38 -0
- package/lib/progress.mjs +4 -3
- package/lib/scaffold.mjs +31 -1
- package/lib/site.mjs +2 -2
- package/lib/stats.mjs +3 -3
- package/lib/studio.mjs +1 -1
- package/lib/template-history.json +24 -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 +11 -1
- 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/README.md
CHANGED
|
@@ -72,8 +72,8 @@ site. A game made with Homie's arcade controls that knocks for a Homie box (`/__
|
|
|
72
72
|
A studio pins one version, so nothing changes until it asks. To take a newer one:
|
|
73
73
|
|
|
74
74
|
```sh
|
|
75
|
-
npx -y @homie-rocks/studio@0.
|
|
76
|
-
npx -y @homie-rocks/studio@0.
|
|
75
|
+
npx -y @homie-rocks/studio@0.12.0 upgrade # the plan; changes nothing
|
|
76
|
+
npx -y @homie-rocks/studio@0.12.0 upgrade --apply # after the person agrees
|
|
77
77
|
npm install && npm run build
|
|
78
78
|
```
|
|
79
79
|
|
|
@@ -94,6 +94,32 @@ Bots, a late joiner taking a bot's body and a new host after the old one leaves
|
|
|
94
94
|
the same at 32 as at 4. One address may hold every seat plus four sockets, so a party
|
|
95
95
|
on one Wi-Fi (or strangers behind one carrier's address) fills a room with a TV beside it.
|
|
96
96
|
|
|
97
|
+
## Player accounts and cloud saves
|
|
98
|
+
|
|
99
|
+
A game whose progress must last (a character that levels up for days, unlocks, a collection, a hardcore mode)
|
|
100
|
+
keeps it in **saves**, not in its room: a room forgets everything 60 s after its last player leaves.
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{ "id": "my-rpg", "saves": true }
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
import { createSaves } from '@homie-rocks/studio/saves';
|
|
108
|
+
const saves = createSaves({ game: 'my-rpg' });
|
|
109
|
+
const hero = (await saves.get('hero')) ?? newHero();
|
|
110
|
+
await saves.set('hero', hero); // on this device at once, in the studio's D1 when online
|
|
111
|
+
saves.stats.add({ kills: 1 }); // lifetime numbers that outlive any hero
|
|
112
|
+
await saves.fall({ character: hero.name, summary: { level: hero.level }, wipe: true }); // hardcore
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Pressing Play needs no account. A player who saves something is a guest on that browser; with a **passkey**
|
|
116
|
+
(Face ID, a fingerprint, a PIN, no password, no email) it becomes an account on this studio only, and the same
|
|
117
|
+
saves follow them to every device they sign in on. Saves are per player and game, versioned (a stale offline
|
|
118
|
+
copy is a conflict, never an overwrite), capped in size, and offline-tolerant. Players see, download and delete
|
|
119
|
+
everything at `/account/`; the owner sees counts (`homie-studio players`) and, through the back office, names,
|
|
120
|
+
never a passkey or an email. The `ember-vale` starter is a small persistent-character game on the same public
|
|
121
|
+
rooms. Everything, including what is stored and the back office's API: [saves/SAVES.md](saves/SAVES.md).
|
|
122
|
+
|
|
97
123
|
## The studio's own stats
|
|
98
124
|
|
|
99
125
|
The site counts, and never tracks: pages opened, Play presses, rooms opened, the most
|
|
@@ -116,7 +142,7 @@ studio's own Cloudflare login. `worker/stats.mjs` says exactly what is counted.
|
|
|
116
142
|
## Setup status
|
|
117
143
|
|
|
118
144
|
```sh
|
|
119
|
-
npx -y @homie-rocks/studio@0.
|
|
145
|
+
npx -y @homie-rocks/studio@0.12.0 setup status --connector yes # before a studio exists
|
|
120
146
|
npx homie-studio setup status # in a studio (also: homie-studio doctor)
|
|
121
147
|
```
|
|
122
148
|
|
|
@@ -205,9 +231,10 @@ Workers Builds deploys the branch as a Preview; the card's **Publish** opens the
|
|
|
205
231
|
person's merge is the approval; Workers Builds deploys `main`, and the card says **Live** when the live site's
|
|
206
232
|
manifest lists the change's mark (`homie-studio build` lists the newest marks from `changes/`).
|
|
207
233
|
|
|
208
|
-
A Claude Code cloud session's default network ("Trusted")
|
|
209
|
-
|
|
210
|
-
|
|
234
|
+
A Claude Code cloud session's default network ("Trusted") reaches homie.rocks. If a session's network blocks it
|
|
235
|
+
(its proxy answers 403 `host_not_allowed`, and the progress card stops updating), the toolkit says so and names the
|
|
236
|
+
setting (the environment's Network access: Custom, add homie.rocks), instead of "did not answer". The build goes on
|
|
237
|
+
with its local feed.
|
|
211
238
|
|
|
212
239
|
The feed is `.studio/progress/<build>.json` (git-ignored). With `--share` the studio's
|
|
213
240
|
directory (studio.json `homie.directory`, homie.rocks by default) keeps a copy for 24
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -37,6 +37,8 @@
|
|
|
37
37
|
* homie-studio stats link (a one-time link to the private stats page, for the owner's browser)
|
|
38
38
|
* homie-studio stats revoke (every stats key and page session ends)
|
|
39
39
|
* homie-studio stats share on|off (tell the homie.rocks directory "played this week", or stop)
|
|
40
|
+
* homie-studio players (player accounts and guests with saves: counts, never a passkey or an email)
|
|
41
|
+
* homie-studio players owner [--revoke] (a one-time link that marks the owner's own player account as the owner's)
|
|
40
42
|
*
|
|
41
43
|
* homie-studio progress start [<id>] [--what game|song|video] [--title "<what this build does>"]
|
|
42
44
|
* [--budget <dollars>] [--unit usd|credits] [--share]
|
|
@@ -96,11 +98,12 @@ import { look } from '../lib/look.mjs';
|
|
|
96
98
|
import { importPort, planPort } from '../lib/port.mjs';
|
|
97
99
|
import { portCheck } from '../lib/port-check.mjs';
|
|
98
100
|
import { recordUpload, resolveMedia, typeOf } from '../lib/media.mjs';
|
|
99
|
-
import {
|
|
101
|
+
import { ensureMigrations, newStudio } from '../lib/scaffold.mjs';
|
|
100
102
|
import { lineDiff, upgradeApply, upgradePlan } from '../lib/upgrade.mjs';
|
|
101
103
|
import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, starters, workerDir } from '../lib/studio.mjs';
|
|
102
104
|
import { STUDIO_VERSION } from '../lib/version.mjs';
|
|
103
105
|
import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
|
|
106
|
+
import { playersOwner, playersShow } from '../lib/players.mjs';
|
|
104
107
|
import { Feed, currentFeed, currentId, flushProgress, publicFeed, readFeed, recordChange, startProgress } from '../lib/progress.mjs';
|
|
105
108
|
import { formatStatus, setupStatus } from '../lib/doctor.mjs';
|
|
106
109
|
import { codexTarget, newCodex, writeCodexPage } from '../lib/codex.mjs';
|
|
@@ -108,7 +111,7 @@ import { installStatusLine, statusLine } from '../lib/statusline.mjs';
|
|
|
108
111
|
|
|
109
112
|
const argv = process.argv.slice(2);
|
|
110
113
|
const flags = new Map();
|
|
111
|
-
const BOOL_FLAGS = ['json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open'];
|
|
114
|
+
const BOOL_FLAGS = ['revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open'];
|
|
112
115
|
const positional = [];
|
|
113
116
|
for (let i = 0; i < argv.length; i++) {
|
|
114
117
|
const a = argv[i];
|
|
@@ -207,8 +210,17 @@ function print(result) {
|
|
|
207
210
|
for (const e of result.songs) lines.push(` song ${e.slug}: ${n(e.visits)} page visits, played ${n(e.plays)}`);
|
|
208
211
|
for (const e of result.videos) lines.push(` video ${e.slug}: ${n(e.visits)} page visits, watched ${n(e.views)}`);
|
|
209
212
|
for (const r of result.referrers.slice(0, 10)) lines.push(` from ${r.from} (${r.kind}): ${n(r.visits)} visits, ${n(r.plays)} plays`);
|
|
213
|
+
if (result.players) lines.push(` players: ${n(result.players.accounts)} accounts (${n(result.players.newAccounts7d)} new this week), ${n(result.players.guests)} guests with saves, ${n(result.players.active7d)} played this week`);
|
|
210
214
|
break;
|
|
211
215
|
}
|
|
216
|
+
case 'players': {
|
|
217
|
+
const p = result.players;
|
|
218
|
+
lines.push(`${p.accounts} player accounts (${p.newAccounts7d} new this week), ${p.guests} guests with saves, ${p.active7d} played this week.`, result.note);
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
case 'players owner':
|
|
222
|
+
lines.push(result.revoked ? result.message : `One-time link (until ${result.expiresAt}): ${result.link}`, ...(result.revoked ? [] : [result.use]));
|
|
223
|
+
break;
|
|
212
224
|
case 'stats key':
|
|
213
225
|
lines.push(`Read key (until ${result.expiresAt}): ${result.key}`, result.use);
|
|
214
226
|
break;
|
|
@@ -388,6 +400,8 @@ async function main() {
|
|
|
388
400
|
return { ok: true, command: 'storage', storage: has ? { kind: 'r2', bucket: cf.r2 } : null, why: has ? undefined : 'no storage yet: the studio runs without it; `homie-studio storage add` adds an R2 bucket for large media (Cloudflare asks for a payment method before R2 works)' };
|
|
389
401
|
}
|
|
390
402
|
if (cmd === 'publish') return publish(root, { homie: flags.get('homie'), site: flags.get('site') });
|
|
403
|
+
if (cmd === 'players' && sub === 'owner') return playersOwner(root, { url: flags.get('url'), revoke: flags.has('revoke') });
|
|
404
|
+
if (cmd === 'players' && !sub) return playersShow(root, { url: flags.get('url') });
|
|
391
405
|
if (cmd === 'stats' && sub === 'key') return statsKey(root, { url: flags.get('url'), hours: flags.get('hours') });
|
|
392
406
|
if (cmd === 'stats' && sub === 'link') return statsLink(root, { url: flags.get('url') });
|
|
393
407
|
if (cmd === 'stats' && sub === 'revoke') return statsRevoke(root, { url: flags.get('url') });
|
|
@@ -562,8 +576,7 @@ async function dev(root) {
|
|
|
562
576
|
if (!bin) return { ok: false, command: 'dev', why: 'run npm install in the studio first' };
|
|
563
577
|
const studio = readStudio(root);
|
|
564
578
|
const env = { ...process.env, WRANGLER_SEND_METRICS: 'false', CI: '1' };
|
|
565
|
-
const added
|
|
566
|
-
if (added) log(`added ${added} (the studio's own stats)`);
|
|
579
|
+
for (const added of ensureMigrations(root)) log(`added ${added} (${/players/.test(added) ? 'player accounts and cloud saves' : 'the studio\'s own stats'})`);
|
|
567
580
|
await new Promise((done) => {
|
|
568
581
|
const m = spawn(bin, ['d1', 'migrations', 'apply', 'DB', '--local'], { cwd: workerDir(root), env, stdio: ['ignore', 'ignore', 'inherit'] });
|
|
569
582
|
m.on('close', done);
|
package/lib/build.mjs
CHANGED
|
@@ -220,6 +220,9 @@ export async function build(root, { only = null, log = () => {} } = {}) {
|
|
|
220
220
|
id: g.id, name: g.name ?? g.id, blurb: g.blurb ?? '', players: { min, max },
|
|
221
221
|
roundSeconds: g.roundSeconds ?? net.roundSeconds ?? null, movement: net.movement ?? null, cover: g.cover ?? null,
|
|
222
222
|
...(g.screen ? { screen: g.screen } : {}),
|
|
223
|
+
// game.json "saves": true — player accounts and cloud saves (saves/SAVES.md): the play shell answers the game's
|
|
224
|
+
// saves calls, and the site's nav and the game's landing offer a player account.
|
|
225
|
+
...(g.saves === true || (g.saves && typeof g.saves === 'object') ? { saves: true } : {}),
|
|
223
226
|
landing: landingOf(g, join(dist, 'games', g.id), { videos: media.videos, songs: media.songs, log }),
|
|
224
227
|
};
|
|
225
228
|
});
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -22,7 +22,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
22
22
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
23
23
|
import { join } from 'node:path';
|
|
24
24
|
import { build } from './build.mjs';
|
|
25
|
-
import { ensureLocalIgnored,
|
|
25
|
+
import { ensureLocalIgnored, ensureMigrations, wranglerConfig } from './scaffold.mjs';
|
|
26
26
|
import { LOCAL_STATE, configPath, isWorkersDev, layoutOf, readLocal, readStudio, siteUrl, workerDir, writeLocal, writeStudio } from './studio.mjs';
|
|
27
27
|
|
|
28
28
|
const ANSI = /\u001b\[[0-9;]*m/g;
|
|
@@ -96,7 +96,7 @@ export function deployPlan(root) {
|
|
|
96
96
|
ok: true, command: 'deploy plan', studio: studio.name, account: cf.accountId ?? null,
|
|
97
97
|
cloudflare: [
|
|
98
98
|
{ kind: 'Worker', name: cf.worker, what: 'the studio\'s pages, each game\'s page and play shell, and /.well-known/homie-studio.json for the directory', state: mark(`worker:${cf.worker}`), plan: 'Workers Free' },
|
|
99
|
-
{ kind: 'D1 database', name: cf.d1, what: 'the directory claim, every finished round,
|
|
99
|
+
{ kind: 'D1 database', name: cf.d1, what: 'the directory claim, every finished round, the studio\'s own stats (daily counters of visits, plays, rooms, rounds and songs, for the owner only; nothing about a visitor), and, for games that keep saves, player accounts (a passkey\'s public key, never a password) and their saves', state: mark(`d1:${cf.d1}`), plan: 'Workers Free (500 MB per database, 5 GB per account)' },
|
|
100
100
|
{ kind: 'Durable Object', name: 'Table', what: 'one per public room: the netplay relay (seats, host, snapshots); runs no game code', state: 'declared by the Worker', plan: 'Workers Free (SQLite-backed)' },
|
|
101
101
|
{ kind: 'Durable Object', name: 'Lobby', what: 'one per game: puts strangers who press Play into the same room', state: 'declared by the Worker', plan: 'Workers Free (SQLite-backed)' },
|
|
102
102
|
storage
|
|
@@ -192,8 +192,7 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
192
192
|
if (!r2) step('no storage (R2): the studio needs none to run; `homie-studio storage add` adds it for large media');
|
|
193
193
|
|
|
194
194
|
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root) }));
|
|
195
|
-
const added
|
|
196
|
-
if (added) step(`added ${added} (the studio's own stats: counts, never tracks)`);
|
|
195
|
+
for (const added of ensureMigrations(root)) step(`added ${added} (${/players/.test(added) ? 'player accounts and cloud saves' : 'the studio\'s own stats: counts, never tracks'})`);
|
|
197
196
|
const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
|
|
198
197
|
if (migrate.code !== 0) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
|
|
199
198
|
step('D1 migrations applied');
|
|
@@ -283,7 +282,7 @@ export async function ciDeploy(root, { log = () => {} } = {}) {
|
|
|
283
282
|
const step = (what) => { steps.push({ what }); log(what); };
|
|
284
283
|
const refuse = (why, out) => ({ ok: false, command: 'deploy', ci: true, ...(explainCloudflare(out) ?? { why }), steps });
|
|
285
284
|
if (!existsSync(join(root, 'site', 'dist', 'games.json'))) return { ok: false, command: 'deploy', ci: true, why: 'nothing is built: the build command is `npm run build` (homie-studio build), and it runs before this', steps };
|
|
286
|
-
|
|
285
|
+
for (const added of ensureMigrations(root)) step(`added ${added} (${/players/.test(added) ? 'player accounts and cloud saves' : 'the studio\'s own counters'})`);
|
|
287
286
|
const apply = () => w(['d1', 'migrations', 'apply', names.binding, '--remote']);
|
|
288
287
|
let migrate = apply();
|
|
289
288
|
const first = migrate.code !== 0 && /not found|could(?:n't| not) find|does not exist|no database|database_id/i.test(migrate.out);
|
package/lib/codex.mjs
CHANGED
|
@@ -637,6 +637,7 @@ ${game.blurb ?? ''}
|
|
|
637
637
|
- **Players in a room:** ${players}
|
|
638
638
|
- **Round:** ${game.roundSeconds ? `${game.roundSeconds} s` : 'how long, and how it ends'}
|
|
639
639
|
- **Bots:** fill empty seats; a person who arrives takes a bot's place
|
|
640
|
+
- **Progress that lasts:** ${game.saves ? 'the player\'s own saves (cloud saves: what is kept, lifetime stats, hardcore or not)' : 'none: a round is all there is (or cloud saves, if the plan says progress persists across sessions or devices)'}
|
|
640
641
|
|
|
641
642
|
## Music and sound
|
|
642
643
|
|
package/lib/players.mjs
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio players` — the studio's players, for its owner (worker/players.mjs, saves/SAVES.md).
|
|
3
|
+
*
|
|
4
|
+
* homie-studio players [--url <site>] how many have accounts, guests with saves, who played this week
|
|
5
|
+
* homie-studio players owner [--url <site>] a one-time link that marks the owner's OWN player account as the
|
|
6
|
+
* owner's (open it in the browser where they are signed in on the site)
|
|
7
|
+
* homie-studio players owner --revoke no account is the owner's any more
|
|
8
|
+
*
|
|
9
|
+
* The proof of ownership is the studio's own Cloudflare login, as for `stats`: the link's key is minted here and
|
|
10
|
+
* only its SHA-256 goes into the studio's D1 (through Wrangler). The link works once, for 30 minutes.
|
|
11
|
+
*/
|
|
12
|
+
import { runner } from './cloudflare.mjs';
|
|
13
|
+
import { loopback, mint, siteOf, statsShow } from './stats.mjs';
|
|
14
|
+
|
|
15
|
+
export async function playersShow(root, { url } = {}) {
|
|
16
|
+
const s = await statsShow(root, { url, range: '7d' });
|
|
17
|
+
if (!s.ok) return { ...s, command: 'players' };
|
|
18
|
+
if (!s.players) return { ok: false, command: 'players', why: 'this studio\'s D1 has no player tables yet: `npm run deploy` applies migration 0004_players.sql (or `npm run dev` for a local site)' };
|
|
19
|
+
return { ok: true, command: 'players', site: s.site, players: s.players, note: 'Counts only. A player\'s name, passkeys and saves are theirs; the back office lists names, never a passkey or an email.' };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function playersOwner(root, { url, revoke = false } = {}) {
|
|
23
|
+
const { studio, site } = siteOf(root, url);
|
|
24
|
+
if (!site) return { ok: false, command: 'players owner', why: 'this studio has no live site yet: `npm run deploy` first, or give --url http://127.0.0.1:8787 for `npm run dev`' };
|
|
25
|
+
const local = loopback(site);
|
|
26
|
+
if (revoke) {
|
|
27
|
+
const w = runner(root, studio.cloudflare?.accountId && !local ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
|
|
28
|
+
const res = w(['d1', 'execute', studio.cloudflare.d1, local ? '--local' : '--remote', '--command', "UPDATE players SET owner = 0; DELETE FROM stats_keys WHERE kind = 'player-owner';"]);
|
|
29
|
+
return res.code === 0 ? { ok: true, command: 'players owner', site, revoked: true, message: 'No player account is the owner\'s now. `homie-studio players owner` marks one again.' }
|
|
30
|
+
: { ok: false, command: 'players owner', why: res.out.trim().split('\n').slice(-3).join(' ') };
|
|
31
|
+
}
|
|
32
|
+
const k = mint(root, studio, 'player-owner', 30 * 60_000, { local });
|
|
33
|
+
if (!k.ok) return { ok: false, command: 'players owner', why: k.why };
|
|
34
|
+
return {
|
|
35
|
+
ok: true, command: 'players owner', site, link: `${site}/account/?owner=${k.key}`, expiresAt: k.expiresAt,
|
|
36
|
+
use: 'Give this link to the studio\'s owner to open in the browser where they play: they sign in (or make an account with a passkey) on that page, then press "Yes, this account is mine". It works once, within 30 minutes. It is theirs alone; do not post it anywhere.',
|
|
37
|
+
};
|
|
38
|
+
}
|
package/lib/progress.mjs
CHANGED
|
@@ -90,9 +90,10 @@ const currentPath = (root) => join(dir(root), 'current');
|
|
|
90
90
|
const loopback = (url) => { try { return ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(url).hostname); } catch { return false; } };
|
|
91
91
|
|
|
92
92
|
/*
|
|
93
|
-
* A CLAUDE CODE CLOUD SESSION
|
|
94
|
-
*
|
|
95
|
-
*
|
|
93
|
+
* A CLAUDE CODE CLOUD SESSION WHOSE NETWORK BLOCKS THE DIRECTORY. The default "Trusted" network reached homie.rocks
|
|
94
|
+
* in a real cloud session (2026-10-01); a stricter one makes the session's proxy answer 403 with
|
|
95
|
+
* `x-deny-reason: host_not_allowed`. Then, and only then, say which setting lets the card follow the build,
|
|
96
|
+
* instead of "did not answer"; the build itself goes on with its local feed.
|
|
96
97
|
*/
|
|
97
98
|
export function networkWhy(res, at) {
|
|
98
99
|
if (res?.status === 403 && /host_not_allowed/i.test(res.headers?.get?.('x-deny-reason') ?? '')) {
|
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/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,30 @@
|
|
|
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
|
+
}
|
|
202
226
|
}
|
|
203
227
|
}
|
|
204
228
|
}
|
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.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",
|
|
@@ -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);
|