@homie-rocks/studio 0.12.1 → 0.14.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 +92 -4
- package/bin/homie-studio.mjs +83 -8
- package/lib/build.mjs +2 -0
- package/lib/cloudflare.mjs +5 -5
- 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/office.mjs +172 -0
- package/lib/scaffold.mjs +98 -25
- package/lib/stats.mjs +1 -1
- package/lib/studio.mjs +4 -1
- package/lib/template-history.json +52 -0
- package/lib/upgrade.mjs +6 -3
- 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 +101 -15
- package/netplay/netplay.ts +49 -3
- package/package.json +2 -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/site.mjs +24 -2
- package/worker/stats-page.mjs +16 -7
- package/worker/stats.mjs +39 -12
- package/worker/version.mjs +1 -1
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The back office's pages (worker/office.mjs has the rules): the owner's live office (/_studio/office), the page
|
|
3
|
+
* where the owner confirms what their AI asked (/_studio/confirm/<ask>), and the page anyone else gets.
|
|
4
|
+
*
|
|
5
|
+
* The office is one document and one script, inline, allowed by its hash (no other script, nothing from anywhere
|
|
6
|
+
* else, connect only to this site). Everything a player typed is set as text, never as markup. Never cached,
|
|
7
|
+
* never indexed, never framed.
|
|
8
|
+
*/
|
|
9
|
+
import { esc } from './site.mjs';
|
|
10
|
+
|
|
11
|
+
const PRIVATE = {
|
|
12
|
+
'content-type': 'text/html; charset=utf-8',
|
|
13
|
+
'cache-control': 'no-store, private',
|
|
14
|
+
'x-robots-tag': 'noindex, nofollow',
|
|
15
|
+
'referrer-policy': 'same-origin',
|
|
16
|
+
'x-frame-options': 'DENY',
|
|
17
|
+
};
|
|
18
|
+
const NO_SCRIPT_CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src data:; form-action 'self'; frame-ancestors 'none'; base-uri 'none'";
|
|
19
|
+
|
|
20
|
+
const accentOf = (cat) => { const a = cat?.studio?.theme?.accent; return /^#[0-9a-f]{3,8}$/i.test(String(a ?? '')) ? a : '#ffcf5a'; };
|
|
21
|
+
|
|
22
|
+
const CSS = `
|
|
23
|
+
:root { color-scheme: dark; --bg: #07080d; --panel: #10131c; --panel2: #161a26; --ink: #eef1f8; --dim: #9aa3b7; --faint: #6c7489; --line: rgba(255,255,255,.10); --line2: rgba(255,255,255,.18); --good: #7dffb0; --warn: #ffb35c; --bad: #ff6b7d; }
|
|
24
|
+
* { box-sizing: border-box; }
|
|
25
|
+
html, body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.45 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
26
|
+
.wrap { max-width: 1120px; margin: 0 auto; padding: 24px 16px 72px; }
|
|
27
|
+
header.top { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 12px; padding-bottom: 16px; border-bottom: 1px solid var(--line); }
|
|
28
|
+
h1 { margin: 0; font-size: clamp(22px, 4.6vw, 32px); letter-spacing: -.02em; }
|
|
29
|
+
h1 small { color: var(--dim); font-weight: 600; font-size: .55em; letter-spacing: .02em; margin-left: 6px; }
|
|
30
|
+
h2 { margin: 0; font-size: 20px; letter-spacing: -.01em; }
|
|
31
|
+
h3 { margin: 18px 0 8px; font-size: 12px; letter-spacing: .09em; text-transform: uppercase; color: var(--dim); }
|
|
32
|
+
a { color: var(--accent); }
|
|
33
|
+
.dim { color: var(--dim); } .faint { color: var(--faint); }
|
|
34
|
+
.links { display: flex; gap: 14px; align-items: center; font-size: 14px; }
|
|
35
|
+
.live { display: inline-flex; align-items: center; gap: 7px; font-weight: 700; }
|
|
36
|
+
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--faint); }
|
|
37
|
+
.dot.on { background: var(--good); box-shadow: 0 0 0 0 rgba(125,255,176,.6); animation: pulse 1.8s infinite; }
|
|
38
|
+
@keyframes pulse { 70% { box-shadow: 0 0 0 9px rgba(125,255,176,0); } 100% { box-shadow: 0 0 0 0 rgba(125,255,176,0); } }
|
|
39
|
+
.bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 18px 0 6px; padding: 12px; background: var(--panel); border: 1px solid var(--line); border-radius: 14px; }
|
|
40
|
+
.bar label { font-size: 13px; color: var(--dim); margin-right: 2px; }
|
|
41
|
+
input, select { font: inherit; color: inherit; background: var(--bg); border: 1px solid var(--line2); border-radius: 10px; padding: 8px 10px; min-height: 38px; }
|
|
42
|
+
input[type=text] { flex: 1 1 220px; min-width: 0; }
|
|
43
|
+
input[type=number] { width: 76px; }
|
|
44
|
+
button { font: inherit; font-weight: 700; color: #16120a; background: var(--accent); border: 0; border-radius: 10px; padding: 8px 14px; min-height: 38px; cursor: pointer; touch-action: manipulation; }
|
|
45
|
+
button.ghost { background: transparent; color: var(--ink); border: 1px solid var(--line2); font-weight: 600; }
|
|
46
|
+
button.warn { background: transparent; color: var(--warn); border: 1px solid rgba(255,179,92,.45); }
|
|
47
|
+
button.bad { background: transparent; color: var(--bad); border: 1px solid rgba(255,107,125,.45); }
|
|
48
|
+
button.armed { background: var(--bad); color: #16080a; border-color: var(--bad); }
|
|
49
|
+
button:disabled { opacity: .45; cursor: default; }
|
|
50
|
+
button.small { min-height: 30px; padding: 4px 10px; font-size: 13px; border-radius: 8px; }
|
|
51
|
+
.game { margin-top: 22px; background: var(--panel); border: 1px solid var(--line); border-radius: 18px; overflow: hidden; }
|
|
52
|
+
.ghead { display: flex; flex-wrap: wrap; gap: 12px 18px; align-items: center; justify-content: space-between; padding: 16px 16px 14px; border-bottom: 1px solid var(--line); }
|
|
53
|
+
.ghead .name { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
|
|
54
|
+
.state { display: inline-flex; border: 1px solid var(--line2); border-radius: 999px; padding: 3px; gap: 2px; }
|
|
55
|
+
.state button { background: transparent; color: var(--dim); font-weight: 600; border-radius: 999px; min-height: 30px; padding: 4px 12px; font-size: 13px; }
|
|
56
|
+
.state button[aria-pressed=true] { background: var(--ink); color: var(--bg); }
|
|
57
|
+
.state button[data-v=private][aria-pressed=true] { background: #c9b8ff; }
|
|
58
|
+
.state button[data-v=invite][aria-pressed=true] { background: var(--warn); }
|
|
59
|
+
.state button[data-v=public][aria-pressed=true] { background: var(--good); }
|
|
60
|
+
.settings { display: flex; flex-wrap: wrap; gap: 10px 18px; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--line); background: var(--panel2); font-size: 14px; }
|
|
61
|
+
.settings label { display: inline-flex; gap: 8px; align-items: center; }
|
|
62
|
+
.switch { appearance: none; -webkit-appearance: none; width: 42px; height: 24px; min-height: 0; padding: 0; border-radius: 999px; background: var(--line2); position: relative; cursor: pointer; border: 0; }
|
|
63
|
+
.switch::after { content: ""; position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%; background: var(--ink); transition: left .15s; }
|
|
64
|
+
.switch:checked { background: var(--good); } .switch:checked::after { left: 21px; background: #0a1a10; }
|
|
65
|
+
.rooms { padding: 4px 0; }
|
|
66
|
+
.room { border-top: 1px solid var(--line); }
|
|
67
|
+
.room:first-child { border-top: 0; }
|
|
68
|
+
.rhead { display: grid; grid-template-columns: minmax(110px, 1.2fr) repeat(4, minmax(70px, 1fr)) auto; gap: 8px 12px; align-items: center; padding: 12px 16px; cursor: pointer; }
|
|
69
|
+
.rhead:hover { background: rgba(255,255,255,.025); }
|
|
70
|
+
.rhead b { font-size: 15px; }
|
|
71
|
+
.stat { font-variant-numeric: tabular-nums; font-size: 14px; }
|
|
72
|
+
.stat span { display: block; color: var(--faint); font-size: 11px; letter-spacing: .06em; text-transform: uppercase; }
|
|
73
|
+
.racts { display: flex; gap: 6px; justify-content: flex-end; flex-wrap: wrap; }
|
|
74
|
+
.people { padding: 0 16px 14px; }
|
|
75
|
+
.person { display: grid; grid-template-columns: 1fr auto; gap: 8px; align-items: center; padding: 9px 12px; margin-top: 6px; border-radius: 12px; background: var(--bg); border: 1px solid var(--line); }
|
|
76
|
+
.person .who { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: baseline; min-width: 0; }
|
|
77
|
+
.person .who b { overflow-wrap: anywhere; }
|
|
78
|
+
.chip { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: 12px; border: 1px solid var(--line2); color: var(--dim); }
|
|
79
|
+
.chip.host { color: var(--accent); border-color: var(--accent); }
|
|
80
|
+
.chip.owner { color: #c9b8ff; border-color: #c9b8ff; }
|
|
81
|
+
.chip.invited { color: var(--warn); border-color: rgba(255,179,92,.6); }
|
|
82
|
+
.chip.muted { color: var(--bad); border-color: rgba(255,107,125,.6); }
|
|
83
|
+
.chip.bot { color: var(--faint); }
|
|
84
|
+
.pacts { display: flex; gap: 6px; }
|
|
85
|
+
.empty { padding: 18px 16px; color: var(--dim); }
|
|
86
|
+
.held { padding: 0 16px 12px; font-size: 13px; color: var(--dim); }
|
|
87
|
+
.invites { padding: 12px 16px 14px; border-bottom: 1px solid var(--line); }
|
|
88
|
+
.inv { display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center; padding: 8px 0; border-top: 1px dashed var(--line); font-size: 14px; }
|
|
89
|
+
.inv:first-of-type { border-top: 0; }
|
|
90
|
+
.inv code { font: 700 15px/1 ui-monospace, Menlo, monospace; letter-spacing: .06em; color: var(--ink); }
|
|
91
|
+
.inv.closed { opacity: .5; }
|
|
92
|
+
.newinv { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
|
|
93
|
+
.toast { position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%); background: #1f2433; border: 1px solid var(--line2); padding: 10px 16px; border-radius: 12px; font-weight: 600; max-width: calc(100vw - 32px); z-index: 9; }
|
|
94
|
+
.note { color: var(--dim); font-size: 13px; max-width: 75ch; margin-top: 26px; }
|
|
95
|
+
.err { color: var(--bad); }
|
|
96
|
+
@media (max-width: 720px) {
|
|
97
|
+
.rhead { grid-template-columns: 1fr 1fr 1fr; }
|
|
98
|
+
.rhead .racts { grid-column: 1 / -1; justify-content: flex-start; }
|
|
99
|
+
.person { grid-template-columns: 1fr; }
|
|
100
|
+
}
|
|
101
|
+
`;
|
|
102
|
+
|
|
103
|
+
/* The office's one script (allowed by its hash). It reads /_studio/api/office every 3 s while the page is visible. */
|
|
104
|
+
export const OFFICE_SCRIPT = String.raw`(function () {
|
|
105
|
+
'use strict';
|
|
106
|
+
var S = { data: null, open: {}, busy: false, err: 0, timer: null };
|
|
107
|
+
var root = document.getElementById('games');
|
|
108
|
+
var liveEl = document.getElementById('live');
|
|
109
|
+
var holdEl = document.getElementById('hold');
|
|
110
|
+
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined && text !== null) e.textContent = String(text); return e; }
|
|
111
|
+
function add(p) { for (var i = 1; i < arguments.length; i++) if (arguments[i]) p.appendChild(arguments[i]); return p; }
|
|
112
|
+
function btn(label, cls, fn) { var b = el('button', cls || '', label); b.type = 'button'; b.onclick = fn; return b; }
|
|
113
|
+
function toast(text, bad) { var t = document.getElementById('toast'); t.textContent = text; t.className = 'toast' + (bad ? ' err' : ''); t.hidden = false; clearTimeout(toast.t); toast.t = setTimeout(function () { t.hidden = true; }, 3500); }
|
|
114
|
+
function dur(ms) { if (!(ms >= 0)) return '–'; var s = Math.floor(ms / 1000); var h = Math.floor(s / 3600); var m = Math.floor((s % 3600) / 60); return h ? h + ' h ' + m + ' min' : m ? m + ' min' : s + ' s'; }
|
|
115
|
+
function clock(ms) { if (!(ms >= 0)) return ''; var s = Math.round(ms / 1000); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); }
|
|
116
|
+
function hold() { return Number(holdEl.value) || 10; }
|
|
117
|
+
function post(path, body) {
|
|
118
|
+
return fetch(path, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
119
|
+
.then(function (r) { return r.json().catch(function () { return { ok: false, message: 'HTTP ' + r.status }; }); });
|
|
120
|
+
}
|
|
121
|
+
function act(path, body, done) {
|
|
122
|
+
return post(path, body).then(function (r) {
|
|
123
|
+
if (r && r.ok) toast(done || (r.what ? 'Done: ' + r.what : 'Done.'));
|
|
124
|
+
else toast((r && (r.message || r.error)) || 'That did not work.', true);
|
|
125
|
+
load();
|
|
126
|
+
return r;
|
|
127
|
+
}).catch(function () { toast('The studio did not answer.', true); });
|
|
128
|
+
}
|
|
129
|
+
// A control that takes something away needs a second tap within 4 s.
|
|
130
|
+
function armed(label, cls, fn) {
|
|
131
|
+
var b = btn(label, cls + ' small', function () {
|
|
132
|
+
if (b.getAttribute('data-armed') === '1') { b.disabled = true; fn(); return; }
|
|
133
|
+
b.setAttribute('data-armed', '1'); b.textContent = 'Sure? ' + label; b.classList.add('armed');
|
|
134
|
+
setTimeout(function () { b.removeAttribute('data-armed'); b.textContent = label; b.classList.remove('armed'); }, 4000);
|
|
135
|
+
});
|
|
136
|
+
return b;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function gameSettings(g) {
|
|
140
|
+
var box = el('div', 'settings');
|
|
141
|
+
var st = el('div', 'state'); st.setAttribute('role', 'group'); st.setAttribute('aria-label', 'Launch state');
|
|
142
|
+
[['private', 'Private'], ['invite', 'Invite-only beta'], ['public', 'Public']].forEach(function (p) {
|
|
143
|
+
var b = btn(p[1], '', function () {
|
|
144
|
+
if (g.launch === p[0]) return;
|
|
145
|
+
if (p[0] !== 'public' && !confirm('Make ' + g.name + ' ' + p[1].toLowerCase() + '? Its live rooms finish their current round with a notice, then everyone the new state leaves out is sent out with a thank-you. It leaves the directory the next time the directory reads this studio.')) return;
|
|
146
|
+
act('/_studio/api/game', { game: g.id, launch: p[0] }, g.name + ' is ' + p[1].toLowerCase() + ' now.');
|
|
147
|
+
});
|
|
148
|
+
b.setAttribute('data-v', p[0]); b.setAttribute('aria-pressed', g.launch === p[0] ? 'true' : 'false');
|
|
149
|
+
st.appendChild(b);
|
|
150
|
+
});
|
|
151
|
+
var remix = el('input', 'switch'); remix.type = 'checkbox'; remix.checked = g.remix; remix.disabled = !g.remixBuilt;
|
|
152
|
+
remix.setAttribute('aria-label', 'Remixable');
|
|
153
|
+
remix.onchange = function () { act('/_studio/api/game', { game: g.id, remix: remix.checked }, remix.checked ? g.name + '\'s source is open for remixing.' : g.name + '\'s source is withdrawn.'); };
|
|
154
|
+
var max = el('input'); max.type = 'number'; max.min = '1'; max.max = String(g.seats); max.value = String(g.maxPlayers); max.setAttribute('aria-label', 'Players per room');
|
|
155
|
+
var setMax = btn('Set', 'ghost small', function () {
|
|
156
|
+
var n = Math.floor(Number(max.value));
|
|
157
|
+
act('/_studio/api/game', { game: g.id, maxPlayers: n >= g.seats ? null : n }, 'Rooms of ' + g.name + ' now hold ' + Math.min(n, g.seats) + '.');
|
|
158
|
+
});
|
|
159
|
+
add(box, st,
|
|
160
|
+
add(el('label'), remix, el('span', '', g.remixBuilt ? 'Remixable' : 'Remixable (not in the build)')),
|
|
161
|
+
add(el('label'), el('span', 'dim', 'Players per room'), max, el('span', 'faint', 'of ' + g.seats), setMax),
|
|
162
|
+
add(el('span', 'faint'), document.createTextNode(g.launchFrom === 'game.json' ? 'state from game.json' : '')));
|
|
163
|
+
return box;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function invites(g) {
|
|
167
|
+
var box = el('div', 'invites');
|
|
168
|
+
add(box, el('h3', '', 'Invites' + (g.launch === 'invite' ? '' : ' (they work while the game is invite-only)')));
|
|
169
|
+
(g.invites || []).forEach(function (i) {
|
|
170
|
+
var row = el('div', 'inv' + (i.open ? '' : ' closed'));
|
|
171
|
+
add(row, el('code', '', i.code), el('span', '', i.label || 'no label'),
|
|
172
|
+
el('span', 'faint', i.uses + (i.maxUses ? ' of ' + i.maxUses : '') + ' used' + (i.revoked ? ' · revoked' : !i.open ? ' · ended' : '')));
|
|
173
|
+
if (i.open) {
|
|
174
|
+
add(row, btn('Copy link', 'ghost small', function () { (navigator.clipboard ? navigator.clipboard.writeText(i.link) : Promise.reject()).then(function () { toast('Invite link copied'); }, function () { toast(i.link); }); }),
|
|
175
|
+
armed('Revoke', 'bad', function () { act('/_studio/api/invites/revoke', { game: g.id, id: i.id }, 'Invite ' + i.code + ' revoked.'); }));
|
|
176
|
+
}
|
|
177
|
+
box.appendChild(row);
|
|
178
|
+
});
|
|
179
|
+
var label = el('input'); label.type = 'text'; label.placeholder = 'Who it is for (optional)'; label.maxLength = 60;
|
|
180
|
+
var uses = el('select'); [['1', 'one person'], ['10', 'up to 10'], ['100', 'up to 100'], ['any', 'anyone with it']].forEach(function (o) { var op = el('option', '', o[1]); op.value = o[0]; uses.appendChild(op); });
|
|
181
|
+
add(box, add(el('div', 'newinv'), label, uses, btn('New invite', 'small', function () {
|
|
182
|
+
post('/_studio/api/invites', { game: g.id, label: label.value, uses: uses.value === 'any' ? 'any' : Number(uses.value) }).then(function (r) {
|
|
183
|
+
if (r && r.ok && r.invites && r.invites[0]) {
|
|
184
|
+
var link = r.invites[0].link; label.value = ''; label.blur();
|
|
185
|
+
(navigator.clipboard ? navigator.clipboard.writeText(link) : Promise.reject()).then(function () { toast('Invite ' + r.invites[0].code + ' made, and its link copied.'); }, function () { toast('Invite ' + r.invites[0].code + ': ' + link); });
|
|
186
|
+
} else toast((r && r.message) || 'No invite made.', true);
|
|
187
|
+
load();
|
|
188
|
+
});
|
|
189
|
+
})));
|
|
190
|
+
return box;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function person(g, r, c, now) {
|
|
194
|
+
var row = el('div', 'person');
|
|
195
|
+
var who = el('div', 'who');
|
|
196
|
+
add(who, el('b', '', c.name || 'Someone'),
|
|
197
|
+
c.seat !== null && c.seat !== undefined ? el('span', 'faint', 'seat ' + (c.seat + 1)) : el('span', 'faint', c.waiting ? 'waiting for a seat' : 'watching'),
|
|
198
|
+
el('span', 'chip', c.device === 'phone' ? 'phone' : c.device === 'tv' ? 'big screen' : 'computer'),
|
|
199
|
+
c.role === 'host' ? el('span', 'chip host', 'host') : null,
|
|
200
|
+
c.as === 'owner' ? el('span', 'chip owner', 'you') : c.as === 'invited' ? el('span', 'chip invited', 'invited') : c.account ? el('span', 'chip owner', 'player ' + (c.account.handle || c.account.id)) : el('span', 'chip', 'guest'),
|
|
201
|
+
c.muted ? el('span', 'chip muted', 'muted') : null,
|
|
202
|
+
el('span', 'faint', 'here ' + dur(now - c.joinedAt)),
|
|
203
|
+
c.browser ? el('span', 'faint', 'browser ' + c.browser) : null);
|
|
204
|
+
var acts = el('div', 'pacts');
|
|
205
|
+
if (c.as !== 'owner') {
|
|
206
|
+
acts.appendChild(c.muted
|
|
207
|
+
? btn('Unmute', 'ghost small', function () { act('/_studio/api/mute', { game: g.id, room: r.room, id: c.id, off: true }, (c.name || 'They') + ' can talk again.'); })
|
|
208
|
+
: btn('Mute', 'warn small', function () { act('/_studio/api/mute', { game: g.id, room: r.room, id: c.id, minutes: hold() }, (c.name || 'They') + ' is muted for ' + hold() + ' min.'); }));
|
|
209
|
+
acts.appendChild(armed('Kick', 'bad', function () { act('/_studio/api/kick', { game: g.id, room: r.room, id: c.id, minutes: hold() }, (c.name || 'They') + ' was removed from ' + r.label + ' for ' + hold() + ' min.'); }));
|
|
210
|
+
}
|
|
211
|
+
return add(row, who, acts);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function room(g, r, now) {
|
|
215
|
+
var box = el('div', 'room');
|
|
216
|
+
var key = g.id + '/' + r.room;
|
|
217
|
+
var head = el('div', 'rhead');
|
|
218
|
+
head.onclick = function (e) { if (e.target.closest('button')) return; S.open[key] = !S.open[key]; render(); };
|
|
219
|
+
var round = r.round ? 'Round ' + (r.round.n || '?') + (r.round.phase === 'live' && r.round.endsAt ? ' · ' + clock(r.round.endsAt - now) + ' left' : r.round.phase === 'over' ? ' · results' : '') : 'no round yet';
|
|
220
|
+
var announce = btn('Announce', 'ghost small', function () {
|
|
221
|
+
var text = prompt('Announce to everyone in ' + r.label + ' of ' + g.name + ':');
|
|
222
|
+
if (text) act('/_studio/api/announce', { game: g.id, room: r.room, text: text }, 'Announced in ' + r.label + '.');
|
|
223
|
+
});
|
|
224
|
+
var acts = add(el('div', 'racts'), announce, r.closedUntil
|
|
225
|
+
? btn('Reopen', 'ghost small', function () { act('/_studio/api/close', { game: g.id, room: r.room, reopen: true }, r.label + ' is open again.'); })
|
|
226
|
+
: armed('Close', 'bad', function () { act('/_studio/api/close', { game: g.id, room: r.room, minutes: hold() }, r.label + ' closed for ' + hold() + ' min.'); }));
|
|
227
|
+
add(head,
|
|
228
|
+
add(el('div'), el('b', '', (S.open[key] ? '▾ ' : '▸ ') + r.label), el('div', 'faint', r.public ? 'public room' : 'named room')),
|
|
229
|
+
add(el('div', 'stat'), el('span', '', 'players'), document.createTextNode((r.players) + ' / ' + r.max)),
|
|
230
|
+
add(el('div', 'stat'), el('span', '', 'bots'), document.createTextNode(String(r.bots))),
|
|
231
|
+
add(el('div', 'stat'), el('span', '', 'round'), document.createTextNode(round)),
|
|
232
|
+
add(el('div', 'stat'), el('span', '', 'up'), document.createTextNode(r.closedUntil ? 'closed ' + dur(r.closedUntil - now) : dur(now - r.openedAt))),
|
|
233
|
+
acts);
|
|
234
|
+
box.appendChild(head);
|
|
235
|
+
if (r.announce && r.announce.text) box.appendChild(el('div', 'held', '📣 "' + r.announce.text + '" (' + dur(r.announce.until - now) + ' left)'));
|
|
236
|
+
if (r.regate) box.appendChild(el('div', 'held', 'After this round (at the latest in ' + dur(r.regate.until - now) + '), players the new launch state leaves out are sent out with a thank-you.'));
|
|
237
|
+
if (S.open[key] !== false && (S.open[key] || r.players + r.screens <= 12)) {
|
|
238
|
+
S.open[key] = true;
|
|
239
|
+
var people = el('div', 'people');
|
|
240
|
+
(r.clients || []).forEach(function (c) { people.appendChild(person(g, r, c, now)); });
|
|
241
|
+
var bots = (r.slots || []).filter(function (s) { return s.bot; });
|
|
242
|
+
if (bots.length) people.appendChild(el('div', 'held', 'Bots: ' + bots.map(function (s) { return s.name; }).join(', ')));
|
|
243
|
+
(r.bans || []).forEach(function (b) { people.appendChild(el('div', 'held', 'Kicked: ' + (b.name || 'someone') + ', may come back in ' + dur(b.until - now) + (b.address ? ' (their network too)' : ''))); });
|
|
244
|
+
box.appendChild(people);
|
|
245
|
+
}
|
|
246
|
+
return box;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function render() {
|
|
250
|
+
var d = S.data;
|
|
251
|
+
if (!d) return;
|
|
252
|
+
var now = Date.now() - (S.skew || 0);
|
|
253
|
+
liveEl.textContent = d.playing ? d.playing + (d.playing === 1 ? ' playing now' : ' playing now') : 'nobody playing right now';
|
|
254
|
+
document.getElementById('livedot').className = 'dot' + (d.playing ? ' on' : '');
|
|
255
|
+
var focus = document.activeElement && root.contains(document.activeElement) && /INPUT|SELECT/.test(document.activeElement.tagName);
|
|
256
|
+
// Never redraw under a typing owner, or a control waiting for its second tap; the next poll will.
|
|
257
|
+
if (focus || root.querySelector('[data-armed="1"]')) return;
|
|
258
|
+
root.textContent = '';
|
|
259
|
+
if (!d.games.length) { root.appendChild(el('p', 'empty', 'This studio has no games yet.')); return; }
|
|
260
|
+
d.games.forEach(function (g) {
|
|
261
|
+
var card = el('section', 'game'); card.id = 'game-' + g.id;
|
|
262
|
+
var head = el('div', 'ghead');
|
|
263
|
+
add(head, add(el('div', 'name'), el('h2', '', g.name), el('span', 'faint', g.playing ? g.playing + ' playing' : 'quiet')),
|
|
264
|
+
add(el('div', 'links'), Object.assign(el('a', '', 'Play'), { href: g.play, target: '_blank', rel: 'noopener' }), Object.assign(el('a', '', 'Page'), { href: g.page, target: '_blank', rel: 'noopener' })));
|
|
265
|
+
add(card, head, gameSettings(g));
|
|
266
|
+
if (g.launch === 'invite' || (g.invites && g.invites.length)) card.appendChild(invites(g));
|
|
267
|
+
var rooms = el('div', 'rooms');
|
|
268
|
+
if (!g.rooms.length) rooms.appendChild(el('div', 'empty', g.launch === 'private' ? 'Private: only you can open it. Nobody is in a room.' : 'Nobody is in a room right now.'));
|
|
269
|
+
g.rooms.forEach(function (r) { rooms.appendChild(room(g, r, now)); });
|
|
270
|
+
card.appendChild(rooms);
|
|
271
|
+
root.appendChild(card);
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function load() {
|
|
276
|
+
if (S.busy) return;
|
|
277
|
+
S.busy = true;
|
|
278
|
+
fetch('/_studio/api/office', { credentials: 'same-origin', cache: 'no-store' }).then(function (r) { return r.json(); }).then(function (d) {
|
|
279
|
+
S.busy = false;
|
|
280
|
+
if (!d || !d.ok) { S.err++; document.getElementById('status').textContent = (d && d.message) || 'The office did not answer.'; return; }
|
|
281
|
+
S.err = 0; document.getElementById('status').textContent = '';
|
|
282
|
+
S.skew = Date.now() - d.now;
|
|
283
|
+
S.data = d; window.__office = d; render();
|
|
284
|
+
}).catch(function () { S.busy = false; S.err++; document.getElementById('status').textContent = 'The studio did not answer; trying again.'; });
|
|
285
|
+
}
|
|
286
|
+
function tick() { if (document.visibilityState !== 'hidden') load(); S.timer = setTimeout(tick, Math.min(30000, 3000 * Math.pow(2, Math.min(S.err, 3)))); }
|
|
287
|
+
document.getElementById('announce-all').onsubmit = function (e) {
|
|
288
|
+
e.preventDefault();
|
|
289
|
+
var input = document.getElementById('announce-text');
|
|
290
|
+
var scope = document.getElementById('announce-scope').value;
|
|
291
|
+
if (!input.value.trim()) return;
|
|
292
|
+
act('/_studio/api/announce', scope ? { game: scope, text: input.value } : { text: input.value }, 'Announced.').then(function () { input.value = ''; });
|
|
293
|
+
};
|
|
294
|
+
document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible') load(); });
|
|
295
|
+
tick();
|
|
296
|
+
}());`;
|
|
297
|
+
|
|
298
|
+
let scriptHash = null;
|
|
299
|
+
async function officeScriptHash() {
|
|
300
|
+
if (!scriptHash) {
|
|
301
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(OFFICE_SCRIPT));
|
|
302
|
+
scriptHash = `sha256-${btoa(String.fromCharCode(...new Uint8Array(digest)))}`;
|
|
303
|
+
}
|
|
304
|
+
return scriptHash;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function shell(cat, title, body, { status = 200, script = null, extra = {} } = {}) {
|
|
308
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
309
|
+
<meta name="robots" content="noindex, nofollow"><title>${esc(title)}</title><style>:root{--accent:${accentOf(cat)}}${CSS}</style></head><body><div class="wrap">${body}</div>${script ? `<script>${script}</script>` : ''}</body></html>`;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** The owner's live office. `headers` may carry the session carried over to / (a session from before 0.13.0). */
|
|
313
|
+
export async function officePage(cat, headers = {}) {
|
|
314
|
+
const name = cat.studio?.name ?? 'Studio';
|
|
315
|
+
const options = (cat.games ?? []).map((g) => `<option value="${esc(g.id)}">every room of ${esc(g.name)}</option>`).join('');
|
|
316
|
+
const body = `<header class="top"><h1>${esc(name)} <small>Office</small></h1>
|
|
317
|
+
<div class="links"><span class="live"><i class="dot" id="livedot"></i><span id="live">…</span></span><a href="/_studio/stats">Stats</a>
|
|
318
|
+
<form method="post" action="/_studio/signout" style="display:inline"><button class="ghost small" type="submit">Sign out</button></form></div></header>
|
|
319
|
+
<form class="bar" id="announce-all"><label for="announce-text">Announce</label><input type="text" id="announce-text" maxlength="280" placeholder="A line every player sees, in the game" autocomplete="off">
|
|
320
|
+
<select id="announce-scope" aria-label="Who sees it"><option value="">every room of every game</option>${options}</select><button type="submit">Send</button>
|
|
321
|
+
<label for="hold" style="margin-left:auto">Kick, mute and close for</label><select id="hold"><option value="5">5 min</option><option value="10" selected>10 min</option><option value="30">30 min</option><option value="60">1 hour</option><option value="1440">a day</option></select></form>
|
|
322
|
+
<p class="dim" id="status" role="status"></p>
|
|
323
|
+
<div id="games" aria-live="polite"><p class="empty">Reading the rooms…</p></div>
|
|
324
|
+
<div class="toast" id="toast" role="status" hidden></div>
|
|
325
|
+
<p class="note">Live from this studio's own rooms, every few seconds. A kick holds that player out of that room for the time you pick (their browser, and their account once players sign in); a mute stops their chat and emotes; Close sends everyone in a room out with a thank-you. Private: only you can open the game. Invite-only: invited players only (each invite link or code lets a browser in). Public: anyone, and listed. A game that is not public leaves the Homie directory the next time it reads this studio. This page is yours alone, never cached or indexed.</p>`;
|
|
326
|
+
return new Response(shell(cat, `Office · ${name}`, body, { script: OFFICE_SCRIPT }), {
|
|
327
|
+
headers: {
|
|
328
|
+
...PRIVATE, ...headers,
|
|
329
|
+
'content-security-policy': `default-src 'none'; style-src 'unsafe-inline'; img-src data:; script-src '${await officeScriptHash()}'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'`,
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** What anyone but the owner gets at /_studio/office or a confirm link. */
|
|
335
|
+
export function lockedPage(cat, { what = 'office', ask = null } = {}) {
|
|
336
|
+
const name = cat.studio?.name ?? 'Studio';
|
|
337
|
+
const link = what === 'confirm' && ask ? `npx --no-install homie-studio office link --to /_studio/confirm/${ask}` : 'npx --no-install homie-studio office link';
|
|
338
|
+
const body = `<h1>${esc(name)} <small>${what === 'confirm' ? 'Confirm' : 'Office'}</small></h1>
|
|
339
|
+
<p class="dim">${what === 'confirm' ? 'Your AI asked for something only the studio\'s owner can say yes to.' : 'The office is for the studio\'s owner only.'}</p>
|
|
340
|
+
<p class="note">Sign this browser in first: ask your AI, in the studio's folder, to run <code>${esc(link)}</code>. It uses the studio's own Cloudflare login and gives a one-time link for this browser${what === 'confirm' ? ' that comes straight back here' : ''}.</p>`;
|
|
341
|
+
return new Response(shell(cat, `${what === 'confirm' ? 'Confirm' : 'Office'} · private`, body), { status: 401, headers: { ...PRIVATE, 'content-security-policy': NO_SCRIPT_CSP } });
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const VERB = { kick: 'Kick', mute: 'Mute', close: 'Close the room', announce: 'Announce', game: 'Change it' };
|
|
345
|
+
|
|
346
|
+
/** The owner's one tap for what their AI asked, and what came of it. */
|
|
347
|
+
export function confirmPage(cat, ask, { missing = false } = {}) {
|
|
348
|
+
const name = cat.studio?.name ?? 'Studio';
|
|
349
|
+
let body;
|
|
350
|
+
if (missing || !ask) {
|
|
351
|
+
body = `<h1>Nothing to confirm</h1><p class="dim">That ask is not here: asks last 15 minutes. Your AI can ask again.</p><p><a href="/_studio/office">Open the office</a></p>`;
|
|
352
|
+
} else if (ask.state === 'pending') {
|
|
353
|
+
const verb = ask.action?.op === 'mute' && ask.action.off ? 'Unmute' : ask.action?.op === 'close' && ask.action.reopen ? 'Open it again' : VERB[ask.action?.op] ?? 'Yes';
|
|
354
|
+
body = `<h1>${esc(name)} <small>Your AI asks</small></h1>
|
|
355
|
+
<p style="font-size:19px;line-height:1.45;max-width:46ch">${esc(ask.what)}</p>
|
|
356
|
+
<form method="post" class="bar" style="background:transparent;border:0;padding:0"><button type="submit" name="do" value="yes" class="${['kick', 'close'].includes(ask.action?.op) && !ask.action?.reopen ? 'armed' : ''}">${esc(verb)}</button><button type="submit" name="do" value="no" class="ghost">No</button></form>
|
|
357
|
+
<p class="note">Only you can confirm this: your AI asked through the studio's office key, which cannot say yes. It lasts until ${esc(new Date(ask.expiresAt).toISOString().slice(11, 16))} UTC.</p>`;
|
|
358
|
+
} else {
|
|
359
|
+
const said = { done: 'Done.', failed: 'That did not work.', cancelled: 'Not done: you said no.', expired: 'This ask ran out (15 minutes). Your AI can ask again.', working: 'Working on it…' }[ask.state] ?? ask.state;
|
|
360
|
+
const result = ask.result && !ask.result.ok && ask.result.message ? ` ${ask.result.message}` : '';
|
|
361
|
+
body = `<h1>${esc(said)}</h1><p class="dim">${esc(ask.what)}${esc(result)}</p><p><a href="/_studio/office">Open the office</a></p>`;
|
|
362
|
+
}
|
|
363
|
+
// One panel, thumb-sized buttons: this page is often opened on the owner's phone from a chat.
|
|
364
|
+
const panel = `<div class="game" style="max-width:560px;margin:8vh auto 0;padding:22px 20px">${body.replace(/<button /g, '<button style="min-height:48px;padding:0 24px;font-size:16px" ')}</div>`;
|
|
365
|
+
return new Response(shell(cat, `Confirm · ${name}`, panel), { status: missing ? 404 : 200, headers: { ...PRIVATE, 'content-security-policy': NO_SCRIPT_CSP } });
|
|
366
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** The back office's D1 tables (worker/office.mjs), as the template's migration 0003 and `homie-studio upgrade` write them. */
|
|
2
|
+
export const OFFICE_MIGRATION_FILE = '0005_studio_office.sql';
|
|
3
|
+
export const OFFICE_MIGRATION = `-- The studio's back office (@homie-rocks/studio 0.13.0): each game's launch state, remix switch and room size,
|
|
4
|
+
-- its invites and the passes they gave, and the controls the owner's AI asked the owner to confirm.
|
|
5
|
+
CREATE TABLE IF NOT EXISTS office_games (
|
|
6
|
+
game TEXT PRIMARY KEY,
|
|
7
|
+
launch TEXT,
|
|
8
|
+
remix INTEGER,
|
|
9
|
+
max_players INTEGER,
|
|
10
|
+
updated_at INTEGER NOT NULL
|
|
11
|
+
) WITHOUT ROWID;
|
|
12
|
+
CREATE TABLE IF NOT EXISTS office_invites (
|
|
13
|
+
id TEXT PRIMARY KEY,
|
|
14
|
+
game TEXT NOT NULL,
|
|
15
|
+
code TEXT NOT NULL UNIQUE,
|
|
16
|
+
label TEXT,
|
|
17
|
+
uses INTEGER NOT NULL DEFAULT 0,
|
|
18
|
+
max_uses INTEGER,
|
|
19
|
+
expires_at INTEGER,
|
|
20
|
+
revoked INTEGER NOT NULL DEFAULT 0,
|
|
21
|
+
created_at INTEGER NOT NULL
|
|
22
|
+
);
|
|
23
|
+
CREATE INDEX IF NOT EXISTS office_invites_game ON office_invites (game);
|
|
24
|
+
-- A pass is one browser's way into an invite-only game: only the SHA-256 of its cookie.
|
|
25
|
+
CREATE TABLE IF NOT EXISTS office_passes (
|
|
26
|
+
hash TEXT PRIMARY KEY,
|
|
27
|
+
game TEXT NOT NULL,
|
|
28
|
+
invite TEXT NOT NULL,
|
|
29
|
+
expires_at INTEGER NOT NULL
|
|
30
|
+
) WITHOUT ROWID;
|
|
31
|
+
CREATE TABLE IF NOT EXISTS office_asks (
|
|
32
|
+
id TEXT PRIMARY KEY,
|
|
33
|
+
action TEXT NOT NULL,
|
|
34
|
+
state TEXT NOT NULL,
|
|
35
|
+
result TEXT,
|
|
36
|
+
created_at INTEGER NOT NULL,
|
|
37
|
+
expires_at INTEGER NOT NULL
|
|
38
|
+
) WITHOUT ROWID;
|
|
39
|
+
`;
|