@homie-rocks/studio 0.13.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -5
- package/bin/homie-studio.mjs +32 -5
- package/lib/cloudflare.mjs +2 -2
- package/lib/codex.mjs +28 -9
- package/lib/demo.mjs +85 -0
- package/lib/files.mjs +154 -0
- package/lib/handoff.mjs +128 -0
- package/lib/jobs.mjs +181 -0
- package/lib/mcp-tools.mjs +1000 -0
- package/lib/mcp.mjs +180 -0
- package/lib/scaffold.mjs +56 -24
- package/lib/studio.mjs +4 -1
- package/lib/template-history.json +54 -0
- package/lib/upgrade.mjs +1 -1
- package/mcp/ui/bridge.js +148 -0
- package/mcp/ui/build.js +63 -0
- package/mcp/ui/card.css +73 -0
- package/mcp/ui/codex.js +23 -0
- package/mcp/ui/setup.js +72 -0
- package/mcp/ui/studio.js +41 -0
- package/netplay/NETPLAY.md +1 -1
- package/package.json +2 -1
- package/worker/index.mjs +20 -1
- package/worker/office.mjs +16 -6
- package/worker/site.mjs +24 -2
- package/worker/version.mjs +1 -1
package/mcp/ui/build.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/* The build card: one build's stages, each check going green, a picture and where to play it, money against the
|
|
2
|
+
* budget, and Stop. It follows the build every 2 s while it runs and tells the model once when it ends. */
|
|
3
|
+
(function () {
|
|
4
|
+
var C = Card;
|
|
5
|
+
var ICON = { pass: '✓', done: '✓', fail: '✕', failed: '✕', running: '●', pending: '·', skip: '–', skipped: '–', stopped: '■' };
|
|
6
|
+
var STATE = { running: ['Running', 'live', true], passed: ['Done', 'ok'], failed: ['Failed', 'bad'], stopped: ['Stopped', ''] };
|
|
7
|
+
function money(n, unit) { return unit === 'credits' ? Math.round(n).toLocaleString('en-US') + ' credits' : '$' + Number(n || 0).toFixed(2); }
|
|
8
|
+
function render(sc) {
|
|
9
|
+
var f = sc.feed; var card = C.el('main', 'card');
|
|
10
|
+
var st = STATE[f.state] || ['…', ''];
|
|
11
|
+
C.add(card, C.top('Build', C.add(C.el('div', 'actions'), C.pill(st[0], st[1], st[2]), C.fullscreenButton())), C.el('h1', '', f.title || 'A build'),
|
|
12
|
+
C.el('p', 'sub', [f.studio, f.what === 'game' ? null : f.what, f.state === 'running' ? 'started ' + C.ago(f.startedAt) : f.endedAt ? 'took ' + C.dur(Date.parse(f.endedAt) - Date.parse(f.startedAt)) : null].filter(Boolean).join(' · ')));
|
|
13
|
+
var stages = C.el('div', 'stages');
|
|
14
|
+
(f.stages || []).forEach(function (s) { stages.appendChild(C.add(C.el('div', 'stage ' + s.state), C.el('div', 'bar'), C.el('span', '', (ICON[s.state] && s.state !== 'pending' ? ICON[s.state] + ' ' : '') + s.label))); });
|
|
15
|
+
card.appendChild(stages);
|
|
16
|
+
var cur = (f.stages || []).filter(function (s) { return s.note && s.state !== 'pending'; }).pop();
|
|
17
|
+
if (cur) card.appendChild(C.el('p', 'fine', cur.label + ': ' + cur.note));
|
|
18
|
+
if ((f.checks || []).length) {
|
|
19
|
+
var list = C.el('div');
|
|
20
|
+
f.checks.forEach(function (c) { list.appendChild(C.add(C.el('div', 'check ' + c.state), C.el('span', 'i', ICON[c.state] || '·'), C.el('span', '', c.label + (c.note ? ' — ' + c.note : '')), c.ms ? C.el('span', 'n', C.dur(c.ms)) : null)); });
|
|
21
|
+
card.appendChild(list);
|
|
22
|
+
}
|
|
23
|
+
var p = f.preview;
|
|
24
|
+
if (p && (p.image || p.url)) {
|
|
25
|
+
var box = C.el('div', 'preview');
|
|
26
|
+
var pic = C.img(p.image, p.caption || 'The game');
|
|
27
|
+
if (pic) box.appendChild(pic);
|
|
28
|
+
if (p.caption) box.appendChild(C.el('span', 'fine', p.caption));
|
|
29
|
+
card.appendChild(box);
|
|
30
|
+
}
|
|
31
|
+
if (f.spend && (f.spend.used > 0 || f.spend.budget !== null)) card.appendChild(C.el('p', 'fine', 'Spent ' + money(f.spend.used, f.spend.unit) + (f.spend.budget !== null ? ' of ' + money(f.spend.budget, f.spend.unit) : '')));
|
|
32
|
+
if (f.error) card.appendChild(C.el('p', 'note bad', f.error));
|
|
33
|
+
var failed = (sc.jobs || []).filter(function (j) { return j.state === 'failed'; });
|
|
34
|
+
if (failed.length && !f.error) card.appendChild(C.el('pre', 'log', failed.map(function (j) { return j.label + ': ' + ((j.result && j.result.why) || (j.tail || []).slice(-3).join('\n')); }).join('\n')));
|
|
35
|
+
else if ((f.log || []).length && f.state === 'running') card.appendChild(C.el('pre', 'log', f.log.slice(-3).map(function (l) { return l.text; }).join('\n')));
|
|
36
|
+
var foot = C.el('div', 'foot');
|
|
37
|
+
foot.appendChild(C.el('span', 'when', f.state === 'running' ? 'Updated ' + C.ago(f.updatedAt) : 'Ended ' + C.ago(f.endedAt)));
|
|
38
|
+
var acts = C.el('div', 'actions');
|
|
39
|
+
if (f.state === 'running' && !(f.stop && f.stop.requested)) {
|
|
40
|
+
acts.appendChild(C.btn(C.state.stopping ? 'Stopping…' : 'Stop', 'danger small', function () {
|
|
41
|
+
if (C.state.stopping) return; C.state.stopping = true; C.render();
|
|
42
|
+
C.call('build_stop', { build: sc.build }).then(function (r) { C.state.stopping = false; C.take(r); }).catch(function () { C.state.stopping = false; C.render(); });
|
|
43
|
+
}));
|
|
44
|
+
} else if (f.stop && f.stop.requested && f.state === 'running') acts.appendChild(C.pill('Stopping at the next safe point', 'warn'));
|
|
45
|
+
if (p && p.url && /^https?:/.test(p.url)) acts.appendChild(C.btn('Play it', f.state === 'passed' ? '' : 'ghost small', function () { C.open(p.url); }));
|
|
46
|
+
foot.appendChild(acts);
|
|
47
|
+
card.appendChild(foot);
|
|
48
|
+
var r = sc.run;
|
|
49
|
+
if (r && r.state !== 'running' && f.state === 'running') {
|
|
50
|
+
card.insertBefore(C.el('p', 'note' + (r.state === 'failed' ? ' bad' : ' ok'), (r.state === 'passed' ? '✓ ' : '') + r.title + ': ' + (r.state === 'passed' ? 'passed' : 'failed' + (r.why ? ' — ' + r.why : ''))), foot);
|
|
51
|
+
C.tell('run-' + sc.build + '-' + r.kind, r.title + ' ' + r.state + (r.why ? ': ' + r.why : '') + ' (build ' + sc.build + ' stays open).');
|
|
52
|
+
}
|
|
53
|
+
if (f.state !== 'running') {
|
|
54
|
+
var passed = (f.checks || []).filter(function (c) { return c.state === 'pass'; }).length;
|
|
55
|
+
C.tell('end-' + sc.build, 'Build ' + sc.build + ' (' + f.title + ') ended: ' + f.state + (f.checks && f.checks.length ? ', ' + passed + ' of ' + f.checks.length + ' checks passed' : '') + (f.error ? '. Error: ' + f.error : '') + (p && p.url ? '. Play: ' + p.url : '') + '. Tell the person in a line or two.');
|
|
56
|
+
}
|
|
57
|
+
return card;
|
|
58
|
+
}
|
|
59
|
+
C.start({
|
|
60
|
+
kind: 'build', label: 'Build', fullscreen: true, render: render,
|
|
61
|
+
poll: function (sc) { return sc.feed && sc.feed.state === 'running' ? { tool: 'build_progress', args: { build: sc.build }, every: 2000 } : null; },
|
|
62
|
+
});
|
|
63
|
+
})();
|
package/mcp/ui/card.css
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
:root{color-scheme:light dark;
|
|
2
|
+
--c-bg:var(--color-background-primary,light-dark(#ffffff,#1c1c1e));--c-bg2:var(--color-background-secondary,light-dark(#f6f5f2,#242427));--c-bg3:var(--color-background-tertiary,light-dark(#eeece6,#2d2d31));
|
|
3
|
+
--c-fg:var(--color-text-primary,light-dark(#1d1c1a,#f3f1ec));--c-fg2:var(--color-text-secondary,light-dark(#5e5b54,#b8b4ab));--c-fg3:var(--color-text-tertiary,light-dark(#8b877e,#8a867e));
|
|
4
|
+
--c-line:var(--color-border-tertiary,light-dark(rgba(20,18,12,.10),rgba(255,255,255,.10)));--c-line2:var(--color-border-secondary,light-dark(rgba(20,18,12,.18),rgba(255,255,255,.18)));
|
|
5
|
+
--c-ok:var(--color-text-success,light-dark(#17753b,#5fd08a));--c-okbg:var(--color-background-success,light-dark(#e5f4ea,rgba(95,208,138,.14)));
|
|
6
|
+
--c-bad:var(--color-text-danger,light-dark(#b42318,#ff8a80));--c-badbg:var(--color-background-danger,light-dark(#fdecea,rgba(255,138,128,.14)));
|
|
7
|
+
--c-warn:var(--color-text-warning,light-dark(#8a5a00,#f2c94c));--c-warnbg:var(--color-background-warning,light-dark(#fff4d6,rgba(242,201,76,.14)));
|
|
8
|
+
--accent:#ffcf5a;--accent-ink:#1a1405;
|
|
9
|
+
--r-sm:var(--border-radius-sm,8px);--r-md:var(--border-radius-md,12px);--r-lg:var(--border-radius-lg,16px);--r-full:var(--border-radius-full,999px);
|
|
10
|
+
--f-sans:var(--font-sans,ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif);--f-mono:var(--font-mono,ui-monospace,SFMono-Regular,Menlo,monospace)}
|
|
11
|
+
[data-theme=light]{color-scheme:light}[data-theme=dark]{color-scheme:dark}
|
|
12
|
+
*{box-sizing:border-box}html,body{margin:0;background:transparent}
|
|
13
|
+
body{font:14px/1.45 var(--f-sans);color:var(--c-fg);-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:100%}
|
|
14
|
+
.card{background:var(--c-bg);border:1px solid var(--c-line);border-radius:var(--r-lg);padding:16px 16px 14px;overflow:hidden}
|
|
15
|
+
.top{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px}
|
|
16
|
+
.eyebrow{display:flex;align-items:center;gap:8px;font:600 12px/1 var(--f-sans);letter-spacing:.04em;text-transform:uppercase;color:var(--c-fg3)}
|
|
17
|
+
.mark{width:14px;height:14px;border-radius:4px;background:var(--accent);box-shadow:inset 0 -3px 0 rgba(0,0,0,.18)}
|
|
18
|
+
h1{margin:0 0 2px;font:650 20px/1.2 var(--f-sans);letter-spacing:-.01em;overflow-wrap:anywhere}
|
|
19
|
+
h2{margin:16px 0 8px;font:600 13px/1.2 var(--f-sans);color:var(--c-fg2);text-transform:uppercase;letter-spacing:.04em}
|
|
20
|
+
.sub{margin:0 0 12px;color:var(--c-fg2);overflow-wrap:anywhere}
|
|
21
|
+
.note{margin:10px 0 0;padding:10px 12px;border-radius:var(--r-md);background:var(--c-bg2);color:var(--c-fg2)}
|
|
22
|
+
.note.bad{background:var(--c-badbg);color:var(--c-bad)}.note.ok{background:var(--c-okbg);color:var(--c-ok);font-weight:600}
|
|
23
|
+
.fine{margin:10px 0 0;font-size:12px;color:var(--c-fg3)}
|
|
24
|
+
.pill{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:var(--r-full);font:600 12px/1.2 var(--f-sans);background:var(--c-bg2);color:var(--c-fg2);white-space:nowrap}
|
|
25
|
+
.pill.ok{background:var(--c-okbg);color:var(--c-ok)}.pill.bad{background:var(--c-badbg);color:var(--c-bad)}.pill.warn{background:var(--c-warnbg);color:var(--c-warn)}
|
|
26
|
+
.pill.live{background:color-mix(in srgb,var(--accent) 22%,transparent);color:var(--c-fg)}
|
|
27
|
+
.dot{width:7px;height:7px;border-radius:50%;background:currentColor;animation:pulse 1.4s ease-in-out infinite}
|
|
28
|
+
@keyframes pulse{50%{opacity:.25}}
|
|
29
|
+
@media (prefers-reduced-motion:reduce){.dot{animation:none}}
|
|
30
|
+
button{font:600 13px/1 var(--f-sans);border:0;border-radius:var(--r-full);padding:9px 14px;cursor:pointer;background:var(--accent);color:var(--accent-ink);min-height:34px}
|
|
31
|
+
button.ghost{background:var(--c-bg2);color:var(--c-fg);box-shadow:inset 0 0 0 1px var(--c-line2)}
|
|
32
|
+
button.small{padding:7px 12px;min-height:30px;font-size:12px}
|
|
33
|
+
button.danger{background:var(--c-badbg);color:var(--c-bad)}
|
|
34
|
+
button:disabled{opacity:.55;cursor:default}
|
|
35
|
+
button:focus-visible{outline:2px solid var(--c-fg);outline-offset:2px}
|
|
36
|
+
.actions{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
|
|
37
|
+
.foot{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px;margin-top:14px;padding-top:12px;border-top:1px solid var(--c-line)}
|
|
38
|
+
.when{color:var(--c-fg3);font-size:12px}
|
|
39
|
+
.skel{height:14px;margin:10px 0;border-radius:6px;background:linear-gradient(90deg,var(--c-bg2),var(--c-bg3),var(--c-bg2));background-size:200% 100%;animation:sk 1.2s linear infinite}
|
|
40
|
+
@keyframes sk{to{background-position:-200% 0}}
|
|
41
|
+
ol.list,ul.list{list-style:none;margin:0;padding:0;display:grid;gap:6px}
|
|
42
|
+
.list li{display:flex;align-items:flex-start;gap:10px;padding:8px 10px;border-radius:var(--r-md);background:var(--c-bg2)}
|
|
43
|
+
.list li.now{box-shadow:inset 0 0 0 1.5px var(--accent);background:color-mix(in srgb,var(--accent) 10%,var(--c-bg2))}
|
|
44
|
+
.list li.done .cn{background:var(--c-okbg);color:var(--c-ok)}
|
|
45
|
+
.list li.todo{opacity:.72}
|
|
46
|
+
.cn{flex:none;display:grid;place-items:center;width:22px;height:22px;border-radius:50%;background:var(--c-bg3);color:var(--c-fg2);font:700 12px/1 var(--f-sans)}
|
|
47
|
+
.cb{flex:1;min-width:0;display:grid;gap:2px}.cb b{font-weight:600}.cb span{color:var(--c-fg2);font-size:13px;overflow-wrap:anywhere}
|
|
48
|
+
.cb .fix{color:var(--c-fg);font-size:12.5px}
|
|
49
|
+
.state-ok .cn{background:var(--c-okbg);color:var(--c-ok)}.state-act .cn,.state-missing .cn{background:var(--c-warnbg);color:var(--c-warn)}
|
|
50
|
+
.stages{display:grid;grid-auto-flow:column;grid-auto-columns:1fr;gap:6px;margin:12px 0}
|
|
51
|
+
.stage{display:grid;gap:6px}.stage .bar{height:6px;border-radius:3px;background:var(--c-bg3)}
|
|
52
|
+
.stage.done .bar{background:var(--c-ok)}.stage.running .bar{background:var(--accent);animation:pulse 1.4s ease-in-out infinite}.stage.failed .bar{background:var(--c-bad)}.stage.skipped .bar{background:var(--c-bg3);opacity:.5}
|
|
53
|
+
.stage span{font-size:12px;color:var(--c-fg2)}
|
|
54
|
+
.check{display:flex;gap:8px;align-items:baseline;padding:4px 0;font-size:13px}.check .i{flex:none;width:18px;text-align:center}
|
|
55
|
+
.check.pass .i{color:var(--c-ok)}.check.fail .i{color:var(--c-bad)}.check.running .i{color:var(--c-warn)}
|
|
56
|
+
.check .n{color:var(--c-fg3);font-size:12px;margin-left:auto;white-space:nowrap}
|
|
57
|
+
.preview{margin-top:12px;display:grid;gap:8px}
|
|
58
|
+
.preview img{width:100%;max-height:260px;object-fit:cover;border-radius:var(--r-md);background:var(--c-bg2)}
|
|
59
|
+
.games{display:grid;grid-template-columns:repeat(auto-fill,minmax(168px,1fr));gap:10px;margin-top:8px}
|
|
60
|
+
.game{display:grid;gap:6px;padding:8px;border-radius:var(--r-md);background:var(--c-bg2)}
|
|
61
|
+
.game .art{aspect-ratio:16/9;border-radius:var(--r-sm);background:linear-gradient(135deg,color-mix(in srgb,var(--accent) 50%,var(--c-bg3)),var(--c-bg3));display:grid;place-items:center;font:800 26px/1 var(--f-sans);color:var(--c-fg2);overflow:hidden}
|
|
62
|
+
.game .art img{width:100%;height:100%;object-fit:cover}
|
|
63
|
+
.game b{font-size:14px}.game span{font-size:12.5px;color:var(--c-fg2)}
|
|
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
|
+
.soon b{font-size:16px}
|
|
66
|
+
.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
|
+
.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
|
+
.frame{width:100%;height:620px;border:0;border-radius:var(--r-md);background:var(--c-bg2);display:block}
|
|
69
|
+
.full .frame{height:calc(100vh - 120px)}
|
|
70
|
+
.ready{margin:0 0 8px;font-size:13px;color:var(--c-fg2)}.ready .ok{color:var(--c-ok);font-weight:600}
|
|
71
|
+
.tabs{display:flex;flex-wrap:wrap;gap:6px;margin:8px 0}
|
|
72
|
+
.tabs span{padding:4px 9px;border-radius:var(--r-full);background:var(--c-bg2);font-size:12px;color:var(--c-fg2)}
|
|
73
|
+
@media (max-width:420px){.card{padding:14px 12px 12px;border-radius:var(--r-md)}h1{font-size:18px}.stages{grid-auto-flow:row;grid-auto-columns:auto}.frame{height:520px}}
|
package/mcp/ui/codex.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/* The Game Codex card: the codex page itself (drawn by @homie-rocks/studio lib/codex.mjs in the game's own look:
|
|
2
|
+
* a tab per section, cards, controls, milestones, decisions, open questions, the build's status), in a frame. */
|
|
3
|
+
(function () {
|
|
4
|
+
var C = Card;
|
|
5
|
+
function render(sc) {
|
|
6
|
+
var card = C.el('main', 'card');
|
|
7
|
+
var todo = (sc.missing || []).length;
|
|
8
|
+
C.add(card, C.top('Game Codex', C.add(C.el('div', 'actions'), C.pill(todo ? todo + ' to decide' : 'Every section filled', todo ? 'warn' : 'ok'), C.fullscreenButton())), C.el('h1', '', sc.title));
|
|
9
|
+
// The page has its own tabs; the card says only what is still open.
|
|
10
|
+
if (sc.openQuestions) card.appendChild(C.el('p', 'sub', sc.openQuestions + ' open question' + (sc.openQuestions === 1 ? '' : 's') + ' · ' + (sc.sections || []).length + ' sections'));
|
|
11
|
+
if (sc.html) {
|
|
12
|
+
var frame = C.el('iframe', 'frame');
|
|
13
|
+
frame.title = 'The Game Codex for ' + sc.title;
|
|
14
|
+
frame.setAttribute('sandbox', 'allow-scripts');
|
|
15
|
+
frame.setAttribute('referrerpolicy', 'no-referrer');
|
|
16
|
+
frame.srcdoc = sc.html;
|
|
17
|
+
card.appendChild(frame);
|
|
18
|
+
} else card.appendChild(C.el('p', 'note', 'The page is too big to show here.'));
|
|
19
|
+
if (todo) card.appendChild(C.el('p', 'fine', 'Not decided yet: ' + sc.missing.join(', ') + '.'));
|
|
20
|
+
return card;
|
|
21
|
+
}
|
|
22
|
+
C.start({ kind: 'codex', label: 'Game Codex', fullscreen: true, render: render, poll: null });
|
|
23
|
+
})();
|
package/mcp/ui/setup.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/* The setup card: the new-studio checklist, this computer's setup status (what each row unlocks and its fix), the
|
|
2
|
+
* studio in use, and its toolkit install as it runs. */
|
|
3
|
+
(function () {
|
|
4
|
+
var C = Card;
|
|
5
|
+
var MARK = { ok: '✓', act: '→', missing: '!', optional: '○', later: '…', unknown: '?' };
|
|
6
|
+
function step(s) {
|
|
7
|
+
var li = C.el('li', s.state);
|
|
8
|
+
C.add(li, C.el('span', 'cn', s.state === 'done' ? '✓' : String(s.n)), C.add(C.el('div', 'cb'), C.el('b', '', s.label), s.state === 'now' ? C.el('span', '', 'Now') : null));
|
|
9
|
+
return li;
|
|
10
|
+
}
|
|
11
|
+
function row(r) {
|
|
12
|
+
var li = C.el('li', 'state-' + r.state);
|
|
13
|
+
var body = C.add(C.el('div', 'cb'), C.el('b', '', r.label + (r.need === 'optional' ? ' (optional)' : '')), C.el('span', '', r.detail), C.el('span', '', 'Unlocks ' + r.unlocks));
|
|
14
|
+
if (r.fix && r.state !== 'ok') body.appendChild(C.el('span', 'fix', r.fix.say));
|
|
15
|
+
C.add(li, C.el('span', 'cn', MARK[r.state] || '?'), body, r.fix && r.fix.open && r.state !== 'ok' ? C.btn('Open', 'ghost small', function () { C.open(r.fix.open); }) : null);
|
|
16
|
+
return li;
|
|
17
|
+
}
|
|
18
|
+
function render(sc) {
|
|
19
|
+
var card = C.el('main', 'card');
|
|
20
|
+
var cur = sc.current;
|
|
21
|
+
var install = sc.install;
|
|
22
|
+
var head = install && install.state === 'running' ? C.pill('Installing · ' + install.seconds + ' s', 'live', true)
|
|
23
|
+
: cur ? C.pill(cur.games.length ? cur.games.length + (cur.games.length === 1 ? ' game' : ' games') : 'No game yet', cur.games.length ? 'ok' : '') : C.pill('New studio', '');
|
|
24
|
+
C.add(card, C.top('Studio setup', head), C.el('h1', '', cur ? cur.name : 'Set up a studio'),
|
|
25
|
+
C.el('p', 'sub', cur ? (cur.games.length ? 'On this computer, in ' + cur.folder : 'On this computer, in ' + cur.folder + '. Its home page says "First game coming soon".') : 'Studios live in ' + (sc.studiosDir || 'the folder set in the Homie extension') + '.'));
|
|
26
|
+
var ol = C.el('ol', 'list');
|
|
27
|
+
(sc.checklist || []).forEach(function (s) { ol.appendChild(step(s)); });
|
|
28
|
+
card.appendChild(ol);
|
|
29
|
+
if (install) {
|
|
30
|
+
var msg = install.state === 'running' ? 'Installing the studio\'s toolkit and Wrangler (' + install.seconds + ' s)…' : install.state === 'done' ? 'The studio\'s toolkit is installed.' : 'The install failed: ' + (install.tail || []).slice(-2).join(' ');
|
|
31
|
+
card.appendChild(C.el('p', 'note' + (install.state === 'failed' ? ' bad' : ''), msg));
|
|
32
|
+
}
|
|
33
|
+
var rows = (sc.status && sc.status.rows) || [];
|
|
34
|
+
if (rows.length) {
|
|
35
|
+
// What needs the person comes in full; what is ready is one line.
|
|
36
|
+
var todo = rows.filter(function (r) { return r.state !== 'ok'; });
|
|
37
|
+
var ready = rows.filter(function (r) { return r.state === 'ok'; });
|
|
38
|
+
card.appendChild(C.el('h2', '', 'This computer and your accounts'));
|
|
39
|
+
if (ready.length) card.appendChild(C.add(C.el('p', 'ready'), C.el('span', 'ok', '✓ Ready: '), C.el('span', '', ready.map(function (r) { return r.label; }).join(' · '))));
|
|
40
|
+
if (todo.length) {
|
|
41
|
+
var ul = C.el('ul', 'list');
|
|
42
|
+
todo.forEach(function (r) { ul.appendChild(row(r)); });
|
|
43
|
+
card.appendChild(ul);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if ((sc.studios || []).length > 1) card.appendChild(C.el('p', 'fine', 'Studios here: ' + sc.studios.map(function (s) { return s.name; }).join(', ')));
|
|
47
|
+
var foot = C.el('div', 'foot');
|
|
48
|
+
foot.appendChild(C.el('span', 'when', 'Optional rows never block anything.'));
|
|
49
|
+
var acts = C.el('div', 'actions');
|
|
50
|
+
if (cur && !cur.games.length) {
|
|
51
|
+
acts.appendChild(C.btn(C.state.demoing ? 'Opening…' : 'Play a live game', '', function () {
|
|
52
|
+
if (C.state.demoing) return; C.state.demoing = true; C.render();
|
|
53
|
+
C.call('game_demo', {}).then(function (r) {
|
|
54
|
+
C.state.demoing = false; C.render();
|
|
55
|
+
var d = r && r.structuredContent; var p = d && d.pick;
|
|
56
|
+
if (p && p.play) { C.open(p.play); C.tell('demo', 'The person opened the live demo game ' + p.name + ' (' + p.play + ') from the setup card: step 2, see a working game. Nothing was copied into the studio.'); }
|
|
57
|
+
}).catch(function () { C.state.demoing = false; C.render(); });
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
acts.appendChild(C.btn('Check again', 'ghost small', function () {
|
|
61
|
+
C.call('setup_status', { fresh: true }).then(function (r) { C.take(r); }).catch(function () {});
|
|
62
|
+
}));
|
|
63
|
+
foot.appendChild(acts);
|
|
64
|
+
card.appendChild(foot);
|
|
65
|
+
if (install && install.state === 'done') C.tell('install-' + install.job, (cur ? cur.name : 'The studio') + '\'s toolkit finished installing; the studio is ready to build.');
|
|
66
|
+
return card;
|
|
67
|
+
}
|
|
68
|
+
C.start({
|
|
69
|
+
kind: 'setup', label: 'Studio setup', render: render,
|
|
70
|
+
poll: function (sc) { return sc.install && sc.install.state === 'running' ? { tool: 'setup_status', args: {}, every: 3000 } : null; },
|
|
71
|
+
});
|
|
72
|
+
})();
|
package/mcp/ui/studio.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/* The studio card: its games with Play (on this computer and live), songs, videos and posts, where it runs, its
|
|
2
|
+
* live rooms, and a live game to try while it has none. */
|
|
3
|
+
(function () {
|
|
4
|
+
var C = Card;
|
|
5
|
+
function initials(name) { return String(name || '?').split(/\s+/).filter(Boolean).slice(0, 2).map(function (w) { return w[0]; }).join('').toUpperCase(); }
|
|
6
|
+
function render(sc) {
|
|
7
|
+
var card = C.el('main', 'card');
|
|
8
|
+
C.add(card, C.top('Studio', C.pill(sc.site ? 'Online' : sc.dev ? 'Running here' : 'On this computer', sc.site ? 'ok' : '')), C.el('h1', '', sc.name), C.el('p', 'sub', sc.tagline || sc.folder));
|
|
9
|
+
var stats = C.el('div', 'stats');
|
|
10
|
+
[[sc.games.length, sc.games.length === 1 ? 'game' : 'games'], [sc.songs.length, sc.songs.length === 1 ? 'song' : 'songs'], [sc.videos.length, sc.videos.length === 1 ? 'video' : 'videos'], [sc.posts, sc.posts === 1 ? 'post' : 'posts'], [(sc.rooms || []).length, 'live rooms']].forEach(function (x) {
|
|
11
|
+
stats.appendChild(C.add(C.el('span'), C.el('b', '', String(x[0])), C.el('span', '', ' ' + x[1])));
|
|
12
|
+
});
|
|
13
|
+
card.appendChild(stats);
|
|
14
|
+
if (sc.games.length) {
|
|
15
|
+
var grid = C.el('div', 'games');
|
|
16
|
+
sc.games.forEach(function (g) {
|
|
17
|
+
var art = C.el('div', 'art'); var pic = C.img(g.cover, g.name);
|
|
18
|
+
if (pic) art.appendChild(pic); else art.textContent = initials(g.name);
|
|
19
|
+
var acts = C.el('div', 'actions');
|
|
20
|
+
if (g.play.dev) acts.appendChild(C.btn('Play here', 'small', function () { C.open(g.play.dev); }));
|
|
21
|
+
if (g.play.live) acts.appendChild(C.btn('Play live', g.play.dev ? 'ghost small' : 'small', function () { C.open(g.play.live); }));
|
|
22
|
+
grid.appendChild(C.add(C.el('div', 'game'), art, C.el('b', '', g.name), g.blurb ? C.el('span', '', g.blurb) : null, sc.site && !g.live ? C.el('span', 'fine', 'Not online yet: deploy puts it there') : null, acts.childNodes.length ? acts : null));
|
|
23
|
+
});
|
|
24
|
+
card.appendChild(grid);
|
|
25
|
+
} else {
|
|
26
|
+
var soon = C.add(C.el('div', 'soon'), C.el('b', '', 'First game coming soon'), C.el('span', '', 'That is what the studio\'s home page says until its first game is made. Plan it first: it is made the way you decide.'));
|
|
27
|
+
if (sc.demo) soon.appendChild(C.add(C.el('div', 'actions'), C.btn('Play ' + sc.demo.name, 'small', function () { C.open(sc.demo.play); C.tell('demo', 'The person opened the live demo ' + sc.demo.name + ' from the studio card.'); }), C.el('span', 'fine', 'A live game on ' + sc.demo.studio + ', made with Homie')));
|
|
28
|
+
card.appendChild(soon);
|
|
29
|
+
}
|
|
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
|
+
var foot = C.el('div', 'foot');
|
|
32
|
+
foot.appendChild(C.el('span', 'when', sc.site ? sc.site.replace(/^https?:\/\//, '') : sc.dev ? sc.dev.url.replace(/^https?:\/\//, '') : 'Not online yet'));
|
|
33
|
+
var acts = C.el('div', 'actions');
|
|
34
|
+
if (sc.dev) acts.appendChild(C.btn('Open here', 'ghost small', function () { C.open(sc.dev.url + '/'); }));
|
|
35
|
+
if (sc.site) acts.appendChild(C.btn('Open site', 'small', function () { C.open(sc.site + '/'); }));
|
|
36
|
+
foot.appendChild(acts);
|
|
37
|
+
card.appendChild(foot);
|
|
38
|
+
return card;
|
|
39
|
+
}
|
|
40
|
+
C.start({ kind: 'studio', label: 'Studio', render: render, poll: null });
|
|
41
|
+
})();
|
package/netplay/NETPLAY.md
CHANGED
|
@@ -660,7 +660,7 @@ tools). The room's relay enforces five controls:
|
|
|
660
660
|
| `announce` | `text` (one line, at most 280 characters; empty takes it down), `seconds` (5–3600, default 30) | Everyone and every watching shell gets `announce { id, text, at, until, from: 'studio' }`; a joiner gets it in `welcome.announce` until it ends. |
|
|
661
661
|
| `close` | `minutes`, or `seconds` (10 s up to a day), `message?`, or `reopen: true` | Every socket gets `error room-closed { until, message }` and is closed; the room forgets its play as an empty room does (section 4); watchers get `closed`. Until `until` every `hello` is refused with `room-closed`, and the Lobby sends nobody there. `reopen` opens it at once. |
|
|
662
662
|
| `seats` | `max` | The room's seats while it runs (the owner's "players per room"). Players already seated above it keep their seat; nobody new is seated there. Never above the room's own cap (the game's manifest). |
|
|
663
|
-
| `regate` | `allow` (holder kinds: `o` the owner, `i` an invite, `p` a player account), `notice?`, `message?`; no `allow` calls a waiting one off | A game's launch state narrowed (private, or an invite-only beta). The current round finishes first: the `notice` goes up as an announcement, and 5 s after the host's `round` with `phase: 'over'` (at the latest 20 s after its `endsAt`, or 30 s when no round is running; never later than 15 minutes) every socket whose ticket names no allowed holder gets `error room-closed { message }` (no `until`: they come back through the game's door if they have access) and is closed; its seat is freed and the others play on. A room with nobody to send out does nothing. |
|
|
663
|
+
| `regate` | `allow` (holder kinds: `o` the owner, `i` an invite, `p` a player account), `notice?`, `message?`; no `allow` calls a waiting one off | A game's launch state narrowed (private, or an invite-only beta). The current round finishes first: the `notice` goes up as an announcement, and 5 s after the host's `round` with `phase: 'over'` (at the latest 20 s after its `endsAt`, or 30 s when no round is running; never later than 15 minutes) every socket whose ticket names no allowed holder gets `error room-closed { message }` (no `until`: they come back through the game's door if they have access) and is closed; its seat is freed and the others play on. A room with nobody to send out does nothing. The studio's Worker sends it to every live room the game's Lobby knows; a room that opens in the same moment reads its game's launch state once on its first heartbeat and re-gates itself the same way. |
|
|
664
664
|
|
|
665
665
|
**Signed by the studio, checked by the room.** A control reaches the relay only from the
|
|
666
666
|
studio's own Worker, as
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, optional R2 storage for large media, deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"netplay/",
|
|
40
40
|
"port/",
|
|
41
41
|
"worker/",
|
|
42
|
+
"mcp/",
|
|
42
43
|
"saves/",
|
|
43
44
|
"starters/",
|
|
44
45
|
"media/",
|
package/worker/index.mjs
CHANGED
|
@@ -66,7 +66,7 @@ import { count, countVisit, counter, isQa, onlyOf, ownerAllowed, playedByGame, p
|
|
|
66
66
|
import { ownerRoutes } from './stats-page.mjs';
|
|
67
67
|
import { playerRoutes, players as playerAccounts } from './players.mjs';
|
|
68
68
|
import {
|
|
69
|
-
accessOf, accountSub, gatePage, holders, isOwner, joinHolders, launchOf, officeRoutes, publicCatalogue, redeemInvite, remixOf, seatsFor, settingsOf, ticketAllows, ticketFor,
|
|
69
|
+
accessOf, accountSub, gatePage, holders, isOwner, joinHolders, launchOf, regateArgs, officeRoutes, publicCatalogue, redeemInvite, remixOf, seatsFor, settingsOf, ticketAllows, ticketFor,
|
|
70
70
|
ticketSub, usePlayers, verifyControl,
|
|
71
71
|
} from './office.mjs';
|
|
72
72
|
|
|
@@ -815,6 +815,9 @@ export class Table {
|
|
|
815
815
|
const room = this.room;
|
|
816
816
|
if (!room) return;
|
|
817
817
|
room.tick();
|
|
818
|
+
// A room that just opened reads its game's launch state once: a change made in the moment it opened (before the
|
|
819
|
+
// Lobby knew of it) still reaches it, after the current round as always.
|
|
820
|
+
if (room.openedAt && this.launchReadFor !== room.openedAt) { this.launchReadFor = room.openedAt; this.ctx.waitUntil(this.rereadLaunch().catch(() => {})); }
|
|
818
821
|
// A launch change the room applied by itself (after its round): its stored state says so too.
|
|
819
822
|
if (room.officeDirty) { room.officeDirty = false; this.ctx.storage.put('office', room.officeSaved()).catch(() => {}); }
|
|
820
823
|
n += 1;
|
|
@@ -824,6 +827,22 @@ export class Table {
|
|
|
824
827
|
}, 250);
|
|
825
828
|
}
|
|
826
829
|
|
|
830
|
+
/**
|
|
831
|
+
* The game's launch state, read once when the room opens (one D1 read): a private or invite-only game whose room
|
|
832
|
+
* holds anyone the state leaves out re-gates after the current round. Never in a Preview (no launch states there).
|
|
833
|
+
*/
|
|
834
|
+
async rereadLaunch() {
|
|
835
|
+
const room = this.room;
|
|
836
|
+
if (!room || !this.game || !this.env.DB || this.env.HOMIE_PREVIEW === '1' || room.regate) return null;
|
|
837
|
+
let launch = null;
|
|
838
|
+
try { launch = (await this.env.DB.prepare('SELECT launch FROM office_games WHERE game = ?1').bind(this.game).first())?.launch ?? null; } catch { return null; }
|
|
839
|
+
const args = regateArgs(launch);
|
|
840
|
+
if (!args.allow) return null;
|
|
841
|
+
const res = room.control('regate', args);
|
|
842
|
+
if (res.ok && res.leaving) await this.ctx.storage.put('office', room.officeSaved()).catch(() => {});
|
|
843
|
+
return res;
|
|
844
|
+
}
|
|
845
|
+
|
|
827
846
|
/** Tell the Lobby this room closed (until when; 0 when it opened again), so it sends nobody here meanwhile. */
|
|
828
847
|
tellLobby(extra) {
|
|
829
848
|
if (!this.game || !this.code) return;
|
package/worker/office.mjs
CHANGED
|
@@ -57,6 +57,21 @@ const TICKET_TTL_MS = 12 * 3600_000;
|
|
|
57
57
|
/** Who may stay in a live room after a launch change, by the kinds of holder a ticket names (o, i-…, p-…). */
|
|
58
58
|
const ALLOW = Object.freeze({ private: ['o'], invite: ['o', 'i'], public: null });
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The room control for a launch state (NETPLAY.md section 15 `regate`): who may stay, the notice everyone sees while
|
|
62
|
+
* the round finishes, and the words for whoever then leaves. Public calls a waiting change off.
|
|
63
|
+
*/
|
|
64
|
+
export function regateArgs(launch, name = 'This game') {
|
|
65
|
+
if (!ALLOW[launch]) return {};
|
|
66
|
+
return {
|
|
67
|
+
allow: ALLOW[launch],
|
|
68
|
+
...{
|
|
69
|
+
private: { notice: `${name} goes private after this round. Thanks for playing!`, message: `${name} is private now. Thanks for playing!` },
|
|
70
|
+
invite: { notice: `${name} becomes an invite-only beta after this round. Invited players keep playing.`, message: `${name} is an invite-only beta now. Have an invite? Enter it on the game's page. Thanks for playing!` },
|
|
71
|
+
}[launch],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
60
75
|
const json = (body, status = 200, extra = {}) => new Response(`${JSON.stringify(body)}\n`, {
|
|
61
76
|
status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store, private', 'x-robots-tag': 'noindex', ...extra },
|
|
62
77
|
});
|
|
@@ -578,12 +593,7 @@ export async function perform(env, cat, a) {
|
|
|
578
593
|
// state leaves out is sent out of it (the owner, and in an invite-only beta the invited, play on). Back to a
|
|
579
594
|
// wider state, a change still waiting is called off.
|
|
580
595
|
if (launch !== prev) {
|
|
581
|
-
const
|
|
582
|
-
private: { notice: `${meta.name} goes private after this round. Thanks for playing!`, message: `${meta.name} is private now. Thanks for playing!` },
|
|
583
|
-
invite: { notice: `${meta.name} becomes an invite-only beta after this round. Invited players keep playing.`, message: `${meta.name} is an invite-only beta now. Have an invite? Enter it on the game's page. Thanks for playing!` },
|
|
584
|
-
public: {},
|
|
585
|
-
}[launch];
|
|
586
|
-
const res = await Promise.all(rooms.map((r) => roomControl(env, meta, fresh, r.room, 'regate', ALLOW[launch] ? { allow: ALLOW[launch], ...words } : {})));
|
|
596
|
+
const res = await Promise.all(rooms.map((r) => roomControl(env, meta, fresh, r.room, 'regate', regateArgs(launch, meta.name))));
|
|
587
597
|
done.regating = res.filter((r) => r.ok && r.leaving > 0).length;
|
|
588
598
|
}
|
|
589
599
|
if (a.maxPlayers !== undefined) {
|
package/worker/site.mjs
CHANGED
|
@@ -692,6 +692,7 @@ export function homePage(cat, { origin = '', rooms = [], live = {} } = {}) {
|
|
|
692
692
|
const f = featuredOf(cat);
|
|
693
693
|
const tagline = cat.studio?.tagline ?? '';
|
|
694
694
|
let hero;
|
|
695
|
+
let soon = '';
|
|
695
696
|
if (f) {
|
|
696
697
|
const playing = live[f.id] ?? 0;
|
|
697
698
|
hero = `<section class="hero home" aria-labelledby="hero-title">
|
|
@@ -704,6 +705,27 @@ export function homePage(cat, { origin = '', rooms = [], live = {} } = {}) {
|
|
|
704
705
|
<a class="also" href="/${esc(f.id)}/">${icon('arrow')}<span>About ${esc(f.name)}</span></a>${games.length > 1 ? `<a class="also" href="/games/">${icon('games')}<span>All ${games.length} games</span></a>` : ''}
|
|
705
706
|
</div>
|
|
706
707
|
</section>`;
|
|
708
|
+
} else if (!videos.length && !songs.length) {
|
|
709
|
+
// A new studio with nothing published yet: its own Home, its name, "First game coming soon", and what is on the
|
|
710
|
+
// way (a studio is never given a starter game it did not ask for). Its posts show below as soon as it has any.
|
|
711
|
+
hero = `<section class="hero home soon" aria-labelledby="hero-title">
|
|
712
|
+
${heroMedia({}, { word: name })}
|
|
713
|
+
<div class="hero-copy">
|
|
714
|
+
<p class="kicker">${esc(tagline || 'A Homie studio')}</p>
|
|
715
|
+
<h1 class="title ${titleClass(name)}" id="hero-title">${esc(name)}</h1>
|
|
716
|
+
<p class="line">First game coming soon.</p>
|
|
717
|
+
<p class="live"><span class="live-dot" aria-hidden="true"></span><span>${posts.length ? 'In the making · the latest news is below' : 'In the making'}</span></p>
|
|
718
|
+
</div>
|
|
719
|
+
</section>`;
|
|
720
|
+
const next = [
|
|
721
|
+
['Games', 'The first game', 'It opens here with a Play button: a live public room on a phone, a computer or a TV, with bots in the empty seats.'],
|
|
722
|
+
['Posts', 'News and drops', 'The studio\'s posts land on this page, with feeds to follow.'],
|
|
723
|
+
['Music and videos', 'Songs and trailers', 'Scores, songs and trailers get pages of their own.'],
|
|
724
|
+
];
|
|
725
|
+
soon = `<section class="band glowband" aria-labelledby="soon-title"><div class="band-in">
|
|
726
|
+
<div class="head-row reveal"><div><p class="kicker">On the way</p><h2 class="small-h" id="soon-title">${esc(`Coming to ${name}`)}</h2></div></div>
|
|
727
|
+
<div class="cards">${next.map(([k, t, d]) => `<div class="pcard reveal" data-soon><p class="post-meta">${esc(k)}</p><h3>${esc(t)}</h3><p>${esc(d)}</p></div>`).join('')}</div>
|
|
728
|
+
</div></section>`;
|
|
707
729
|
} else {
|
|
708
730
|
const lead = videos[0] ?? songs[0] ?? null;
|
|
709
731
|
const art = lead ? mediaArt(lead, videos[0] ? 'videos' : 'music', cat) : null;
|
|
@@ -736,10 +758,10 @@ export function homePage(cat, { origin = '', rooms = [], live = {} } = {}) {
|
|
|
736
758
|
const extra = partial(cat, 'home');
|
|
737
759
|
return layout(cat, {
|
|
738
760
|
title: tagline ? `${name} — ${tagline}` : name,
|
|
739
|
-
description: tagline || (f ? `${name}: ${games.map((g) => g.name).join(', ')}. Free in your browser, on a phone, a computer or a TV.` : name),
|
|
761
|
+
description: tagline || (f ? `${name}: ${games.map((g) => g.name).join(', ')}. Free in your browser, on a phone, a computer or a TV.` : soon ? `${name}: first game coming soon.` : name),
|
|
740
762
|
origin, path: '/', image: f ? (f.landing?.hero?.wideImage ?? coverOf(f)) : null, page: 'home', hero: true,
|
|
741
763
|
head: f?.landing?.hero?.wideImage ? `<link rel="preload" as="image" href="${esc(f.landing.hero.tallImage ?? f.landing.hero.wideImage)}">` : '',
|
|
742
|
-
main: `${hero}${extra ? `<section class="band">${extra}</section>` : ''}${roomsBand}${gamesBand}${postsBand}${mediaBand(videos, 'videos', 'Trailers and clips')}${mediaBand(songs, 'music', 'Songs and scores')}`,
|
|
764
|
+
main: `${hero}${extra ? `<section class="band">${extra}</section>` : ''}${roomsBand}${gamesBand}${postsBand}${soon}${mediaBand(videos, 'videos', 'Trailers and clips')}${mediaBand(songs, 'music', 'Songs and scores')}`,
|
|
743
765
|
});
|
|
744
766
|
}
|
|
745
767
|
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.14.1';
|