@homie-rocks/studio 0.19.1 → 0.20.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/CHANGELOG.md +927 -0
- package/README.md +49 -3
- package/bin/homie-studio.mjs +57 -2
- package/lab/harness.js +371 -0
- package/lab/lab.ts +180 -0
- package/lab/page/index.html +83 -0
- package/lab/page/lab.css +174 -0
- package/lab/page/page.js +802 -0
- package/lab/take.js +189 -0
- package/lib/build.mjs +61 -44
- package/lib/changelog.mjs +204 -0
- package/lib/jobs.mjs +3 -2
- package/lib/lab-check.mjs +212 -0
- package/lib/lab.mjs +546 -0
- package/lib/mcp-tools.mjs +72 -6
- package/lib/mcp.mjs +4 -2
- package/lib/scaffold.mjs +7 -1
- package/lib/studio.mjs +14 -0
- package/lib/template-history.json +74 -0
- package/lib/upgrade.mjs +9 -1
- package/mcp/ui/card.css +2 -0
- package/mcp/ui/lab.js +67 -0
- package/mcp/ui/studio.js +9 -1
- package/package.json +7 -1
- package/port/index.ts +2 -0
- package/starters/ember-vale/lab.json +22 -0
- package/starters/ember-vale/src/main.ts +271 -19
- package/starters/ember-vale/tunables.json +13 -0
- package/starters/gem-rush/lab.json +19 -0
- package/starters/gem-rush/src/main.ts +213 -38
- package/starters/gem-rush/tunables.json +13 -0
- package/worker/version.mjs +1 -1
package/lib/mcp-tools.mjs
CHANGED
|
@@ -28,15 +28,20 @@ import { cliJob, findNode, getJob, installJob, installed, jobView, runningJobs,
|
|
|
28
28
|
import { Feed, currentId, publicFeed, readFeed, startProgress } from './progress.mjs';
|
|
29
29
|
import { newStudio, slugify } from './scaffold.mjs';
|
|
30
30
|
import { repoFromUrl, studioRepo } from './repo.mjs';
|
|
31
|
-
import { GAME_ID, findStudio, listGames, readLocal, readStudio, siteUrl, workerDir } from './studio.mjs';
|
|
31
|
+
import { GAME_ID, PACKAGE_ROOT, findStudio, listGames, readLocal, readStudio, siteUrl, workerDir } from './studio.mjs';
|
|
32
32
|
import { STUDIO_VERSION } from './version.mjs';
|
|
33
|
+
import { compareVersions, whatsNew, whatsNewLines } from './changelog.mjs';
|
|
34
|
+
import { pinnedVersion } from './upgrade.mjs';
|
|
33
35
|
import { AgentSeat } from './agent-seat.mjs';
|
|
36
|
+
import { LAB_PORT, runningLab } from './lab.mjs';
|
|
37
|
+
import { pictureFor } from './pictures.mjs';
|
|
34
38
|
|
|
35
39
|
export const UI = Object.freeze({
|
|
36
40
|
setup: 'ui://homie-studio/setup',
|
|
37
41
|
build: 'ui://homie-studio/build',
|
|
38
42
|
studio: 'ui://homie-studio/studio',
|
|
39
43
|
codex: 'ui://homie-studio/codex',
|
|
44
|
+
lab: 'ui://homie-studio/lab',
|
|
40
45
|
});
|
|
41
46
|
|
|
42
47
|
const inside = (base, p) => p === base || p.startsWith(base.endsWith(sep) ? base : `${base}${sep}`);
|
|
@@ -120,8 +125,8 @@ const ok = (text, structured, extra = {}) => ({ content: [{ type: 'text', text }
|
|
|
120
125
|
const fail = (text, structured) => ({ content: [{ type: 'text', text }], ...(structured ? { structuredContent: structured } : {}), isError: true });
|
|
121
126
|
|
|
122
127
|
/** Run the pinned CLI and wait a while; { ended, job, result }. */
|
|
123
|
-
async function cli(ctx, root, label, args, { wait = ctx.waitMs, onEnd = null } = {}) {
|
|
124
|
-
const job = cliJob(root, label, args, { onEnd });
|
|
128
|
+
async function cli(ctx, root, label, args, { wait = ctx.waitMs, onEnd = null, cli: bin = null } = {}) {
|
|
129
|
+
const job = cliJob(root, label, args, { onEnd, ...(bin ? { cli: bin } : {}) });
|
|
125
130
|
const ended = await waitJob(job, wait);
|
|
126
131
|
return { ended, job, result: job.result };
|
|
127
132
|
}
|
|
@@ -245,6 +250,18 @@ function published(root, kind) {
|
|
|
245
250
|
} catch { return []; }
|
|
246
251
|
}
|
|
247
252
|
|
|
253
|
+
/**
|
|
254
|
+
* When the studio pins an older @homie-rocks/studio than this one: the pin, this version, and what's new between them
|
|
255
|
+
* (this package's own CHANGELOG.md). null when it is on this version, a newer one, or a link to a checkout.
|
|
256
|
+
*/
|
|
257
|
+
export function behindOf(root) {
|
|
258
|
+
let pkg = null;
|
|
259
|
+
try { pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); } catch { return null; }
|
|
260
|
+
const pinned = pinnedVersion(pkg?.devDependencies?.['@homie-rocks/studio'] ?? pkg?.dependencies?.['@homie-rocks/studio']);
|
|
261
|
+
if (!pinned || compareVersions(pinned, STUDIO_VERSION) >= 0) return null;
|
|
262
|
+
return { pinned, here: STUDIO_VERSION, whatsNew: whatsNew(pinned, STUDIO_VERSION) };
|
|
263
|
+
}
|
|
264
|
+
|
|
248
265
|
async function studioCard(ctx, root) {
|
|
249
266
|
const studio = readStudio(root);
|
|
250
267
|
const dev = devOf(root);
|
|
@@ -282,6 +299,8 @@ async function studioCard(ctx, root) {
|
|
|
282
299
|
games, songs: published(root, 'music'), videos: published(root, 'videos'), posts,
|
|
283
300
|
dev: dev ? { url: dev.url } : null, site: live, rooms, demo, installed: installed(root),
|
|
284
301
|
};
|
|
302
|
+
const behind = behindOf(root);
|
|
303
|
+
if (behind) data.behind = { ...behind, whatsNew: behind.whatsNew ? { ...behind.whatsNew, versions: behind.whatsNew.versions.slice(0, 6).map(({ version, summary, upgrade }) => ({ version, summary, upgrade: upgrade.slice(0, 2) })) } : null };
|
|
285
304
|
const text = [
|
|
286
305
|
`${studio.name} (${root})`,
|
|
287
306
|
games.length ? `Games: ${games.map((g) => `${g.name} (${g.id}${live ? (g.live ? ', live' : ', not deployed yet') : ''})`).join(', ')}` : 'No game yet: its home page says "First game coming soon".',
|
|
@@ -289,6 +308,10 @@ async function studioCard(ctx, root) {
|
|
|
289
308
|
live ? `Live: ${live}/` : 'Not online yet (studio_deploy).',
|
|
290
309
|
...(rooms.length ? [`Live rooms: ${rooms.map((r) => `${r.game} ${r.room} (${r.players})`).join(', ')}`] : []),
|
|
291
310
|
...(demo ? [`See a working game meanwhile: ${demo.name}, ${demo.play}`] : []),
|
|
311
|
+
...(behind ? [
|
|
312
|
+
`Toolkit: this studio pins @homie-rocks/studio ${behind.pinned}; this Homie is ${behind.here}. Tell the person what is new (below) and offer the upgrade: studio_run { "args": ["upgrade"] } shows the plan and changes nothing; with their yes, ["upgrade","--apply"], then studio_install.`,
|
|
313
|
+
...whatsNewLines(behind.whatsNew, { maxVersions: 6, maxNotes: 6 }),
|
|
314
|
+
] : []),
|
|
292
315
|
].join('\n');
|
|
293
316
|
return ok(text, data);
|
|
294
317
|
}
|
|
@@ -853,6 +876,46 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
853
876
|
] });
|
|
854
877
|
},
|
|
855
878
|
},
|
|
879
|
+
{
|
|
880
|
+
name: 'game_lab', title: 'Open the Game Lab',
|
|
881
|
+
description: 'The Game Lab for one mechanic of a game (a jump, a hit, a dash): one short take played in New (the working tree) beside Today (the last commit) on one clock, slowed down or a frame at a time, with its phases, graphs and live sliders that write kept values into the game\'s tunables.json. Starts the lab on this computer, plays the take headless once (New against Today: phases, peaks, whether replays match, JavaScript per frame) and answers with a card: a still of both builds at the take\'s busiest moment and Open. The game must call @homie-rocks/studio/lab (the starters do); studio_guide { "topic": "lab" } has the whole method. The person opens the lab from the card or the link; do not open a browser.',
|
|
882
|
+
inputSchema: { type: 'object', properties: { game: str('The game\'s id (default: the only game)'), take: str('Optional: a take in games/<id>/lab.json (default: its default)'), today: str('Optional: the git ref Today is built from (default HEAD)'), ...STUDIO_ARG } },
|
|
883
|
+
annotations: { title: 'Open the Game Lab', ...RW }, _meta: ui(UI.lab),
|
|
884
|
+
run: async (a) => {
|
|
885
|
+
const root = ctx.root(a.studio);
|
|
886
|
+
const need = needsInstall(ctx, root); if (need) return need;
|
|
887
|
+
const games = listGames(root);
|
|
888
|
+
const game = a.game ?? (games.length === 1 ? games[0].id : null);
|
|
889
|
+
if (!game || !games.some((g) => g.id === game)) return fail(games.length ? `which game? ${games.map((g) => g.id).join(', ')}` : 'there is no game yet');
|
|
890
|
+
const ref = a.today ? String(a.today) : 'HEAD';
|
|
891
|
+
let lab = await runningLab(root);
|
|
892
|
+
if (!lab) {
|
|
893
|
+
const port = await freePort(LAB_PORT);
|
|
894
|
+
const job = cliJob(root, `the Game Lab, here (port ${port})`, ['lab', game, '--port', String(port), ...(ref !== 'HEAD' ? ['--today', ref] : [])]);
|
|
895
|
+
const until = Date.now() + 20_000;
|
|
896
|
+
while (!lab && Date.now() < until && !job.endedAt) { await new Promise((r) => setTimeout(r, 400)); lab = await runningLab(root); }
|
|
897
|
+
if (!lab) return fail(`the Game Lab did not start: ${whyOf(job)}`);
|
|
898
|
+
}
|
|
899
|
+
const q = new URLSearchParams({ ...(a.take ? { take: String(a.take) } : {}), ...(ref !== 'HEAD' ? { today: ref } : {}) }).toString();
|
|
900
|
+
const url = `${lab.url}/${game}/${q ? `?${q}` : ''}`;
|
|
901
|
+
const r = await cli(ctx, root, `Game Lab check of ${game}`, ['lab', 'check', game, ...(a.take ? ['--take', String(a.take)] : []), ...(ref !== 'HEAD' ? ['--today', ref] : [])]);
|
|
902
|
+
if (!r.ended) return ok(`The Game Lab is running: ${url}\nIts headless check is still going (job ${r.job.id}); the person can open the lab now.`, { kind: 'lab', game, url, running: true });
|
|
903
|
+
const c = r.result;
|
|
904
|
+
if (!c?.ok) return fail(`The Game Lab is running at ${url}, but its check failed: ${whyOf(r.job)}`, { kind: 'lab', game, url });
|
|
905
|
+
let still = null;
|
|
906
|
+
try { const p = pictureFor(resolve(root, c.still), { label: c.still, max: 420 * 1024 }); still = `data:${p.mimeType};base64,${p.data}`; } catch { still = null; }
|
|
907
|
+
const name = games.find((g) => g.id === game)?.name ?? game;
|
|
908
|
+
const det = (x) => (x === null ? 'replays match' : `replays DIFFER from frame ${x}`);
|
|
909
|
+
return ok([
|
|
910
|
+
`The Game Lab for ${name}: ${url} (on this computer; the person opens it from the card or this link).`,
|
|
911
|
+
`Take "${c.take ?? '(none)'}", ${c.frames} frames at ${c.fps} fps.`,
|
|
912
|
+
`New: ${c.phases.new.join(' · ') || 'no phases yet'} (${det(c.deterministic.new)}; JavaScript ${c.cost.new?.mean} ms a frame, p95 ${c.cost.new?.p95}).`,
|
|
913
|
+
c.phases.today ? `Today (${c.today}): ${c.phases.today.join(' · ') || 'no phases'} (${det(c.deterministic.today)}; JavaScript ${c.cost.today?.mean} ms, p95 ${c.cost.today?.p95}).` : `Today: ${c.todayNote ?? 'none'}.`,
|
|
914
|
+
`Report: ${c.report}; pictures: ${c.sheet} (file_read shows it).`,
|
|
915
|
+
...(c.errors?.length ? [`Errors: ${c.errors.join('; ')}`] : []),
|
|
916
|
+
].join('\n'), { kind: 'lab', game, name, url, take: c.take, frames: c.frames, fps: c.fps, phases: c.phases, timeline: c.timeline, deterministic: c.deterministic, cost: c.cost, today: c.today, todayNote: c.todayNote, report: c.report, sheet: c.sheet, still, errors: c.errors ?? [] });
|
|
917
|
+
},
|
|
918
|
+
},
|
|
856
919
|
{
|
|
857
920
|
name: 'studio_deploy', title: 'Put the studio online',
|
|
858
921
|
description: 'Put the studio\'s site online on the studio\'s OWN Cloudflare account: one Worker, one D1 database and two Durable Objects, free plan, no payment method. Call it with plan: true first and tell the person in two or three lines what it creates and costs. If Cloudflare is not signed in, cloudflare_login opens it in their browser to approve once. Runs in the background with the build card.',
|
|
@@ -948,18 +1011,21 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
948
1011
|
},
|
|
949
1012
|
{
|
|
950
1013
|
name: 'studio_run', title: 'Run a studio command',
|
|
951
|
-
description: 'Any other homie-studio command in the studio, by its words (the guides name them): e.g. ["progress","stage","plan","done","--note","…"], ["progress","spend","0.40","--what","a cover"], ["look"], ["stats"], ["codex","link","<id>"], ["port","check","<id>","--url","…"], ["upgrade"], ["storage","add"]. Runs the studio\'s own pinned toolkit; long commands hand back a job.',
|
|
1014
|
+
description: 'Any other homie-studio command in the studio, by its words (the guides name them): e.g. ["progress","stage","plan","done","--note","…"], ["progress","spend","0.40","--what","a cover"], ["look"], ["stats"], ["codex","link","<id>"], ["port","check","<id>","--url","…"], ["upgrade"], ["storage","add"]. Runs the studio\'s own pinned toolkit; long commands hand back a job. ["upgrade"] runs with THIS Homie\'s toolkit when the studio pins an older one (that is how a studio moves up to it): it says what is new since the studio\'s version and what would change, and changes nothing; ["upgrade","--apply"] only after the person agrees, then studio_install.',
|
|
952
1015
|
inputSchema: { type: 'object', properties: { args: { type: 'array', items: { type: 'string' }, description: 'The command\'s words after `homie-studio`' }, ...STUDIO_ARG }, required: ['args'] },
|
|
953
1016
|
annotations: { title: 'Run a studio command', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
954
1017
|
run: async (a) => {
|
|
955
1018
|
const root = ctx.root(a.studio);
|
|
956
1019
|
const args = Array.isArray(a.args) ? a.args.map((x) => String(x)).filter((x) => x !== '--json') : [];
|
|
957
1020
|
if (!args.length) return fail('args: the command\'s words, e.g. ["look"]');
|
|
958
|
-
const refused = { mcp: 'this is it', new: 'studio_scaffold makes a studio', dev: 'preview_run runs the site here, preview_stop stops it', statusline: 'only for Claude Code' }[args[0]]
|
|
1021
|
+
const refused = { mcp: 'this is it', new: 'studio_scaffold makes a studio', dev: 'preview_run runs the site here, preview_stop stops it', statusline: 'only for Claude Code' }[args[0]]
|
|
1022
|
+
?? (args[0] === 'lab' && !['check', 'set', 'stop'].includes(args[1] ?? '') && !args.includes('--stop') ? 'game_lab starts the Game Lab here (and answers with its card)' : null);
|
|
959
1023
|
if (refused) return fail(`not here: ${refused}`);
|
|
960
1024
|
if (!['help', 'version', 'setup', 'doctor', 'demo', 'starters', 'progress', 'codex', 'games', 'status'].includes(args[0])) { const need = needsInstall(ctx, root); if (need) return need; }
|
|
961
1025
|
if (args[0] === 'game' && args[1] === 'new') ctx.note(root).demo = true;
|
|
962
|
-
|
|
1026
|
+
// The upgrade is the newer toolkit's to make: the studio's own pinned copy knows nothing newer than itself.
|
|
1027
|
+
const own = args[0] === 'upgrade' && behindOf(root) ? { cli: join(PACKAGE_ROOT, 'bin', 'homie-studio.mjs') } : {};
|
|
1028
|
+
const r = await cli(ctx, root, `homie-studio ${args.slice(0, 3).join(' ')}`, args, own);
|
|
963
1029
|
if (!r.ended) return stillRunning(r.job, `homie-studio ${args.slice(0, 2).join(' ')}`);
|
|
964
1030
|
const text = r.result ? JSON.stringify(r.result, null, 1).slice(0, 12_000) : jobView(r.job, { lines: 40 }).tail.join('\n');
|
|
965
1031
|
return r.job.code === 0 ? ok(text, { kind: 'run', args, result: r.result }) : fail(`homie-studio ${args.join(' ')}: ${whyOf(r.job)}`, { kind: 'run', args, result: r.result });
|
package/lib/mcp.mjs
CHANGED
|
@@ -46,10 +46,12 @@ A studio made elsewhere (on a phone, with Deploy to Cloudflare, so it lives on G
|
|
|
46
46
|
|
|
47
47
|
Links are the person's to open: give them in your reply (the cards have their own buttons). Never open a browser or run a command such as open to open one yourself.
|
|
48
48
|
|
|
49
|
+
To make one mechanic of a game feel better (a jump, a hit, a dash), game_lab opens the Game Lab: New beside Today, one take, its phases and sliders (studio_guide { "topic": "lab" }).
|
|
50
|
+
|
|
49
51
|
Long work (npm install, check, playtest, deploy, renders) runs in the background: the tool answers at once with a card that follows it, and build_progress or studio_job reads where it is. studio_guide has Homie's full guide for each job (game, plan, port, playtest, publish, music, sound, art, video). Never put a key or password in a file or the chat. If Homie's homie.rocks connector is connected too, its tools of the same names say what to run; these run it.`;
|
|
50
52
|
|
|
51
|
-
const CARD_FILES = { [UI.setup]: 'setup.js', [UI.build]: 'build.js', [UI.studio]: 'studio.js', [UI.codex]: 'codex.js' };
|
|
52
|
-
const CARD_TITLES = { [UI.setup]: 'Studio setup', [UI.build]: 'Build progress', [UI.studio]: 'Studio', [UI.codex]: 'Game Codex' };
|
|
53
|
+
const CARD_FILES = { [UI.setup]: 'setup.js', [UI.build]: 'build.js', [UI.studio]: 'studio.js', [UI.codex]: 'codex.js', [UI.lab]: 'lab.js' };
|
|
54
|
+
const CARD_TITLES = { [UI.setup]: 'Studio setup', [UI.build]: 'Build progress', [UI.studio]: 'Studio', [UI.codex]: 'Game Codex', [UI.lab]: 'Game Lab' };
|
|
53
55
|
const UI_DIR = join(PACKAGE_ROOT, 'mcp', 'ui');
|
|
54
56
|
|
|
55
57
|
/** One card's whole document: the shared look and bridge, and the card's own script, as the files are. */
|
package/lib/scaffold.mjs
CHANGED
|
@@ -104,7 +104,7 @@ directory lists its games; homie.rocks does not host them.
|
|
|
104
104
|
|
|
105
105
|
| Path | What it is |
|
|
106
106
|
| --- | --- |
|
|
107
|
-
| \`games/<id>/\` | One game: \`game.json\` (id, name, blurb, players, round length), \`index.html\`, \`src/main.ts
|
|
107
|
+
| \`games/<id>/\` | One game: \`game.json\` (id, name, blurb, players, round length), \`index.html\`, \`src/main.ts\`; and, when it has them, \`tunables.json\` (the numbers that shape how it feels: the Game Lab's sliders, kept values written back) and \`lab.json\` (the Game Lab's takes). |
|
|
108
108
|
| \`music/\`, \`videos/\` | Songs, scores, loops; trailers, music videos, cutscenes. \`manifest.json\` lists each one (\`node_modules/@homie-rocks/studio/media/MEDIA.md\`); a published entry gets a page at \`/music/<slug>/\` or \`/videos/<slug>/\`. Without storage the site serves each file itself (up to 25 MiB). Once the studio has storage (see below), its big media (over 1 MiB, or left out of git) lives in its own R2: every deploy uploads it, checks it by SHA-256 and serves it from R2 at the same address. Large files never go into git. The Homie plugin's \`music\` and \`video\` skills make them. |
|
|
109
109
|
| \`posts/\` | The studio's news and drops: one markdown file each (\`posts/2026-09-30-we-are-live.md\`: \`title:\`, \`date:\`, \`summary:\`, and \`game:\` / \`song:\` / \`video:\` to link one). They are the site's Posts, with Atom and JSON feeds. |
|
|
110
110
|
| \`site/\` | The studio's site: its look (\`theme.json\`), and anything of its own that wins over the generated pages (\`site/README.md\`); the Worker (\`src/worker.mjs\`) and its D1 migrations. |
|
|
@@ -140,6 +140,12 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
140
140
|
per frame, time to playable, what it downloads, the heap, netplay messages a second (files under \`.perf/\`). The
|
|
141
141
|
Homie plugin's \`perf\` skill runs the whole loop: one change at a time, kept only when it is better beyond the noise
|
|
142
142
|
and \`check\` still passes, and a report in \`perf/<id>/\`.
|
|
143
|
+
- \`npx --no-install homie-studio lab <id>\` — the Game Lab, on this computer (run it in the background; stop it with
|
|
144
|
+
\`lab --stop\`): one take of a move (\`games/<id>/lab.json\`) played in the working tree beside the last commit, on
|
|
145
|
+
one clock, slowed down or a frame at a time, with the phases, graphs and tunables the game reports
|
|
146
|
+
(\`@homie-rocks/studio/lab\`). \`lab check <id>\` plays it headless and writes the numbers and a contact sheet. The
|
|
147
|
+
Homie plugin's \`lab\` skill runs the whole loop: instrument and commit first, change how it feels, keep what the
|
|
148
|
+
person likes.
|
|
143
149
|
- \`npx --no-install homie-studio deploy --plan\` — says what deploy will create on Cloudflare and what it
|
|
144
150
|
costs, and changes nothing. Tell the person before the first deploy.
|
|
145
151
|
- \`npm run deploy\` — the site on this studio's Cloudflare: one Worker, one D1 database, two
|
package/lib/studio.mjs
CHANGED
|
@@ -103,6 +103,18 @@ export function starters() {
|
|
|
103
103
|
});
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* A game's Game Lab takes (lab.json) seed its saves by the game's id (`homie-saves.local.<id>`, saves/saves.ts): a copy
|
|
108
|
+
* under a new id renames them, so a take's saved hero is still found.
|
|
109
|
+
*/
|
|
110
|
+
function renameTakeSaves(dir, from, id) {
|
|
111
|
+
const file = join(dir, 'lab.json');
|
|
112
|
+
if (!existsSync(file) || from === id) return;
|
|
113
|
+
const text = readFileSync(file, 'utf8');
|
|
114
|
+
const next = text.split(`"homie-saves.local.${from}"`).join(`"homie-saves.local.${id}"`);
|
|
115
|
+
if (next !== text) writeFileSync(file, next);
|
|
116
|
+
}
|
|
117
|
+
|
|
106
118
|
/** `homie-studio game new <id> --from <starter> --name "<Name>"`: a starter copied in as this studio's own game. */
|
|
107
119
|
export function newGame(root, id, { from = 'gem-rush', name } = {}) {
|
|
108
120
|
if (!GAME_ID.test(String(id ?? '')) || RESERVED_IDS.has(id)) throw new Error(`a game id is lowercase letters, digits and hyphens, up to 40, and not one of ${[...RESERVED_IDS].join(', ')} (got ${JSON.stringify(id)})`);
|
|
@@ -121,6 +133,7 @@ export function newGame(root, id, { from = 'gem-rush', name } = {}) {
|
|
|
121
133
|
writeFileSync(join(dest, 'game.json'), `${JSON.stringify(meta, null, 2)}\n`);
|
|
122
134
|
const main = join(dest, meta.entry ?? 'src/main.ts');
|
|
123
135
|
if (existsSync(main)) writeFileSync(main, readFileSync(main, 'utf8').replace(new RegExp(`game: '${from}'`, 'g'), `game: '${id}'`));
|
|
136
|
+
renameTakeSaves(dest, from, id);
|
|
124
137
|
const html = join(dest, 'index.html');
|
|
125
138
|
if (name && existsSync(html)) writeFileSync(html, readFileSync(html, 'utf8').replace(/<title>[^<]*<\/title>/, `<title>${String(name).replace(/[<&]/g, '')}</title>`));
|
|
126
139
|
return { ok: true, command: 'game new', id, from, dir: dest, files: readdirSync(dest, { recursive: true }).map(String) };
|
|
@@ -174,5 +187,6 @@ export async function remixGame(root, source, id, { name } = {}) {
|
|
|
174
187
|
writeFileSync(join(dest, 'game.json'), `${JSON.stringify(meta, null, 2)}\n`);
|
|
175
188
|
const main = join(dest, meta.entry ?? 'src/main.ts');
|
|
176
189
|
if (existsSync(main) && from) writeFileSync(main, readFileSync(main, 'utf8').replace(new RegExp(`game: '${from}'`, 'g'), `game: '${id}'`));
|
|
190
|
+
if (from) renameTakeSaves(dest, from, id);
|
|
177
191
|
return { ok: true, command: 'game remix', id, from: String(url), credit: meta.remixOf.credit, page: meta.remixOf.page, license, files: wrote };
|
|
178
192
|
}
|
|
@@ -813,6 +813,80 @@
|
|
|
813
813
|
}
|
|
814
814
|
}
|
|
815
815
|
}
|
|
816
|
+
},
|
|
817
|
+
"0.19.2": {
|
|
818
|
+
"files": {
|
|
819
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
820
|
+
"README.md": "684b3cca8001673c",
|
|
821
|
+
"HANDOFF.md": "ce7a8fd91c6bbee2",
|
|
822
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
823
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
824
|
+
"videos/README.md": "16babc3437d5545b",
|
|
825
|
+
"posts/README.md": "37841aae476bd540",
|
|
826
|
+
"site/README.md": "ca131cd0472464fb",
|
|
827
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
828
|
+
},
|
|
829
|
+
"sections": {
|
|
830
|
+
"AGENTS.md": {
|
|
831
|
+
"(intro)": "214aa46eb3a895d5",
|
|
832
|
+
"## Layout": "8dc97df0d77e655e",
|
|
833
|
+
"## Commands (all through the pinned CLI in node_modules)": "a06ab13fc385ab25",
|
|
834
|
+
"## The site": "ab922bb6bde23293",
|
|
835
|
+
"## Making games": "a8a893eeada33bf5",
|
|
836
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
837
|
+
"## Running live games (the back office)": "f0566f07cff04113",
|
|
838
|
+
"## Servers and AI seats": "0a332b07e22d3fce",
|
|
839
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
840
|
+
"## Rules": "969318425c99f5a5",
|
|
841
|
+
"## Beta": "9d02638207a26e73"
|
|
842
|
+
}
|
|
843
|
+
},
|
|
844
|
+
"named": {
|
|
845
|
+
"Homie Arcade": {
|
|
846
|
+
"sections": {
|
|
847
|
+
"AGENTS.md": {
|
|
848
|
+
"## Commands (all through the pinned CLI in node_modules)": "677ee39dc13485e0"
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
},
|
|
854
|
+
"0.20.0": {
|
|
855
|
+
"files": {
|
|
856
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
857
|
+
"README.md": "684b3cca8001673c",
|
|
858
|
+
"HANDOFF.md": "ce7a8fd91c6bbee2",
|
|
859
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
860
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
861
|
+
"videos/README.md": "16babc3437d5545b",
|
|
862
|
+
"posts/README.md": "37841aae476bd540",
|
|
863
|
+
"site/README.md": "ca131cd0472464fb",
|
|
864
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
865
|
+
},
|
|
866
|
+
"sections": {
|
|
867
|
+
"AGENTS.md": {
|
|
868
|
+
"(intro)": "214aa46eb3a895d5",
|
|
869
|
+
"## Layout": "43a4c5bf07db36f9",
|
|
870
|
+
"## Commands (all through the pinned CLI in node_modules)": "d80ec56e4b4fc4c6",
|
|
871
|
+
"## The site": "ab922bb6bde23293",
|
|
872
|
+
"## Making games": "a8a893eeada33bf5",
|
|
873
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
874
|
+
"## Running live games (the back office)": "f0566f07cff04113",
|
|
875
|
+
"## Servers and AI seats": "0a332b07e22d3fce",
|
|
876
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
877
|
+
"## Rules": "969318425c99f5a5",
|
|
878
|
+
"## Beta": "9d02638207a26e73"
|
|
879
|
+
}
|
|
880
|
+
},
|
|
881
|
+
"named": {
|
|
882
|
+
"Homie Arcade": {
|
|
883
|
+
"sections": {
|
|
884
|
+
"AGENTS.md": {
|
|
885
|
+
"## Commands (all through the pinned CLI in node_modules)": "b35df2c19cd4b534"
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
816
890
|
}
|
|
817
891
|
}
|
|
818
892
|
}
|
package/lib/upgrade.mjs
CHANGED
|
@@ -25,11 +25,16 @@
|
|
|
25
25
|
* Media (0.18.0): the plan also says which songs and videos are big media (over studio.json media.r2Over, 1 MiB
|
|
26
26
|
* unless set, or left out of git), what is in the studio's R2 already, and the step that moves the rest
|
|
27
27
|
* (`media move`, which every deploy of a studio with storage also runs). Upgrade itself never moves a file.
|
|
28
|
+
*
|
|
29
|
+
* What's new (0.19.2): the plan and the applied result carry `whatsNew`, every version after the one the studio
|
|
30
|
+
* was on up to this one, each with its one-line summary and its upgrade notes, read from THIS package's own
|
|
31
|
+
* CHANGELOG.md (lib/changelog.mjs): the version being moved to says what it brings, with no network.
|
|
28
32
|
*/
|
|
29
33
|
import { createHash } from 'node:crypto';
|
|
30
34
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
31
35
|
import { spawnSync } from 'node:child_process';
|
|
32
36
|
import { dirname, join } from 'node:path';
|
|
37
|
+
import { whatsNew } from './changelog.mjs';
|
|
33
38
|
import { R2_COST, mediaPlan, r2OverOf, sizeOf } from './media.mjs';
|
|
34
39
|
import { studioFiles } from './scaffold.mjs';
|
|
35
40
|
import { readStudio } from './studio.mjs';
|
|
@@ -243,12 +248,15 @@ export function upgradePlan(root, { history = readHistory() } = {}) {
|
|
|
243
248
|
const git = spawnSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' });
|
|
244
249
|
const dirty = git.status === 0 ? git.stdout.split('\n').filter(Boolean).length : null;
|
|
245
250
|
const media = mediaNote(root, studio);
|
|
251
|
+
const news = whatsNew(from, STUDIO_VERSION);
|
|
246
252
|
return {
|
|
247
253
|
ok: true, command: 'upgrade', applied: false, root, studio: name, from, to: STUDIO_VERSION,
|
|
254
|
+
...(news ? { whatsNew: news } : {}),
|
|
248
255
|
changes, kept,
|
|
249
256
|
...(media ? { media } : {}),
|
|
250
257
|
...(dirty ? { uncommitted: dirty } : {}),
|
|
251
|
-
|
|
258
|
+
// A plan that moves the pin is this (newer) toolkit's to apply: the studio's own older copy knows nothing newer.
|
|
259
|
+
next: changes.length ? [`${changes.some((c) => c.kind === 'pin') ? `npx -y @homie-rocks/studio@${STUDIO_VERSION} upgrade --apply` : 'npx --no-install homie-studio upgrade --apply'} (after the person agrees)`] : [],
|
|
252
260
|
};
|
|
253
261
|
}
|
|
254
262
|
|
package/mcp/ui/card.css
CHANGED
|
@@ -63,6 +63,8 @@ ol.list,ul.list{list-style:none;margin:0;padding:0;display:grid;gap:6px}
|
|
|
63
63
|
.game b{font-size:14px}.game span{font-size:12.5px;color:var(--c-fg2)}
|
|
64
64
|
.soon{padding:14px;border-radius:var(--r-md);background:linear-gradient(135deg,color-mix(in srgb,var(--accent) 18%,var(--c-bg2)),var(--c-bg2));display:grid;gap:6px}
|
|
65
65
|
.soon b{font-size:16px}
|
|
66
|
+
.news{margin:12px 0 0;padding:12px 14px;border-radius:var(--r-md);background:var(--c-bg2);display:grid;gap:6px;font-size:13px;color:var(--c-fg2)}
|
|
67
|
+
.news b{color:var(--c-fg);font-size:14px}.news code{font-weight:600;color:var(--c-fg);margin-right:2px}.news .fine{margin:2px 0 0}
|
|
66
68
|
.stats{display:flex;flex-wrap:wrap;gap:6px 16px;margin:4px 0 6px;color:var(--c-fg2);font-size:13px}.stats b{color:var(--c-fg)}
|
|
67
69
|
.log{margin:10px 0 0;padding:8px 10px;border-radius:var(--r-md);background:var(--c-bg2);font:12px/1.5 var(--f-mono);color:var(--c-fg2);white-space:pre-wrap;overflow-wrap:anywhere}
|
|
68
70
|
.frame{width:100%;height:620px;border:0;border-radius:var(--r-md);background:var(--c-bg2);display:block}
|
package/mcp/ui/lab.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/* The Game Lab card: one take of a game's mechanic, New (the working tree) beside Today (the last commit). A still of
|
|
2
|
+
* both at the take's busiest moment, the phases of each as a timing strip, whether replays match, the game's
|
|
3
|
+
* JavaScript per frame, and Open (the lab runs on this computer). */
|
|
4
|
+
(function () {
|
|
5
|
+
var C = Card;
|
|
6
|
+
var COLOURS = ['#a374ff', '#17c3b2', '#f5a524', '#ff6fb5', '#8bd346', '#4dd2ff', '#ffd84a', '#e58cff'];
|
|
7
|
+
function strip(rows, frames) {
|
|
8
|
+
// rows: { label, phases: [{ name, from, to }] }: blocks at their frames on the take's own axis; colours by name, shared.
|
|
9
|
+
var names = [];
|
|
10
|
+
rows.forEach(function (r) { (r.phases || []).forEach(function (p) { if (names.indexOf(p.name) < 0) names.push(p.name); }); });
|
|
11
|
+
var box = C.el('div');
|
|
12
|
+
box.style.cssText = 'display:grid;gap:6px;margin:12px 0 4px';
|
|
13
|
+
rows.forEach(function (r) {
|
|
14
|
+
var line = C.el('div');
|
|
15
|
+
line.style.cssText = 'display:grid;grid-template-columns:52px 1fr;gap:8px;align-items:center';
|
|
16
|
+
var label = C.el('span', '', r.label);
|
|
17
|
+
label.style.cssText = 'font:700 11px/1 var(--f-mono);letter-spacing:.08em;color:' + r.colour;
|
|
18
|
+
var bar = C.el('div');
|
|
19
|
+
bar.style.cssText = 'position:relative;height:20px;border-radius:4px;overflow:hidden;background:var(--c-bg2)';
|
|
20
|
+
(r.phases || []).forEach(function (p) {
|
|
21
|
+
var b = C.el('span', '', p.name);
|
|
22
|
+
var colour = COLOURS[names.indexOf(p.name) % COLOURS.length];
|
|
23
|
+
b.title = p.name + ': frames ' + p.from + ' to ' + p.to;
|
|
24
|
+
b.style.cssText = 'position:absolute;top:0;bottom:0;left:' + ((p.from - 1) / frames) * 100 + '%;width:calc(' + ((p.to - p.from + 1) / frames) * 100 + '% - 1px);background:' + colour + ';color:#0b0d14;font:700 10px/20px var(--f-mono);padding:0 4px;white-space:nowrap;overflow:hidden;text-overflow:clip';
|
|
25
|
+
bar.appendChild(b);
|
|
26
|
+
});
|
|
27
|
+
if (!(r.phases || []).length) { var e = C.el('span', 'fine', r.empty || 'no phases'); e.style.cssText = 'margin:0;line-height:20px;padding:0 8px'; bar.appendChild(e); }
|
|
28
|
+
line.appendChild(label); line.appendChild(bar);
|
|
29
|
+
box.appendChild(line);
|
|
30
|
+
});
|
|
31
|
+
return box;
|
|
32
|
+
}
|
|
33
|
+
function render(sc) {
|
|
34
|
+
var card = C.el('main', 'card');
|
|
35
|
+
var det = sc.deterministic || {};
|
|
36
|
+
var same = det.new === null && (det.today === null || det.today === undefined);
|
|
37
|
+
var head = sc.running ? C.pill('Checking', 'live', true) : same ? C.pill('Replays match', 'ok') : C.pill('Replays differ', 'bad');
|
|
38
|
+
C.add(card, C.top('Game Lab', C.add(C.el('div', 'actions'), head, C.fullscreenButton())), C.el('h1', '', (sc.name || sc.game) + (sc.take ? ' · ' + sc.take : '')),
|
|
39
|
+
C.el('p', 'sub', sc.running ? 'The lab is open on this computer; its first check is still running.' : 'New (the working tree) beside Today' + (sc.today ? ' (' + sc.today + ')' : '') + ' · ' + sc.frames + ' frames at ' + sc.fps + ' fps'));
|
|
40
|
+
if (sc.still) {
|
|
41
|
+
var box = C.el('div', 'preview');
|
|
42
|
+
var pic = C.img(sc.still, 'New beside Today at the take\'s busiest moment');
|
|
43
|
+
if (pic) { pic.style.maxHeight = 'none'; pic.style.objectFit = 'contain'; box.appendChild(pic); }
|
|
44
|
+
card.appendChild(box);
|
|
45
|
+
}
|
|
46
|
+
if (sc.timeline && sc.frames) {
|
|
47
|
+
card.appendChild(strip([
|
|
48
|
+
{ label: 'NEW', colour: '#e08a12', phases: sc.timeline.new },
|
|
49
|
+
{ label: 'TODAY', colour: '#2f8fd8', phases: sc.timeline.today || [], empty: sc.todayNote || 'no phases' },
|
|
50
|
+
], sc.frames));
|
|
51
|
+
card.appendChild(C.el('p', 'fine', 'Phases on the take\'s ' + sc.frames + ' frames (' + (sc.frames / sc.fps).toFixed(2) + ' s).'));
|
|
52
|
+
}
|
|
53
|
+
if (sc.cost && sc.cost.new) {
|
|
54
|
+
card.appendChild(C.el('p', 'fine', 'The game\'s JavaScript a frame, on average: New ' + sc.cost.new.mean + ' ms (p95 ' + sc.cost.new.p95 + ')' + (sc.cost.today ? ', Today ' + sc.cost.today.mean + ' ms (p95 ' + sc.cost.today.p95 + ')' : '') + '.'));
|
|
55
|
+
}
|
|
56
|
+
if (!same && !sc.running) card.appendChild(C.el('p', 'note bad', 'The same build played the take differently' + (det.new !== null ? ' (New from frame ' + det.new + ')' : '') + (det.today ? ' (Today from frame ' + det.today + ')' : '') + ': something in the game reads a clock or dice the lab does not drive.'));
|
|
57
|
+
(sc.errors || []).slice(0, 3).forEach(function (e) { card.appendChild(C.el('p', 'note bad', e)); });
|
|
58
|
+
var foot = C.el('div', 'foot');
|
|
59
|
+
foot.appendChild(C.el('span', 'when', 'Runs on this computer'));
|
|
60
|
+
var acts = C.el('div', 'actions');
|
|
61
|
+
if (sc.url) acts.appendChild(C.btn('Open the lab', '', function () { C.open(sc.url); C.tell('lab-open', 'The person opened the Game Lab for ' + (sc.name || sc.game) + ' from the card.'); }));
|
|
62
|
+
foot.appendChild(acts);
|
|
63
|
+
card.appendChild(foot);
|
|
64
|
+
return card;
|
|
65
|
+
}
|
|
66
|
+
C.start({ kind: 'lab', label: 'Game Lab', fullscreen: true, render: render, poll: null });
|
|
67
|
+
})();
|
package/mcp/ui/studio.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/* The studio card: its games with Play (on this computer and live), songs, videos and posts, where it runs, its
|
|
2
|
-
* live rooms,
|
|
2
|
+
* live rooms, a live game to try while it has none, and what's new when its toolkit is older than this Homie. */
|
|
3
3
|
(function () {
|
|
4
4
|
var C = Card;
|
|
5
5
|
function initials(name) { return String(name || '?').split(/\s+/).filter(Boolean).slice(0, 2).map(function (w) { return w[0]; }).join('').toUpperCase(); }
|
|
@@ -28,6 +28,14 @@
|
|
|
28
28
|
card.appendChild(soon);
|
|
29
29
|
}
|
|
30
30
|
if ((sc.rooms || []).length) card.appendChild(C.el('p', 'fine', 'Playing now: ' + sc.rooms.map(function (r) { return r.game + ' (' + r.players + ')'; }).join(', ')));
|
|
31
|
+
// A studio on an older toolkit than this Homie: what's new since (this package's CHANGELOG.md), and the words to ask.
|
|
32
|
+
if (sc.behind) {
|
|
33
|
+
var news = C.add(C.el('div', 'news'), C.el('b', '', 'New in Homie since ' + sc.behind.pinned));
|
|
34
|
+
((sc.behind.whatsNew && sc.behind.whatsNew.versions) || []).slice(0, 3).forEach(function (v) { news.appendChild(C.add(C.el('span'), C.el('code', '', v.version), C.el('span', '', ' ' + v.summary))); });
|
|
35
|
+
news.appendChild(C.el('span', 'fine', 'This studio is on ' + sc.behind.pinned + ', and ' + sc.behind.here + ' is here. Say \u201cUpgrade my studio\u201d to see what changes; nothing does until you agree.'));
|
|
36
|
+
card.appendChild(news);
|
|
37
|
+
C.tell('behind', sc.name + ' pins @homie-rocks/studio ' + sc.behind.pinned + ' and this Homie is ' + sc.behind.here + ': offer the upgrade (studio_run ["upgrade"] shows what is new and the plan, and changes nothing).');
|
|
38
|
+
}
|
|
31
39
|
var foot = C.el('div', 'foot');
|
|
32
40
|
foot.appendChild(C.el('span', 'when', sc.site ? sc.site.replace(/^https?:\/\//, '') : sc.dev ? sc.dev.url.replace(/^https?:\/\//, '') : 'Not online yet'));
|
|
33
41
|
var acts = C.el('div', 'actions');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -34,6 +34,10 @@
|
|
|
34
34
|
"types": "./agents/agents.ts",
|
|
35
35
|
"default": "./agents/agents.ts"
|
|
36
36
|
},
|
|
37
|
+
"./lab": {
|
|
38
|
+
"types": "./lab/lab.ts",
|
|
39
|
+
"default": "./lab/lab.ts"
|
|
40
|
+
},
|
|
37
41
|
"./worker": "./worker/index.mjs",
|
|
38
42
|
"./package.json": "./package.json"
|
|
39
43
|
},
|
|
@@ -46,10 +50,12 @@
|
|
|
46
50
|
"mcp/",
|
|
47
51
|
"saves/",
|
|
48
52
|
"agents/",
|
|
53
|
+
"lab/",
|
|
49
54
|
"starters/",
|
|
50
55
|
"media/",
|
|
51
56
|
"site/",
|
|
52
57
|
"README.md",
|
|
58
|
+
"CHANGELOG.md",
|
|
53
59
|
"LICENSE",
|
|
54
60
|
"NOTICE"
|
|
55
61
|
],
|
package/port/index.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* jitter, engages, standoff
|
|
19
19
|
* the dial for the rest of a bot's decisions (port/skill.ts)
|
|
20
20
|
* createSaves player accounts and cloud saves (a character that lasts days, on every device; saves/SAVES.md)
|
|
21
|
+
* lab the Game Lab's calls (@homie-rocks/studio/lab): tracks, phases, tunables, overlays, views
|
|
21
22
|
* and everything from @homie-rocks/studio/netplay.
|
|
22
23
|
*/
|
|
23
24
|
import './early';
|
|
@@ -35,4 +36,5 @@ export { createRoom, type Room, type RoomOptions, type BodyBase, type RoomSnap,
|
|
|
35
36
|
export { createHud, type HudOptions } from './hud';
|
|
36
37
|
export { BotBrain, brainOf, seek, flee, wanderer, nearest, rubberBand, protectedNewcomer, type V2 } from './bots';
|
|
37
38
|
export { jitter, engages, standoff } from './skill';
|
|
39
|
+
export { lab, type TunableSpec, type TunableFile, type Tuned } from '../lab/lab';
|
|
38
40
|
export { createSaves, SAVES_VERSION, type Saves, type SavesOptions, type Player, type SaveEntry, type SetResult, type Memorial, type SavesStatus, type Conflict } from '../saves/saves';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"v": 1,
|
|
3
|
+
"default": "strike",
|
|
4
|
+
"takes": {
|
|
5
|
+
"strike": {
|
|
6
|
+
"note": "Three strikes on a big training slime",
|
|
7
|
+
"seconds": 2.6,
|
|
8
|
+
"seed": 11,
|
|
9
|
+
"stage": "dummy",
|
|
10
|
+
"device": "desk",
|
|
11
|
+
"fps": 60,
|
|
12
|
+
"view": "close",
|
|
13
|
+
"track": "slime squash",
|
|
14
|
+
"storage": { "homie-saves.local.ember-vale": { "saves": { "hero": { "v": { "v": 1, "name": "Wren", "level": 1, "xp": 0, "gold": 0, "hardcore": false, "kills": 0, "deaths": 0, "born": 1767225600000 }, "ver": 1, "at": 1767225600000 } }, "stats": {}, "fallen": [] } },
|
|
15
|
+
"inputs": [
|
|
16
|
+
{ "at": 0.4, "key": "Space", "hold": 0.08 },
|
|
17
|
+
{ "at": 1.0, "key": "Space", "hold": 0.08 },
|
|
18
|
+
{ "at": 1.6, "key": "Space", "hold": 0.08 }
|
|
19
|
+
]
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|