@homie-rocks/studio 0.12.1 → 0.13.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 +64 -3
- package/bin/homie-studio.mjs +51 -3
- package/lib/build.mjs +2 -0
- package/lib/cloudflare.mjs +3 -3
- package/lib/office.mjs +172 -0
- package/lib/scaffold.mjs +42 -1
- package/lib/stats.mjs +1 -1
- package/lib/template-history.json +25 -0
- package/lib/upgrade.mjs +5 -2
- package/netplay/NETPLAY.md +101 -15
- package/netplay/netplay.ts +49 -3
- package/package.json +1 -1
- package/worker/index.mjs +172 -33
- package/worker/office-page.mjs +366 -0
- package/worker/office-schema.mjs +39 -0
- package/worker/office.mjs +762 -0
- package/worker/pages.mjs +257 -10
- package/worker/room.mjs +301 -8
- package/worker/stats-page.mjs +16 -7
- package/worker/stats.mjs +39 -12
- package/worker/version.mjs +1 -1
package/README.md
CHANGED
|
@@ -19,6 +19,7 @@ npx homie-studio deploy --plan # what deploy will create
|
|
|
19
19
|
npx homie-studio deploy # the studio's own Cloudflare
|
|
20
20
|
npx homie-studio publish # the homie.rocks directory
|
|
21
21
|
npx homie-studio stats # the studio's own numbers, for its owner
|
|
22
|
+
npx homie-studio office # who is playing now, in every live room (the back office)
|
|
22
23
|
npx homie-studio upgrade # what a newer template adds to this studio (--apply to take it)
|
|
23
24
|
```
|
|
24
25
|
|
|
@@ -72,8 +73,8 @@ site. A game made with Homie's arcade controls that knocks for a Homie box (`/__
|
|
|
72
73
|
A studio pins one version, so nothing changes until it asks. To take a newer one:
|
|
73
74
|
|
|
74
75
|
```sh
|
|
75
|
-
npx -y @homie-rocks/studio@0.
|
|
76
|
-
npx -y @homie-rocks/studio@0.
|
|
76
|
+
npx -y @homie-rocks/studio@0.13.0 upgrade # the plan; changes nothing
|
|
77
|
+
npx -y @homie-rocks/studio@0.13.0 upgrade --apply # after the person agrees
|
|
77
78
|
npm install && npm run build
|
|
78
79
|
```
|
|
79
80
|
|
|
@@ -139,10 +140,70 @@ npx homie-studio stats share on # tell the directory "pl
|
|
|
139
140
|
Each key is minted on the owner's computer; only its SHA-256 goes into D1, through the
|
|
140
141
|
studio's own Cloudflare login. `worker/stats.mjs` says exactly what is counted.
|
|
141
142
|
|
|
143
|
+
## The back office: run your live games
|
|
144
|
+
|
|
145
|
+
The owner sees and runs the studio's live games from `/_studio/office`, a private page
|
|
146
|
+
that refreshes itself: every live room of every game (players, bots, the round, how long
|
|
147
|
+
it has been up) and who is in it (each seat's handle, its device, whether it hosts, the
|
|
148
|
+
browser it came from; signed-in players once a studio has player accounts). From there,
|
|
149
|
+
and from a small Owner button in the owner's own play page (nobody else's page carries
|
|
150
|
+
it):
|
|
151
|
+
|
|
152
|
+
- **Kick** a player: they get a polite notice and cannot come back to that room for the
|
|
153
|
+
minutes the owner chose (their seat, their browser, their account; their network only
|
|
154
|
+
when asked, since a household shares one).
|
|
155
|
+
- **Mute** a player: their chat and emotes reach nobody (the `say`, `chat` and `emote`
|
|
156
|
+
events; a game hides the rest with `net.isMuted(seat)`).
|
|
157
|
+
- **Announce** one line to a room, a game or the whole studio: every player sees it as a
|
|
158
|
+
banner, and a game can show it its own way (`net.on('announce')`).
|
|
159
|
+
- **Close** a room: everyone is sent out with a thank-you; nobody gets in until it opens.
|
|
160
|
+
- Per game: **players per room**, the **launch state** (`private`: only the owner;
|
|
161
|
+
`invite`: an invite-only beta, where each invite link or code lets a browser in;
|
|
162
|
+
`public`: anyone, listed), and **remixable** (publish or withdraw the game's source).
|
|
163
|
+
A game that is not public is in no list and not in the directory manifest, so the
|
|
164
|
+
directory drops it the next time it reads the studio. A new game is private from its
|
|
165
|
+
first deploy with `"launch": "private"` in its game.json.
|
|
166
|
+
|
|
167
|
+
```sh
|
|
168
|
+
npx homie-studio office link # a one-time link that signs the owner's browser in
|
|
169
|
+
npx homie-studio office link --to /crown-thief/play # ... onto a private game, on the owner's phone
|
|
170
|
+
npx homie-studio office # every live room and who is in it, now
|
|
171
|
+
npx homie-studio office announce "Double gems this round!" --game crown-thief
|
|
172
|
+
npx homie-studio office invite crown-thief --label "Sam" --uses 1
|
|
173
|
+
npx homie-studio office launch crown-thief invite # asks: the owner confirms with one tap
|
|
174
|
+
npx homie-studio office kick crown-thief pub-3 2 # asks: seat 2 of Room 3
|
|
175
|
+
npx homie-studio office key # a key for the Homie MCP's owner tools
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Going private or invite-only never cuts a round short: each live room finishes its current round with a notice to
|
|
179
|
+
the players, then everyone the new state leaves out is sent out with a thank-you (the owner, and in a beta the
|
|
180
|
+
invited, play on). Mute drops a player's `say…`, `chat…` and `emote…` events, so a game that sends its chat and
|
|
181
|
+
emotes under those kinds needs nothing more (`netplay/NETPLAY.md` section 15). Kicking a whole network address is in
|
|
182
|
+
the studio's API (`address: true`) and deliberately not in the office. A Preview (a branch's own unlisted address, with
|
|
183
|
+
no D1) enforces no launch state and has no office.
|
|
184
|
+
|
|
185
|
+
**Privacy.** The site still sets no cookie on a visitor to count or follow them. Two things are kept for the back
|
|
186
|
+
office, both functional: a random room key in the play page's own storage (what a kick holds; it says nothing about
|
|
187
|
+
who the player is), and, for an invited player of an invite-only beta only, their pass to that game: an HttpOnly
|
|
188
|
+
cookie for that game's pages alone, for 90 days. D1 keeps only a hash of each pass.
|
|
189
|
+
|
|
190
|
+
**Only the owner.** The owner's browser is signed in with the same one-time link as the
|
|
191
|
+
stats (an HttpOnly session no page or game can read; games run in a sandboxed, opaque
|
|
192
|
+
frame). An office key (minted with the studio's own Cloudflare login) lets the owner's AI
|
|
193
|
+
look, announce and invite at once; a kick, a mute, a closed room or a launch change is only
|
|
194
|
+
**asked** for, and the owner confirms it with one tap in their own browser. No key can
|
|
195
|
+
confirm. The Worker signs each control with the studio's own secret and the room verifies
|
|
196
|
+
it before it acts (`netplay/NETPLAY.md` section 15). Everything is in the studio's own
|
|
197
|
+
Worker and D1 (migration `0003_studio_office.sql`); homie.rocks stores none of it.
|
|
198
|
+
|
|
199
|
+
With player accounts (0.12.0), a signed-in player's play page names their account in its room ticket (an invited
|
|
200
|
+
player's names the invite and the account), so the office shows them by name and a kick holds the account on every
|
|
201
|
+
device; the owner's own passkey account (`homie-studio players owner`) counts as the owner.
|
|
202
|
+
|
|
142
203
|
## Setup status
|
|
143
204
|
|
|
144
205
|
```sh
|
|
145
|
-
npx -y @homie-rocks/studio@0.
|
|
206
|
+
npx -y @homie-rocks/studio@0.13.0 setup status --connector yes # before a studio exists
|
|
146
207
|
npx homie-studio setup status # in a studio (also: homie-studio doctor)
|
|
147
208
|
```
|
|
148
209
|
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -40,6 +40,19 @@
|
|
|
40
40
|
* homie-studio players (player accounts and guests with saves: counts, never a passkey or an email)
|
|
41
41
|
* homie-studio players owner [--revoke] (a one-time link that marks the owner's own player account as the owner's)
|
|
42
42
|
*
|
|
43
|
+
* homie-studio office [--url <site>] the back office: every live room of every game and who is in it, now
|
|
44
|
+
* homie-studio office link [--to <path>] a one-time link that signs the owner's browser in (the office; or
|
|
45
|
+
* --to /<game>/play: a private game on the owner's own phone)
|
|
46
|
+
* homie-studio office key [--hours 1] a key for the Homie MCP's owner tools (studio_office, room_kick, ...)
|
|
47
|
+
* homie-studio office announce "<text>" [--game <id>] [--room <code>] [--seconds 30]
|
|
48
|
+
* homie-studio office invite <game> [--label "<who>"] [--uses 1|<n>|any] [--count 1] [--days <n>]
|
|
49
|
+
* homie-studio office launch <game> private|invite|public [--remixable on|off] [--max <n>|game]
|
|
50
|
+
* homie-studio office kick <game> <room> <seat number | name> [--minutes 10]
|
|
51
|
+
* homie-studio office close <game> <room> [--minutes 10] [--reopen]
|
|
52
|
+
* (kick, close and launch are ASKED for: the owner confirms each with one
|
|
53
|
+
* tap in their own browser, from the link this prints)
|
|
54
|
+
* homie-studio office revoke (every office key, play ticket and pending ask ends)
|
|
55
|
+
*
|
|
43
56
|
* homie-studio progress start [<id>] [--what game|song|video] [--title "<what this build does>"]
|
|
44
57
|
* [--budget <dollars>] [--unit usd|credits] [--share]
|
|
45
58
|
* (a progress feed for one build; --share shows it in the Claude app
|
|
@@ -98,12 +111,13 @@ import { look } from '../lib/look.mjs';
|
|
|
98
111
|
import { importPort, planPort } from '../lib/port.mjs';
|
|
99
112
|
import { portCheck } from '../lib/port-check.mjs';
|
|
100
113
|
import { recordUpload, resolveMedia, typeOf } from '../lib/media.mjs';
|
|
101
|
-
import { ensureMigrations, newStudio } from '../lib/scaffold.mjs';
|
|
114
|
+
import { ensureMigrations, migrationWord, newStudio } from '../lib/scaffold.mjs';
|
|
102
115
|
import { lineDiff, upgradeApply, upgradePlan } from '../lib/upgrade.mjs';
|
|
103
116
|
import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, starters, workerDir } from '../lib/studio.mjs';
|
|
104
117
|
import { STUDIO_VERSION } from '../lib/version.mjs';
|
|
105
118
|
import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
|
|
106
119
|
import { playersOwner, playersShow } from '../lib/players.mjs';
|
|
120
|
+
import { officeAnnounce, officeClose, officeInvite, officeKey, officeKick, officeLaunch, officeLines, officeLink, officeRevoke, officeShow } from '../lib/office.mjs';
|
|
107
121
|
import { Feed, currentFeed, currentId, flushProgress, publicFeed, readFeed, recordChange, startProgress } from '../lib/progress.mjs';
|
|
108
122
|
import { formatStatus, setupStatus } from '../lib/doctor.mjs';
|
|
109
123
|
import { codexTarget, newCodex, writeCodexPage } from '../lib/codex.mjs';
|
|
@@ -112,7 +126,7 @@ import { restartWithProxy } from '../lib/net.mjs';
|
|
|
112
126
|
|
|
113
127
|
const argv = process.argv.slice(2);
|
|
114
128
|
const flags = new Map();
|
|
115
|
-
const BOOL_FLAGS = ['revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open'];
|
|
129
|
+
const BOOL_FLAGS = ['revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen'];
|
|
116
130
|
const positional = [];
|
|
117
131
|
for (let i = 0; i < argv.length; i++) {
|
|
118
132
|
const a = argv[i];
|
|
@@ -232,6 +246,28 @@ function print(result) {
|
|
|
232
246
|
case 'stats share':
|
|
233
247
|
lines.push(result.message);
|
|
234
248
|
break;
|
|
249
|
+
case 'office':
|
|
250
|
+
lines.push(...officeLines(result));
|
|
251
|
+
break;
|
|
252
|
+
case 'office link':
|
|
253
|
+
lines.push(`One-time link (until ${result.expiresAt}): ${result.link}`, result.use);
|
|
254
|
+
break;
|
|
255
|
+
case 'office key':
|
|
256
|
+
lines.push(`Office key (until ${result.expiresAt}): ${result.key}`, result.use);
|
|
257
|
+
break;
|
|
258
|
+
case 'office announce':
|
|
259
|
+
case 'office revoke':
|
|
260
|
+
lines.push(result.message);
|
|
261
|
+
break;
|
|
262
|
+
case 'office invite':
|
|
263
|
+
for (const i of result.invites) lines.push(`Invite ${i.code}${i.label ? ` (${i.label})` : ''}, ${i.maxUses ? `${i.maxUses} use${i.maxUses === 1 ? '' : 's'}` : 'any number of uses'}: ${i.link}`);
|
|
264
|
+
if (result.note) lines.push(result.note);
|
|
265
|
+
break;
|
|
266
|
+
case 'office launch':
|
|
267
|
+
case 'office kick':
|
|
268
|
+
case 'office close':
|
|
269
|
+
lines.push(result.asked ? `Asked: ${result.what}` : result.message, ...(result.asked ? [`Owner's one-tap link (until the ask ends, 15 min): ${result.link}`, result.use] : []));
|
|
270
|
+
break;
|
|
235
271
|
case 'setup status':
|
|
236
272
|
lines.push(formatStatus(result));
|
|
237
273
|
break;
|
|
@@ -408,6 +444,18 @@ async function main() {
|
|
|
408
444
|
if (cmd === 'stats' && sub === 'revoke') return statsRevoke(root, { url: flags.get('url') });
|
|
409
445
|
if (cmd === 'stats' && sub === 'share') return statsShare(root, positional[2]);
|
|
410
446
|
if (cmd === 'stats' && !sub) return statsShow(root, { url: flags.get('url'), range: flags.get('range'), game: flags.get('game'), song: flags.get('song'), video: flags.get('video') });
|
|
447
|
+
if (cmd === 'office') {
|
|
448
|
+
const url = flags.get('url');
|
|
449
|
+
if (!sub) return officeShow(root, { url });
|
|
450
|
+
if (sub === 'link') return officeLink(root, { url, to: flags.get('to') });
|
|
451
|
+
if (sub === 'key') return officeKey(root, { url, hours: flags.get('hours') });
|
|
452
|
+
if (sub === 'announce') return officeAnnounce(root, positional.slice(2).join(' '), { url, game: flags.get('game'), room: flags.get('room'), seconds: flags.get('seconds') });
|
|
453
|
+
if (sub === 'invite') return officeInvite(root, positional[2], { url, label: flags.get('label'), uses: flags.get('uses'), count: flags.get('count'), days: flags.get('days') });
|
|
454
|
+
if (sub === 'launch') return officeLaunch(root, positional[2], positional[3], { url, remixable: flags.get('remixable'), max: flags.get('max') });
|
|
455
|
+
if (sub === 'kick') return officeKick(root, positional[2], positional[3], positional.slice(4).join(' ') || undefined, { url, minutes: flags.get('minutes') });
|
|
456
|
+
if (sub === 'close') return officeClose(root, positional[2], positional[3], { url, minutes: flags.get('minutes'), reopen: flags.has('reopen') });
|
|
457
|
+
if (sub === 'revoke') return officeRevoke(root, { url });
|
|
458
|
+
}
|
|
411
459
|
if (cmd === 'media' && sub === 'put') return mediaPut(root, positional[2], flags.get('as'));
|
|
412
460
|
if (cmd === 'media' && sub === 'list') {
|
|
413
461
|
const studio = readStudio(root);
|
|
@@ -577,7 +625,7 @@ async function dev(root) {
|
|
|
577
625
|
if (!bin) return { ok: false, command: 'dev', why: 'run npm install in the studio first' };
|
|
578
626
|
const studio = readStudio(root);
|
|
579
627
|
const env = { ...process.env, WRANGLER_SEND_METRICS: 'false', CI: '1' };
|
|
580
|
-
for (const added of ensureMigrations(root)) log(`added ${added} (${
|
|
628
|
+
for (const added of ensureMigrations(root)) log(`added ${added} (${migrationWord(added)})`);
|
|
581
629
|
await new Promise((done) => {
|
|
582
630
|
const m = spawn(bin, ['d1', 'migrations', 'apply', 'DB', '--local'], { cwd: workerDir(root), env, stdio: ['ignore', 'ignore', 'inherit'] });
|
|
583
631
|
m.on('close', done);
|
package/lib/build.mjs
CHANGED
|
@@ -223,6 +223,8 @@ export async function build(root, { only = null, log = () => {} } = {}) {
|
|
|
223
223
|
// game.json "saves": true — player accounts and cloud saves (saves/SAVES.md): the play shell answers the game's
|
|
224
224
|
// saves calls, and the site's nav and the game's landing offer a player account.
|
|
225
225
|
...(g.saves === true || (g.saves && typeof g.saves === 'object') ? { saves: true } : {}),
|
|
226
|
+
// game.json `"launch": "private" | "invite"`: the game's launch state until its owner sets one live (the office).
|
|
227
|
+
...(g.launch === 'private' || g.launch === 'invite' ? { launch: g.launch } : {}),
|
|
226
228
|
landing: landingOf(g, join(dist, 'games', g.id), { videos: media.videos, songs: media.songs, log }),
|
|
227
229
|
};
|
|
228
230
|
});
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -23,7 +23,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
23
23
|
import { join } from 'node:path';
|
|
24
24
|
import { build } from './build.mjs';
|
|
25
25
|
import { repoOf } from './repo.mjs';
|
|
26
|
-
import { ensureLocalIgnored, ensureMigrations, wranglerConfig } from './scaffold.mjs';
|
|
26
|
+
import { ensureLocalIgnored, ensureMigrations, migrationWord, wranglerConfig } from './scaffold.mjs';
|
|
27
27
|
import { LOCAL_STATE, configPath, isWorkersDev, layoutOf, readLocal, readStudio, siteUrl, workerDir, writeLocal, writeStudio } from './studio.mjs';
|
|
28
28
|
|
|
29
29
|
const ANSI = /\u001b\[[0-9;]*m/g;
|
|
@@ -193,7 +193,7 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
193
193
|
if (!r2) step('no storage (R2): the studio needs none to run; `homie-studio storage add` adds it for large media');
|
|
194
194
|
|
|
195
195
|
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root) }));
|
|
196
|
-
for (const added of ensureMigrations(root)) step(`added ${added} (${
|
|
196
|
+
for (const added of ensureMigrations(root)) step(`added ${added} (${migrationWord(added)})`);
|
|
197
197
|
const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
|
|
198
198
|
if (migrate.code !== 0) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
|
|
199
199
|
step('D1 migrations applied');
|
|
@@ -293,7 +293,7 @@ export async function ciDeploy(root, { log = () => {} } = {}) {
|
|
|
293
293
|
const step = (what) => { steps.push({ what }); log(what); };
|
|
294
294
|
const refuse = (why, out) => ({ ok: false, command: 'deploy', ci: true, ...(explainCloudflare(out) ?? { why }), steps });
|
|
295
295
|
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 };
|
|
296
|
-
for (const added of ensureMigrations(root)) step(`added ${added} (${
|
|
296
|
+
for (const added of ensureMigrations(root)) step(`added ${added} (${migrationWord(added)})`);
|
|
297
297
|
const apply = () => w(['d1', 'migrations', 'apply', names.binding, '--remote']);
|
|
298
298
|
let migrate = apply();
|
|
299
299
|
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/office.mjs
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio office` — the studio's back office from the studio folder (worker/office.mjs says what it is).
|
|
3
|
+
*
|
|
4
|
+
* homie-studio office [--url <site>] every live room of every game and who is in it, now
|
|
5
|
+
* homie-studio office link [--to <path>] a one-time link that signs the owner's browser in (the office,
|
|
6
|
+
* or --to /<game>/play: a private game on the owner's phone)
|
|
7
|
+
* homie-studio office key [--hours 1] an office key for the Homie MCP's owner tools (studio_office,
|
|
8
|
+
* room_kick, room_announce, room_close, game_launch_state)
|
|
9
|
+
* homie-studio office announce "<text>" [--game <id>] [--room <code>] [--seconds 30]
|
|
10
|
+
* homie-studio office invite <game> [--label "<who>"] [--uses 1|<n>|any] [--count 1] [--days <n>]
|
|
11
|
+
* homie-studio office launch <game> private|invite|public [--remixable on|off] [--max <n>|game]
|
|
12
|
+
* homie-studio office kick <game> <room> <seat number | name> [--minutes 10]
|
|
13
|
+
* homie-studio office close <game> <room> [--minutes 10] [--reopen]
|
|
14
|
+
* homie-studio office revoke every office key ends; every play ticket and pending ask too
|
|
15
|
+
*
|
|
16
|
+
* THE PROOF OF OWNERSHIP IS THE STUDIO'S OWN CLOUDFLARE LOGIN, as for the stats: a key is minted here and only its
|
|
17
|
+
* SHA-256 goes into the studio's D1. Looking, announcing and inviting happen at once. Kick, mute, closing a room and
|
|
18
|
+
* a game's launch state, remix switch or room size are only ASKED for here: the answer is a one-time sign-in link
|
|
19
|
+
* that opens the ask in the owner's own browser, where one tap does it (the AI that ran this cannot).
|
|
20
|
+
*/
|
|
21
|
+
import { readStudio } from './studio.mjs';
|
|
22
|
+
import { runner } from './cloudflare.mjs';
|
|
23
|
+
import { drop, loopback, mint, siteOf } from './stats.mjs';
|
|
24
|
+
|
|
25
|
+
const HOUR = 3600_000;
|
|
26
|
+
const GAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
27
|
+
|
|
28
|
+
/** Call the studio's office API with a key that lives ten minutes and is dropped as soon as the answer is in. */
|
|
29
|
+
async function withKey(root, url, fn) {
|
|
30
|
+
const { studio, site } = siteOf(root, url);
|
|
31
|
+
if (!site) return { ok: false, 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`' };
|
|
32
|
+
const local = loopback(site);
|
|
33
|
+
const k = mint(root, studio, 'office', 10 * 60_000, { local });
|
|
34
|
+
if (!k.ok) return { ok: false, why: /no such table: stats_keys/.test(k.why) ? k.why : k.why };
|
|
35
|
+
try {
|
|
36
|
+
const call = async (path, body = null) => {
|
|
37
|
+
const res = await fetch(`${site}${path}`, {
|
|
38
|
+
method: body ? 'POST' : 'GET',
|
|
39
|
+
headers: { authorization: `Bearer ${k.key}`, accept: 'application/json', ...(body ? { 'content-type': 'application/json' } : {}) },
|
|
40
|
+
...(body ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(20_000),
|
|
41
|
+
});
|
|
42
|
+
const out = await res.json().catch(() => ({ ok: false, message: `${site}${path} answered ${res.status}` }));
|
|
43
|
+
return { status: res.status, ...out };
|
|
44
|
+
};
|
|
45
|
+
return await fn(call, { studio, site, local });
|
|
46
|
+
} catch (error) {
|
|
47
|
+
return { ok: false, why: `could not reach ${site}: ${error.message}` };
|
|
48
|
+
} finally { drop(root, studio, k.hash, { local }); }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A one-time sign-in link for the owner's browser that lands on `to` (30 minutes). */
|
|
52
|
+
function signinTo(root, url, to) {
|
|
53
|
+
const { studio, site } = siteOf(root, url);
|
|
54
|
+
if (!site) return { ok: false, why: 'this studio has no live site yet: `npm run deploy` first' };
|
|
55
|
+
const k = mint(root, studio, 'signin', 30 * 60_000, { local: loopback(site) });
|
|
56
|
+
if (!k.ok) return k;
|
|
57
|
+
return { ok: true, site, link: `${site}/_studio/signin?k=${k.key}${to && to !== '/_studio/stats' ? `&to=${encodeURIComponent(to)}` : ''}`, expiresAt: k.expiresAt };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const askedFor = (root, url, r, command) => {
|
|
61
|
+
if (!r.ok) return { ok: false, command, why: r.message ?? r.why ?? `the studio said ${r.error ?? r.status}` };
|
|
62
|
+
if (r.needs !== 'owner' || !r.ask) return { ok: true, command, done: true, result: r, message: r.what ? `Done: ${r.what}` : 'Done.' };
|
|
63
|
+
const link = signinTo(root, url, `/_studio/confirm/${r.ask.id}`);
|
|
64
|
+
return {
|
|
65
|
+
ok: true, command, asked: true, ask: r.ask, what: r.ask.what,
|
|
66
|
+
link: link.ok ? link.link : r.ask.confirm,
|
|
67
|
+
use: `Waiting for the owner: ${r.ask.what} Give the owner this link to open in their own browser: it signs that browser in (once, within 30 minutes) and shows the ask with one button. It lasts 15 minutes; nothing happens unless the owner taps.`,
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export async function officeShow(root, { url } = {}) {
|
|
72
|
+
const r = await withKey(root, url, (call) => call('/_studio/api/office'));
|
|
73
|
+
if (!r.ok) return { ok: false, command: 'office', why: r.why ?? r.message ?? 'the office did not answer' };
|
|
74
|
+
return { command: 'office', ...r, ok: true };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function officeLink(root, { url, to } = {}) {
|
|
78
|
+
const target = to && /^\/[A-Za-z0-9_/-]{1,80}$/.test(String(to)) ? String(to) : '/_studio/office';
|
|
79
|
+
const r = signinTo(root, url, target);
|
|
80
|
+
if (!r.ok) return { ok: false, command: 'office link', why: r.why };
|
|
81
|
+
return {
|
|
82
|
+
...r, command: 'office link', to: target,
|
|
83
|
+
use: 'Give this link to the studio\'s owner to open in their own browser: it works once, within 30 minutes, and keeps that browser signed in as the owner for 30 days (the office, the stats, and the owner\'s tools inside their own games). It is theirs alone; do not post it anywhere.',
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function officeKey(root, { url, hours } = {}) {
|
|
88
|
+
const { studio, site } = siteOf(root, url);
|
|
89
|
+
if (!site) return { ok: false, command: 'office key', why: 'this studio has no live site yet: `npm run deploy` first' };
|
|
90
|
+
const h = Math.max(1, Math.min(24 * 30, Math.floor(Number(hours) || 1)));
|
|
91
|
+
const k = mint(root, studio, 'office', h * HOUR, { local: loopback(site) });
|
|
92
|
+
if (!k.ok) return { ok: false, command: 'office key', why: k.why };
|
|
93
|
+
return {
|
|
94
|
+
ok: true, command: 'office key', site, key: k.key, expiresAt: k.expiresAt, hours: h,
|
|
95
|
+
use: `Pass { "site": "${site}", "key": "<this key>" } to the Homie MCP's owner tools (studio_office, room_announce, room_kick, room_close, game_launch_state). With it the AI sees the live rooms, announces and makes invites; a kick, a closed room or a launch state change is only asked for, and the owner confirms it with one tap. It also reads the stats (studio_stats). It ends at ${k.expiresAt}; \`homie-studio office revoke\` ends it sooner. Never paste it anywhere else.`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function officeAnnounce(root, text, { url, game, room, seconds } = {}) {
|
|
100
|
+
if (!String(text ?? '').trim()) return { ok: false, command: 'office announce', why: 'usage: homie-studio office announce "<text>" [--game <id>] [--room <code>]' };
|
|
101
|
+
const r = await withKey(root, url, (call) => call('/_studio/api/announce', { text, ...(game ? { game } : {}), ...(room ? { room } : {}), ...(seconds ? { seconds: Number(seconds) } : {}) }));
|
|
102
|
+
return r.ok ? { ok: true, command: 'office announce', ...r, message: `Announced to ${r.people ?? 0} people in ${r.rooms ?? 0} room(s).` } : { ok: false, command: 'office announce', why: r.message ?? r.why };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function officeInvite(root, game, { url, label, uses, count, days } = {}) {
|
|
106
|
+
if (!GAME.test(String(game ?? ''))) return { ok: false, command: 'office invite', why: 'usage: homie-studio office invite <game> [--label "<who>"] [--uses 1|<n>|any] [--count 1]' };
|
|
107
|
+
const r = await withKey(root, url, (call) => call('/_studio/api/invites', {
|
|
108
|
+
game, ...(label ? { label } : {}), uses: uses === 'any' ? 'any' : uses ? Number(uses) : 1, ...(count ? { count: Number(count) } : {}), ...(days ? { days: Number(days) } : {}),
|
|
109
|
+
}));
|
|
110
|
+
if (!r.ok) return { ok: false, command: 'office invite', why: r.message ?? r.why };
|
|
111
|
+
return { ok: true, command: 'office invite', game, launch: r.launch, invites: r.invites, note: r.launch === 'invite' ? null : `${game} is ${r.launch} now: invites let people in once it is an invite-only beta (homie-studio office launch ${game} invite).` };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function officeLaunch(root, game, state, { url, remixable, max } = {}) {
|
|
115
|
+
if (!GAME.test(String(game ?? '')) || (state !== undefined && state !== null && !['private', 'invite', 'public'].includes(state))) {
|
|
116
|
+
return { ok: false, command: 'office launch', why: 'usage: homie-studio office launch <game> private|invite|public [--remixable on|off] [--max <n>|game]' };
|
|
117
|
+
}
|
|
118
|
+
const body = { game, ...(state ? { launch: state } : {}), ...(remixable !== undefined ? { remix: remixable === 'on' || remixable === true } : {}), ...(max !== undefined ? { maxPlayers: max === 'game' ? null : Number(max) } : {}) };
|
|
119
|
+
const r = await withKey(root, url, (call) => call('/_studio/api/game', body));
|
|
120
|
+
return askedFor(root, url, r, 'office launch');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function officeKick(root, game, room, who, { url, minutes } = {}) {
|
|
124
|
+
if (!GAME.test(String(game ?? '')) || !room || who === undefined) return { ok: false, command: 'office kick', why: 'usage: homie-studio office kick <game> <room> <seat number | name> [--minutes 10]' };
|
|
125
|
+
const r = await withKey(root, url, async (call) => {
|
|
126
|
+
const office = await call('/_studio/api/office');
|
|
127
|
+
const g = (office.games ?? []).find((x) => x.id === game);
|
|
128
|
+
const rm = (g?.rooms ?? []).find((x) => x.room === room || x.label.toLowerCase() === String(room).toLowerCase());
|
|
129
|
+
if (!rm) return { ok: false, message: `nobody is in ${room} of ${game} now (homie-studio office lists the rooms)` };
|
|
130
|
+
const seat = /^\d+$/.test(String(who)) ? Number(who) - 1 : null;
|
|
131
|
+
const c = rm.clients.find((x) => (seat !== null && x.seat === seat) || String(x.name).toLowerCase() === String(who).toLowerCase());
|
|
132
|
+
if (!c) return { ok: false, message: `no "${who}" in ${rm.label} (seats are numbered from 1)` };
|
|
133
|
+
return call('/_studio/api/kick', { game, room: rm.room, id: c.id, minutes: Number(minutes) || 10 });
|
|
134
|
+
});
|
|
135
|
+
return askedFor(root, url, r, 'office kick');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function officeClose(root, game, room, { url, minutes, reopen } = {}) {
|
|
139
|
+
if (!GAME.test(String(game ?? '')) || !room) return { ok: false, command: 'office close', why: 'usage: homie-studio office close <game> <room> [--minutes 10] [--reopen]' };
|
|
140
|
+
const r = await withKey(root, url, (call) => call('/_studio/api/close', { game, room, minutes: Number(minutes) || 10, reopen: Boolean(reopen) }));
|
|
141
|
+
return askedFor(root, url, r, 'office close');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Every office key ends, and the office secret is made anew: every play ticket and every signed control ends with it. */
|
|
145
|
+
export function officeRevoke(root, { url } = {}) {
|
|
146
|
+
const { studio, site } = siteOf(root, url);
|
|
147
|
+
const local = site ? loopback(site) : false;
|
|
148
|
+
const w = runner(root, studio.cloudflare?.accountId && !local ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
|
|
149
|
+
const res = w(['d1', 'execute', studio.cloudflare.d1, local ? '--local' : '--remote', '--command', "DELETE FROM stats_keys WHERE kind = 'office'; DELETE FROM meta WHERE key = 'office_key'; UPDATE office_asks SET state = 'expired' WHERE state = 'pending';"]);
|
|
150
|
+
return res.code === 0
|
|
151
|
+
? { ok: true, command: 'office revoke', site, message: 'Every office key has ended, and every play ticket and pending ask with it (a browser let into a private or invite-only game gets a new ticket the next time it opens the game). Invite passes and the owner\'s signed-in browsers stay; `homie-studio stats revoke` ends those sessions.' }
|
|
152
|
+
: { ok: false, command: 'office revoke', why: res.out.trim().split('\n').slice(-3).join(' ') };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** One line per room for a person (the CLI's print). */
|
|
156
|
+
export function officeLines(r) {
|
|
157
|
+
const lines = [`${r.studio}: ${r.playing ? `${r.playing} playing now` : 'nobody playing right now'} (${r.site})`];
|
|
158
|
+
const now = r.now ?? Date.now();
|
|
159
|
+
const mins = (ms) => `${Math.max(0, Math.round(ms / 60000))} min`;
|
|
160
|
+
for (const g of r.games ?? []) {
|
|
161
|
+
lines.push(` ${g.name} (${g.id}): ${g.launch}${g.launch === 'public' ? '' : ' (not listed)'}, ${g.remix ? 'remixable' : 'source closed'}, rooms of ${g.maxPlayers}${g.maxSet ? ` (the game's own: ${g.seats})` : ''}`);
|
|
162
|
+
for (const i of (g.invites ?? []).filter((x) => x.open)) lines.push(` invite ${i.code}${i.label ? ` (${i.label})` : ''}: ${i.uses}${i.maxUses ? ` of ${i.maxUses}` : ''} used · ${i.link}`);
|
|
163
|
+
if (!g.rooms.length) lines.push(' no live rooms');
|
|
164
|
+
for (const rm of g.rooms) {
|
|
165
|
+
const round = rm.round ? `round ${rm.round.n} ${rm.round.phase}` : 'no round';
|
|
166
|
+
lines.push(` ${rm.label}${rm.public ? '' : ' (named)'}: ${rm.players}/${rm.max} players, ${rm.bots} bots, ${round}, up ${rm.openedAt ? mins(now - rm.openedAt) : '?'}${rm.closedUntil ? `, CLOSED for ${mins(rm.closedUntil - now)}` : ''}`);
|
|
167
|
+
for (const c of rm.clients) lines.push(` ${c.seat !== null ? `seat ${c.seat + 1}` : 'watching'}: ${c.name} · ${c.device}${c.role === 'host' ? ' · host' : ''} · ${c.as}${c.muted ? ' · muted' : ''} · here ${mins(now - c.joinedAt)}`);
|
|
168
|
+
for (const b of rm.bans ?? []) lines.push(` kicked: ${b.name} (back in ${mins(b.until - now)})`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return lines;
|
|
172
|
+
}
|
package/lib/scaffold.mjs
CHANGED
|
@@ -25,6 +25,7 @@ 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
27
|
import { PLAYERS_MIGRATION, PLAYERS_MIGRATION_FILE } from '../worker/players.mjs';
|
|
28
|
+
import { OFFICE_MIGRATION, OFFICE_MIGRATION_FILE } from '../worker/office-schema.mjs';
|
|
28
29
|
import { themeFile } from './site.mjs';
|
|
29
30
|
import { PACKAGE_ROOT } from './studio.mjs';
|
|
30
31
|
|
|
@@ -222,6 +223,28 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
222
223
|
Cloudflare, Chrome, ffmpeg, GitHub, ElevenLabs, fal), what each unlocks, and the exact fix. Optional ones never
|
|
223
224
|
block anything.
|
|
224
225
|
|
|
226
|
+
## Running live games (the back office)
|
|
227
|
+
|
|
228
|
+
- The owner runs the studio's live games from \`/_studio/office\` (\`npx --no-install homie-studio office link\` gives
|
|
229
|
+
the owner a one-time sign-in link): every live room of every game, who is in it (handles; accounts once players
|
|
230
|
+
sign in), bots, the round and uptime, refreshing by itself; Kick (that player cannot come back to that room for
|
|
231
|
+
the minutes chosen), Mute (their chat and emotes reach nobody), Announce (one line every player sees), Close a
|
|
232
|
+
room; and per game: launch state, Remixable, players per room and invites.
|
|
233
|
+
- **Launch states:** \`private\` (only the owner, signed in; \`office link --to /<id>/play\` signs the owner's phone
|
|
234
|
+
in), \`invite\` (an invite-only beta: \`office invite <id>\` makes invite links and codes, each for one browser or
|
|
235
|
+
as many as \`--uses\` says), \`public\` (the default; listed). A game that is not public is in no list and not in
|
|
236
|
+
the directory manifest, so the directory drops it the next time it reads the studio. A new game stays private
|
|
237
|
+
from its first deploy with \`"launch": "private"\` in its game.json. **Remixable** publishes or withdraws its source.
|
|
238
|
+
- The owner is recognised in their own games: signed in, their play page has a small Owner button (tap a player:
|
|
239
|
+
Mute, Kick; Announce). Nobody else's page has it.
|
|
240
|
+
- **From the AI:** \`npx --no-install homie-studio office\` lists who is playing now; \`office announce "<text>"\` and
|
|
241
|
+
\`office invite <id>\` happen at once; \`office kick\`, \`office close\` and \`office launch\` only ASK, and print a
|
|
242
|
+
one-time link that opens the ask in the owner's own browser, where one tap does it. An ask is not done until the
|
|
243
|
+
owner tapped. \`office key\` gives a key for the Homie MCP's owner tools (\`studio_office\`, \`room_announce\`,
|
|
244
|
+
\`room_kick\`, \`room_close\`, \`game_launch_state\`), which ask the same way; never paste a key anywhere else.
|
|
245
|
+
- A game can listen (\`NETPLAY.md\` section 15): \`net.on('announce', …)\`, \`net.isMuted(seat)\` to hide a muted
|
|
246
|
+
player's chat, and \`net.pickPlayer(seat)\` when a player is clicked (the owner's page opens their card).
|
|
247
|
+
|
|
225
248
|
## Rules
|
|
226
249
|
|
|
227
250
|
- Keys stay in the providers' own logins (Wrangler, ElevenLabs, fal) or the OS
|
|
@@ -397,6 +420,7 @@ export { default, Table, Lobby } from '@homie-rocks/studio/worker';
|
|
|
397
420
|
'site/migrations/0001_studio.sql': MIGRATION,
|
|
398
421
|
[`site/migrations/${STATS_MIGRATION_FILE}`]: STATS_MIGRATION,
|
|
399
422
|
[`site/migrations/${PLAYERS_MIGRATION_FILE}`]: PLAYERS_MIGRATION,
|
|
423
|
+
[`site/migrations/${OFFICE_MIGRATION_FILE}`]: OFFICE_MIGRATION,
|
|
400
424
|
'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
|
|
401
425
|
'.claude/skills/.gitkeep': '',
|
|
402
426
|
};
|
|
@@ -466,6 +490,18 @@ export function ensureStatsMigration(root) {
|
|
|
466
490
|
return `site/migrations/${STATS_MIGRATION_FILE}`;
|
|
467
491
|
}
|
|
468
492
|
|
|
493
|
+
/**
|
|
494
|
+
* A studio made before 0.13.0 has no back-office migration (worker/office.mjs): add it, so the next
|
|
495
|
+
* `d1 migrations apply` makes its tables. Returns the file it wrote, or null when it was there.
|
|
496
|
+
*/
|
|
497
|
+
export function ensureOfficeMigration(root) {
|
|
498
|
+
const file = join(root, 'site', 'migrations', OFFICE_MIGRATION_FILE);
|
|
499
|
+
if (existsSync(file)) return null;
|
|
500
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
501
|
+
writeFileSync(file, OFFICE_MIGRATION);
|
|
502
|
+
return `site/migrations/${OFFICE_MIGRATION_FILE}`;
|
|
503
|
+
}
|
|
504
|
+
|
|
469
505
|
/**
|
|
470
506
|
* A studio made before 0.12.0 has no players migration (player accounts and cloud saves, saves/SAVES.md): add it,
|
|
471
507
|
* so the next `d1 migrations apply` makes the tables. Returns the file it wrote, or null when it was there.
|
|
@@ -480,7 +516,12 @@ export function ensurePlayersMigration(root) {
|
|
|
480
516
|
|
|
481
517
|
/** Every migration the template owns that this studio lacks, added: the files written (deploy and dev say so). */
|
|
482
518
|
export function ensureMigrations(root) {
|
|
483
|
-
return [ensureStatsMigration(root), ensurePlayersMigration(root)].filter(Boolean);
|
|
519
|
+
return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root)].filter(Boolean);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** What a migration file the template added is for, in a few words (deploy and dev say it). */
|
|
523
|
+
export function migrationWord(file) {
|
|
524
|
+
return /players/.test(file) ? 'player accounts and cloud saves' : /office/.test(file) ? 'the back office: launch states, invites, the owner\'s controls' : 'the studio\'s own stats: counts, never tracks';
|
|
484
525
|
}
|
|
485
526
|
|
|
486
527
|
/**
|
package/lib/stats.mjs
CHANGED
|
@@ -48,7 +48,7 @@ export function mint(root, studio, kind, ttlMs, { local }) {
|
|
|
48
48
|
return { ok: true, key, hash, expiresAt: new Date(now + ttlMs).toISOString() };
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
function drop(root, studio, hash, { local }) {
|
|
51
|
+
export function drop(root, studio, hash, { local }) {
|
|
52
52
|
const w = runner(root, studio.cloudflare?.accountId && !local ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
|
|
53
53
|
w(['d1', 'execute', studio.cloudflare.d1, local ? '--local' : '--remote', '--command', `DELETE FROM stats_keys WHERE hash = '${hash}';`]);
|
|
54
54
|
}
|
|
@@ -247,6 +247,31 @@
|
|
|
247
247
|
"## Beta": "9d02638207a26e73"
|
|
248
248
|
}
|
|
249
249
|
}
|
|
250
|
+
},
|
|
251
|
+
"0.13.0": {
|
|
252
|
+
"files": {
|
|
253
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
254
|
+
"README.md": "684b3cca8001673c",
|
|
255
|
+
"games/README.md": "5e5e942baa7e0372",
|
|
256
|
+
"music/README.md": "b8a828de9311bd28",
|
|
257
|
+
"videos/README.md": "70614388cbb203c7",
|
|
258
|
+
"posts/README.md": "37841aae476bd540",
|
|
259
|
+
"site/README.md": "ca131cd0472464fb",
|
|
260
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
261
|
+
},
|
|
262
|
+
"sections": {
|
|
263
|
+
"AGENTS.md": {
|
|
264
|
+
"(intro)": "214aa46eb3a895d5",
|
|
265
|
+
"## Layout": "f14d641159480e6c",
|
|
266
|
+
"## Commands (all through the pinned CLI in node_modules)": "3a3d3bef7e8d1a50",
|
|
267
|
+
"## The site": "7d3525ba39b7f262",
|
|
268
|
+
"## Making games": "63069b31c87a2aa2",
|
|
269
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
270
|
+
"## Running live games (the back office)": "f0566f07cff04113",
|
|
271
|
+
"## Rules": "969318425c99f5a5",
|
|
272
|
+
"## Beta": "9d02638207a26e73"
|
|
273
|
+
}
|
|
274
|
+
}
|
|
250
275
|
}
|
|
251
276
|
}
|
|
252
277
|
}
|
package/lib/upgrade.mjs
CHANGED
|
@@ -170,11 +170,14 @@ export function upgradePlan(root, { history = readHistory() } = {}) {
|
|
|
170
170
|
}
|
|
171
171
|
const have = sections(mine);
|
|
172
172
|
const tsecs = sections(want);
|
|
173
|
+
const adding = new Set();
|
|
173
174
|
for (const [k, t] of tsecs.entries()) {
|
|
174
175
|
const at = have.find((s) => s.heading === t.heading);
|
|
175
176
|
if (!at) {
|
|
176
|
-
// After the nearest template section before it that the studio has
|
|
177
|
-
|
|
177
|
+
// After the nearest template section before it that the studio has (or that this plan adds just before it, so
|
|
178
|
+
// two new sections in a row keep the template's order); else before the first one after it.
|
|
179
|
+
const before = tsecs.slice(0, k).reverse().find((p) => have.some((s) => s.heading === p.heading) || adding.has(p.heading));
|
|
180
|
+
adding.add(t.heading);
|
|
178
181
|
const after = before ? null : tsecs.slice(k + 1).find((p) => have.some((s) => s.heading === p.heading));
|
|
179
182
|
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
183
|
continue;
|