@homie-rocks/studio 0.15.0 → 0.16.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 +34 -4
- package/bin/homie-studio.mjs +59 -3
- package/lib/build.mjs +45 -0
- package/lib/office.mjs +7 -6
- package/lib/scaffold.mjs +41 -2
- package/lib/servers.mjs +133 -0
- package/lib/template-history.json +28 -0
- package/netplay/NETPLAY.md +207 -17
- package/netplay/netplay.ts +360 -30
- package/package.json +1 -1
- package/port/bots.ts +25 -2
- package/port/index.ts +5 -2
- package/port/room.ts +33 -10
- package/port/skill.ts +28 -0
- package/site/SITE.md +43 -0
- package/starters/ember-vale/src/main.ts +21 -7
- package/starters/gem-rush/src/main.ts +84 -19
- package/worker/account-page.mjs +26 -0
- package/worker/agents.mjs +238 -0
- package/worker/index.mjs +307 -74
- package/worker/office-page.mjs +178 -10
- package/worker/office.mjs +294 -25
- package/worker/pages.mjs +206 -15
- package/worker/players.mjs +11 -3
- package/worker/room.mjs +491 -44
- package/worker/saves.mjs +4 -0
- package/worker/servers.mjs +445 -0
- package/worker/site.mjs +138 -7
- package/worker/stats.mjs +6 -1
- package/worker/version.mjs +1 -1
package/worker/pages.mjs
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { esc, layout } from './site.mjs';
|
|
6
6
|
import { NET_PALETTE } from './room.mjs';
|
|
7
|
+
import { SKILLS } from './agents.mjs';
|
|
8
|
+
import { KIDS_LINE, POLICY_WORDS } from './servers.mjs';
|
|
7
9
|
import { SAVES_SHELL_CSS, SAVES_SHELL_JS } from './saves-shell.mjs';
|
|
8
10
|
|
|
9
11
|
export { homePage, mediaIndexPage, notFoundPage, songPage, videoPage } from './site.mjs';
|
|
@@ -82,7 +84,7 @@ export function sharePlaces(value) {
|
|
|
82
84
|
* the edge (game.json `screen.share`, per device: sharePlaces) opens Invite, Big screen and the room code; nothing
|
|
83
85
|
* covers the middle of the screen or a thumb.
|
|
84
86
|
*/
|
|
85
|
-
export function playPage(cat, g, { screen = false, joinUrl = null, qr = null, room = null, ticket = null, owner = false, launch = 'public' } = {}) {
|
|
87
|
+
export function playPage(cat, g, { screen = false, joinUrl = null, qr = null, room = null, ticket = null, owner = false, launch = 'public', server = null } = {}) {
|
|
86
88
|
const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
|
|
87
89
|
const corner = (name, fallback) => (['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(g.screen?.[name]) ? g.screen[name] : fallback);
|
|
88
90
|
const places = sharePlaces(g.screen?.share);
|
|
@@ -148,16 +150,28 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
|
|
|
148
150
|
.notice .acts { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 16px; }
|
|
149
151
|
.notice .acts a { display: inline-flex; align-items: center; min-height: 44px; padding: 0 16px; border-radius: 12px; text-decoration: none; font-weight: 700; color: #eef1f8; border: 1px solid rgba(255,255,255,.18); }
|
|
150
152
|
.notice .acts a.primary { background: var(--hot); color: #0b0b10; border-color: transparent; }
|
|
151
|
-
[hidden] { display: none !important; }
|
|
153
|
+
[hidden] { display: none !important; }
|
|
154
|
+
${SERVER_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}`;
|
|
152
155
|
// The room button's place on each device (sharePlaces); the shell moves it to this browser's once it knows the device.
|
|
153
156
|
// A ticket (a game that is not public) and the owner's overlay ride along only for the browser they are for.
|
|
154
|
-
|
|
157
|
+
// The server this page plays on (0.16.0): its pool (the Lobby), its badge and line for the chip, its ceiling.
|
|
158
|
+
const srv = server ? { id: server.id, name: server.name, badge: server.badge, line: server.line, policy: server.policy, kids: Boolean(server.kids), mentor: Boolean(server.mentor), levelMax: server.levelMax } : null;
|
|
159
|
+
// game.json "agents": { "vote": "game" } draws its own vote card; false turns the vote off (section 17).
|
|
160
|
+
const vote = g.agents?.vote === 'game' || g.agents?.vote === false ? g.agents.vote : 'shell';
|
|
161
|
+
const boot = { game: g.id, name: g.name, screen: Boolean(screen), share: places, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}), ...(owner ? { owner: true, launch } : {}), ...(srv ? { server: srv } : {}), vote, skills: SKILLS.map((k) => ({ level: k.level, name: k.name, card: k.card })), words: POLICY_WORDS, kidsLine: KIDS_LINE };
|
|
155
162
|
// game.json "screen": { "join": "top-left" | "top-right" | "bottom-left" | "bottom-right" } keeps the card off the game's own HUD.
|
|
156
163
|
const joinCorner = corner('join', 'bottom-right');
|
|
157
164
|
const first = places.desk;
|
|
158
165
|
const joinCard = screen && joinUrl ? `<div class="join join-${joinCorner}" data-join>${qr ? `<div class="qr">${qr}</div>` : ''}<div><b>Scan to play</b><span>${esc(joinUrl.replace(/^https?:\/\//, ''))}</span></div></div>` : '';
|
|
159
166
|
const share = screen ? '' : `<div class="room at-${first.at}" style="--dx:${first.x}px;--dy:${first.y}px" data-room-ui>
|
|
160
167
|
<button class="pill${first.label ? '' : ' icon'}" type="button" data-share-toggle aria-expanded="false" aria-controls="share-sheet" aria-label="Room, invite and big screen"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5.5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="18.5" r="2.5"/><path d="m8.2 10.8 7.6-4.1M8.2 13.2l7.6 4.1"/></svg><span data-room-code>Room</span></button>
|
|
168
|
+
<button class="pill spill" type="button" data-server-toggle aria-expanded="false" aria-controls="server-sheet" hidden><span class="sdot" aria-hidden="true"></span><span data-server-label></span></button>
|
|
169
|
+
<div class="sheet ssheet" id="server-sheet" role="dialog" aria-label="This server" data-server-sheet hidden>
|
|
170
|
+
<div class="code"><b data-server-name></b><span data-server-badge></span></div>
|
|
171
|
+
<p class="sline" data-server-line></p>
|
|
172
|
+
<div class="srow" data-level-row hidden><span>AI level: <b data-level-name></b></span><button type="button" data-level-vote>Change ▾</button></div>
|
|
173
|
+
<label class="srow quiet-ai"><span>Quiet AI <small>hides AI chat on this screen</small></span><input type="checkbox" data-quiet-ai></label>
|
|
174
|
+
</div>
|
|
161
175
|
<div class="sheet" id="share-sheet" role="dialog" aria-label="This room" data-share-sheet hidden>
|
|
162
176
|
<div class="code"><b data-room-label>This room</b><span data-room-count></span></div>
|
|
163
177
|
<div class="acts">
|
|
@@ -173,6 +187,7 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
|
|
|
173
187
|
return layoutless(`${g.name} · play`, `
|
|
174
188
|
<iframe class="game" title="${esc(g.name)}" sandbox="allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups" allow="fullscreen *; autoplay *; gamepad *"></iframe>
|
|
175
189
|
<div class="chip" data-chip><span data-status>finding a room…</span></div>
|
|
190
|
+
<div class="vote" data-vote role="dialog" aria-label="How strong should the AI be?" data-keep-focus hidden></div>
|
|
176
191
|
<div class="card" data-results hidden></div>
|
|
177
192
|
<div class="card" data-screen hidden></div>
|
|
178
193
|
${joinCard}${share}
|
|
@@ -181,6 +196,42 @@ ${joinCard}${share}
|
|
|
181
196
|
<script>${SAVES_SHELL_JS}</script>` : ''}${owner ? `<style>${OWNER_CSS}</style><script>${OWNER_JS}</script>` : ''}`, css, frameAncestors(cat));
|
|
182
197
|
}
|
|
183
198
|
|
|
199
|
+
/*
|
|
200
|
+
* SERVERS AND THE AI DIAL (0.16.0, NETPLAY.md section 17): the server pill beside the room button (its name and
|
|
201
|
+
* badge; its sheet has the policy's line, the AI's level with "Change", and Quiet AI), and the party's vote card on
|
|
202
|
+
* how strong the AI should be (a bottom sheet on a phone; keys 1-5 on a computer). Nothing covers the middle of the
|
|
203
|
+
* screen except the card, for a few seconds, when a vote is open.
|
|
204
|
+
*/
|
|
205
|
+
const SERVER_CSS = `.spill .sdot { width: 8px; height: 8px; border-radius: 50%; background: var(--hot); flex: none; }
|
|
206
|
+
.spill.dim .sdot { display: block; }
|
|
207
|
+
.ssheet .sline { margin: 0 2px 10px; color: #c3cad9; font-weight: 500; line-height: 1.4; }
|
|
208
|
+
.ssheet .srow { display: flex; justify-content: space-between; align-items: center; gap: 10px; min-height: 40px; margin: 0 2px; border-top: 1px solid rgba(255,255,255,.08); font-weight: 600; }
|
|
209
|
+
.ssheet .srow small { display: block; color: #8b93a7; font-weight: 500; font-size: 11px; }
|
|
210
|
+
.ssheet .srow button { min-height: 34px; padding: 0 12px; border-radius: 10px; border: 1px solid rgba(255,255,255,.18); background: transparent; color: inherit; font: inherit; cursor: pointer; }
|
|
211
|
+
.ssheet .srow input { width: 20px; height: 20px; accent-color: var(--hot); }
|
|
212
|
+
.aipill { display: inline-block; margin-left: 6px; padding: 0 6px; border-radius: 999px; background: rgba(255,207,90,.2); color: #ffe7a8; font: 800 10px/1.6 ui-monospace, Menlo, monospace; letter-spacing: .06em; vertical-align: 1px; }
|
|
213
|
+
.vote { box-sizing: border-box; position: fixed; left: 50%; top: 50%; z-index: 12; transform: translate(-50%, -50%); width: min(560px, calc(100vw - 24px)); padding: 16px 16px 14px; border-radius: 18px; background: rgba(8,12,22,.95); border: 1px solid var(--hot); color: #eef1f8; font: 600 14px/1.35 ui-sans-serif, system-ui, -apple-system, sans-serif; box-shadow: 0 24px 70px rgba(0,0,0,.55); touch-action: manipulation; -webkit-user-select: none; user-select: none; animation: rise .25s ease-out; }
|
|
214
|
+
@keyframes rise { from { opacity: 0; transform: translate(-50%, -46%); } }
|
|
215
|
+
.vote .vh { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; margin: 0 2px 12px; }
|
|
216
|
+
.vote .vh b { font-size: 18px; letter-spacing: -.01em; }
|
|
217
|
+
.vote .vh span { color: #aab3c7; font-weight: 600; font-size: 12px; white-space: nowrap; }
|
|
218
|
+
.vote .opts { display: grid; grid-template-columns: repeat(var(--n, 5), 1fr); gap: 8px; }
|
|
219
|
+
.vote .opts button { min-height: 58px; padding: 6px 4px; border-radius: 13px; border: 1px solid rgba(255,255,255,.18); background: rgba(255,255,255,.04); color: inherit; font: inherit; cursor: pointer; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; }
|
|
220
|
+
.vote .opts button b { font-size: 16px; }
|
|
221
|
+
.vote .opts button small { color: #9aa3b7; font-size: 11px; font-weight: 600; }
|
|
222
|
+
.vote .opts button[aria-pressed="true"] { background: var(--hot); color: #0b0b10; border-color: transparent; }
|
|
223
|
+
.vote .opts button[aria-pressed="true"] small { color: #3b3420; }
|
|
224
|
+
.vote .legend { display: flex; justify-content: space-between; gap: 10px; margin: 10px 4px 0; color: #8b93a7; font-size: 11px; font-weight: 600; }
|
|
225
|
+
.vote .legend i { flex: 1; border-bottom: 2px dotted rgba(255,255,255,.18); margin-bottom: 4px; }
|
|
226
|
+
.vote .foot { margin: 10px 2px 0; text-align: center; color: #c3cad9; font-weight: 600; }
|
|
227
|
+
@media (max-width: 540px) {
|
|
228
|
+
.vote { top: auto; bottom: 0; left: 0; right: 0; width: auto; transform: none; border-radius: 20px 20px 0 0; padding-bottom: max(16px, env(safe-area-inset-bottom)); animation: up .25s ease-out; }
|
|
229
|
+
@keyframes up { from { opacity: 0; transform: translateY(20px); } }
|
|
230
|
+
.vote .opts { grid-template-columns: repeat(3, 1fr); }
|
|
231
|
+
.vote .opts button { min-height: 64px; }
|
|
232
|
+
.vote .legend { display: none; }
|
|
233
|
+
}`;
|
|
234
|
+
|
|
184
235
|
/** The play page's own document (no site chrome: the game owns the whole screen). */
|
|
185
236
|
function layoutless(title, body, css, ancestors = "'self'") {
|
|
186
237
|
return new Response(`<!doctype html>
|
|
@@ -212,7 +263,7 @@ const SHELL_JS = String.raw`(function () {
|
|
|
212
263
|
var statusEl = document.querySelector('[data-status]');
|
|
213
264
|
var results = document.querySelector('[data-results]');
|
|
214
265
|
var screenCard = document.querySelector('[data-screen]');
|
|
215
|
-
var state = { game: boot.game, room: null, device: device, want: want, attached: false, stats: null, round: null, roster: null, facts: null, seat: null, results: [], closed: null, link: null, notice: null, banner: null, muted: null };
|
|
266
|
+
var state = { game: boot.game, room: null, device: device, want: want, attached: false, stats: null, round: null, roster: null, facts: null, seat: null, results: [], closed: null, link: null, notice: null, banner: null, muted: null, server: boot.server || null, vote: null, myVote: {}, quietAi: false, full: false };
|
|
216
267
|
window.__shell = state;
|
|
217
268
|
// This browser's room key: random, kept in this site's own storage, and sent only to this site's rooms. It is what
|
|
218
269
|
// an owner's kick holds out of a room for a while (section 15); it says nothing about who the player is.
|
|
@@ -259,7 +310,18 @@ const SHELL_JS = String.raw`(function () {
|
|
|
259
310
|
addEventListener('resize', place);
|
|
260
311
|
function labelOf(room) { var m = /^pub-(\d+)$/.exec(room); return m ? 'Room ' + m[1] : room; }
|
|
261
312
|
function flash(text) { if (!toast) return; toast.textContent = text; toast.hidden = false; clearTimeout(flash.t); flash.t = setTimeout(function () { toast.hidden = true; }, 1800); }
|
|
262
|
-
|
|
313
|
+
// The room pill and the server pill fade to dots after 6 s, so neither sits on the game's own HUD.
|
|
314
|
+
function wake() {
|
|
315
|
+
if (!toggle) return;
|
|
316
|
+
var sp = document.querySelector('[data-server-toggle]');
|
|
317
|
+
toggle.classList.remove('dim'); if (sp) sp.classList.remove('dim');
|
|
318
|
+
clearTimeout(dimTimer);
|
|
319
|
+
dimTimer = setTimeout(function () {
|
|
320
|
+
if (sheet.hidden) toggle.classList.add('dim');
|
|
321
|
+
var ss = document.querySelector('[data-server-sheet]');
|
|
322
|
+
if (sp && (!ss || ss.hidden)) sp.classList.add('dim');
|
|
323
|
+
}, 6000);
|
|
324
|
+
}
|
|
263
325
|
function open(on) { if (!sheet) return; sheet.hidden = !on; toggle.setAttribute('aria-expanded', on ? 'true' : 'false'); if (on) wake(); else { wake(); try { frame.focus(); } catch (e) {} } }
|
|
264
326
|
function copy(text, done) {
|
|
265
327
|
var ok = function () { flash(done || 'Link copied'); };
|
|
@@ -309,6 +371,8 @@ const SHELL_JS = String.raw`(function () {
|
|
|
309
371
|
try { var who = JSON.parse(localStorage.getItem('homie.player') || 'null'); if (who && typeof who.name === 'string' && who.name) q.set('name', who.name.slice(0, 24)); } catch (e) {}
|
|
310
372
|
}
|
|
311
373
|
if (params.get('debug') === '1') q.set('debug', '1');
|
|
374
|
+
// Quiet AI (this browser hides AI speech): the helper starts hushed, and hears a change by message.
|
|
375
|
+
if (state.quietAi) q.set('hush', '1');
|
|
312
376
|
// The frame cannot read this page's address (it is an opaque origin): hand it the game's own switches.
|
|
313
377
|
['touchdebug', 'cam', 'view'].forEach(function (k) { var v = params.get(k); if (v && /^[A-Za-z0-9_-]{1,16}$/.test(v)) q.set(k, v); });
|
|
314
378
|
frame.src = '/' + boot.game + '/__game/?' + q.toString();
|
|
@@ -323,7 +387,7 @@ const SHELL_JS = String.raw`(function () {
|
|
|
323
387
|
if (m.what === 'stats') state.stats = m.stats;
|
|
324
388
|
if (m.what === 'round') onRound(m.round);
|
|
325
389
|
if (m.what === 'roster') state.roster = m.slots;
|
|
326
|
-
if (m.what === 'closed') { state.closed = m.why; if (m.why === 'kicked' || m.why === 'room-closed') notice(m.why === 'kicked' ? 'kicked' : 'closed', m); }
|
|
390
|
+
if (m.what === 'closed') { state.closed = m.why; if (m.why === 'kicked' || m.why === 'room-closed' || m.why === 'agents-off') notice(m.why === 'kicked' ? 'kicked' : 'closed', m); }
|
|
327
391
|
// A game's net.pickPlayer(seat): only an owner's page listens (its overlay opens that player's card).
|
|
328
392
|
if (m.what === 'pick' && (m.seat === null || typeof m.seat === 'number')) { try { window.dispatchEvent(new CustomEvent('homie-pick', { detail: { seat: m.seat } })); } catch (e) {} }
|
|
329
393
|
paint();
|
|
@@ -349,7 +413,9 @@ const SHELL_JS = String.raw`(function () {
|
|
|
349
413
|
var ol = document.createElement('ol');
|
|
350
414
|
r.results.slice(0, 6).forEach(function (row) {
|
|
351
415
|
var li = document.createElement('li');
|
|
352
|
-
li.textContent = row.name
|
|
416
|
+
li.textContent = String(row.name) + ' — ' + row.score;
|
|
417
|
+
// Every AI row (an agent, or a bot) carries the AI pill.
|
|
418
|
+
if (row.agent || row.bot) { var pill = document.createElement('span'); pill.className = 'aipill'; pill.textContent = 'AI'; li.append(pill); }
|
|
353
419
|
ol.append(li);
|
|
354
420
|
});
|
|
355
421
|
results.append(h, ol); results.hidden = params.get('debug') !== '1'; // the game draws its own results; the card is for ?debug=1
|
|
@@ -370,10 +436,13 @@ const SHELL_JS = String.raw`(function () {
|
|
|
370
436
|
if (m.t === 'closed') { notice('closed', m); return; }
|
|
371
437
|
if (m.t === 'announce') { banner(m); return; }
|
|
372
438
|
if (m.t === 'muted') { mutedNote(m); return; }
|
|
439
|
+
if (m.t === 'vote') { onVote(m); return; }
|
|
373
440
|
if (m.t !== 'net') return;
|
|
374
441
|
state.facts = m;
|
|
442
|
+
if (typeof m.st === 'number') state.offset = m.st - Date.now();
|
|
375
443
|
if (m.announce && m.announce.text) banner(m.announce, true);
|
|
376
444
|
if (state.facts && state.facts.round) onRound(state.facts.round);
|
|
445
|
+
if (m.vote) onVote(m.vote, true); else if (state.vote && state.vote.open) onVote({ open: false, id: state.vote.id }, true);
|
|
377
446
|
paint();
|
|
378
447
|
};
|
|
379
448
|
ws.onclose = function () { if (!state.notice) setTimeout(function () { watch(room); }, 1500); };
|
|
@@ -438,24 +507,141 @@ const SHELL_JS = String.raw`(function () {
|
|
|
438
507
|
var f = state.facts;
|
|
439
508
|
var n = f && f.counts ? f.counts.players : null;
|
|
440
509
|
var bits = [];
|
|
441
|
-
if (
|
|
442
|
-
if (
|
|
510
|
+
if (state.server) bits.push(state.server.name);
|
|
511
|
+
if (n !== null) bits.push(n + ' playing');
|
|
512
|
+
// Every AI is counted as AI (agents, the seats kept for them, and the game's bots), never as a player.
|
|
513
|
+
var aiN = f && f.counts ? (Number(f.counts.ai) || 0) + (Number(f.counts.bots) || 0) : 0;
|
|
514
|
+
if (aiN) bits.push(aiN + ' AI');
|
|
443
515
|
if (f && f.counts && f.counts.watchers) bits.push(f.counts.watchers + ' watching');
|
|
516
|
+
if (state.full) bits.push('waiting for a seat');
|
|
444
517
|
if (state.closed) bits.push(state.closed === 'replaced' ? 'opened in another tab' : 'reconnecting');
|
|
445
518
|
say(bits.join(' · ') || 'joining…');
|
|
446
519
|
var count = ui && ui.querySelector('[data-room-count]');
|
|
447
|
-
if (count && n !== null) count.textContent = n + (n === 1 ? ' player here' : ' players here');
|
|
520
|
+
if (count && n !== null) count.textContent = n + (n === 1 ? ' player here' : ' players here') + (aiN ? ' · ' + aiN + ' AI' : '');
|
|
521
|
+
serverUi();
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/* ---------------------------------------------------------- the server pill, Quiet AI and the vote (section 17) */
|
|
525
|
+
var SKILL = boot.skills || [];
|
|
526
|
+
var sToggle = document.querySelector('[data-server-toggle]');
|
|
527
|
+
var sSheet = document.querySelector('[data-server-sheet]');
|
|
528
|
+
var voteCard = document.querySelector('[data-vote]');
|
|
529
|
+
try { state.quietAi = localStorage.getItem('homie-quiet-ai') === '1'; } catch (e) { state.quietAi = false; }
|
|
530
|
+
function skillName(n) { var s = SKILL[n - 1]; return s ? s.name : 'level ' + n; }
|
|
531
|
+
function hasAi() { var f = state.facts; return Boolean(f && f.counts && (f.counts.ai || f.counts.bots || f.counts.agents)); }
|
|
532
|
+
function readsDial() { var f = state.facts; return Boolean(f && Array.isArray(f.caps) && f.caps.indexOf('skill') >= 0); }
|
|
533
|
+
// The room's live policy says the badge and line (the owner's change reaches an open page at once); the
|
|
534
|
+
// server's words from the page load are the fallback.
|
|
535
|
+
function policyText(pol) {
|
|
536
|
+
var w = pol && boot.words ? boot.words[pol.kind] : null;
|
|
537
|
+
if (!w) return state.server ? { badge: state.server.badge, line: state.server.line } : { badge: 'Open', line: 'Open: anyone can play. AI players are always marked AI.' };
|
|
538
|
+
var n = pol.kind === 'hybrid' ? (Number(pol.aiSeats) || 0) : 0;
|
|
539
|
+
return { badge: pol.kind === 'hybrid' ? w.badge + ' · ' + n : w.badge, line: w.line.replace('{n}', String(n)) + (pol.kids && boot.kidsLine ? ' ' + boot.kidsLine : '') };
|
|
448
540
|
}
|
|
541
|
+
function serverUi() {
|
|
542
|
+
if (!sToggle || !sSheet) return;
|
|
543
|
+
var f = state.facts; var pol = f && f.policy ? f.policy : null;
|
|
544
|
+
var show = Boolean(state.server) || (hasAi() && readsDial());
|
|
545
|
+
sToggle.hidden = !show;
|
|
546
|
+
if (!show) return;
|
|
547
|
+
var name = state.server ? state.server.name : 'Quick play';
|
|
548
|
+
var words = policyText(pol);
|
|
549
|
+
var label = name + ' · ' + words.badge + (state.server && state.server.mentor ? ' · Mentor' : '');
|
|
550
|
+
var labelEl = sToggle.querySelector('[data-server-label]');
|
|
551
|
+
// A new policy (the owner's change) shows in full for a moment, then fades with the room pill.
|
|
552
|
+
if (labelEl.textContent !== label) { labelEl.textContent = label; wake(); }
|
|
553
|
+
sSheet.querySelector('[data-server-name]').textContent = name;
|
|
554
|
+
sSheet.querySelector('[data-server-badge]').textContent = words.badge;
|
|
555
|
+
sSheet.querySelector('[data-server-line]').textContent = words.line;
|
|
556
|
+
var row = sSheet.querySelector('[data-level-row]');
|
|
557
|
+
row.hidden = !(readsDial() && hasAi() && boot.vote !== false);
|
|
558
|
+
if (pol && pol.skill) sSheet.querySelector('[data-level-name]').textContent = pol.skill.name;
|
|
559
|
+
sSheet.querySelector('[data-quiet-ai]').checked = state.quietAi;
|
|
560
|
+
}
|
|
561
|
+
if (sToggle) {
|
|
562
|
+
sToggle.addEventListener('click', function (e) { e.stopPropagation(); var on = sSheet.hidden; sSheet.hidden = !on; sToggle.setAttribute('aria-expanded', on ? 'true' : 'false'); if (on && sheet) open(false); wake(); });
|
|
563
|
+
document.addEventListener('pointerdown', function (e) { if (!sSheet.hidden && !sSheet.contains(e.target) && e.target !== sToggle && !sToggle.contains(e.target)) { sSheet.hidden = true; sToggle.setAttribute('aria-expanded', 'false'); } }, true);
|
|
564
|
+
sSheet.querySelector('[data-quiet-ai]').addEventListener('change', function (e) {
|
|
565
|
+
state.quietAi = e.target.checked;
|
|
566
|
+
try { localStorage.setItem('homie-quiet-ai', state.quietAi ? '1' : '0'); } catch (e2) {}
|
|
567
|
+
try { frame.contentWindow.postMessage({ t: 'homie-hush', on: state.quietAi }, '*'); } catch (e3) {}
|
|
568
|
+
flash(state.quietAi ? 'AI chat is hidden on this screen.' : 'AI chat shows again.');
|
|
569
|
+
});
|
|
570
|
+
sSheet.querySelector('[data-level-vote]').addEventListener('click', function () { sendVote({ open: true }); sSheet.hidden = true; sToggle.setAttribute('aria-expanded', 'false'); });
|
|
571
|
+
}
|
|
572
|
+
function sendVote(m) {
|
|
573
|
+
var ws = state.watchSocket;
|
|
574
|
+
if (!ws || ws.readyState !== 1) { flash('Not connected yet; try again in a moment.'); return; }
|
|
575
|
+
try { ws.send(JSON.stringify(Object.assign({ t: 'vote', of: 'skill' }, m))); } catch (e) {}
|
|
576
|
+
}
|
|
577
|
+
function cast(n) { if (!state.vote || !state.vote.open) return; state.myVote[state.vote.id] = n; sendVote({ n: n }); renderVote(); }
|
|
578
|
+
var shownResults = {};
|
|
579
|
+
function onVote(v, quiet) {
|
|
580
|
+
if (!v || typeof v !== 'object') return;
|
|
581
|
+
if (v.t === 'error' || v.code) return;
|
|
582
|
+
var prev = state.vote;
|
|
583
|
+
state.vote = v.options ? v : (prev && prev.id === v.id ? Object.assign({}, prev, { open: false }) : prev);
|
|
584
|
+
if (v.result && !v.open && !shownResults[v.id] && !quiet) {
|
|
585
|
+
shownResults[v.id] = true;
|
|
586
|
+
flash('The party set the AI to ' + v.result.name + ' (' + v.result.votes + (v.result.votes === 1 ? ' vote' : ' votes') + ').');
|
|
587
|
+
} else if (v.result && !v.open) shownResults[v.id] = true;
|
|
588
|
+
renderVote();
|
|
589
|
+
}
|
|
590
|
+
var voteTimer = null;
|
|
591
|
+
function renderVote() {
|
|
592
|
+
var v = state.vote;
|
|
593
|
+
if (!voteCard) return;
|
|
594
|
+
// A game that draws its own card (game.json "agents": { "vote": "game" }), or no vote: the shell shows none.
|
|
595
|
+
if (!v || !v.open || boot.vote !== 'shell' || boot.screen || state.seat === null || state.seat === undefined) { voteCard.hidden = true; clearInterval(voteTimer); voteTimer = null; return; }
|
|
596
|
+
var left = Math.max(0, Math.ceil((v.until - (Date.now() + (state.offset || 0))) / 1000));
|
|
597
|
+
var mine = state.myVote[v.id];
|
|
598
|
+
voteCard.textContent = '';
|
|
599
|
+
var head = document.createElement('div'); head.className = 'vh';
|
|
600
|
+
var t = document.createElement('b'); t.textContent = 'How strong should the AI be?';
|
|
601
|
+
var sub = document.createElement('span'); sub.textContent = 'for this party · ' + left + ' s';
|
|
602
|
+
head.append(t, sub);
|
|
603
|
+
var opts = document.createElement('div'); opts.className = 'opts'; opts.style.setProperty('--n', String(v.options.length));
|
|
604
|
+
v.options.forEach(function (n) {
|
|
605
|
+
var b = document.createElement('button'); b.type = 'button'; b.setAttribute('data-vote-n', String(n));
|
|
606
|
+
b.setAttribute('aria-pressed', mine === n ? 'true' : 'false');
|
|
607
|
+
var big = document.createElement('b'); big.textContent = n + ' ' + skillName(n);
|
|
608
|
+
var small = document.createElement('small'); small.textContent = (v.counts && v.counts[n] ? v.counts[n] + (v.counts[n] === 1 ? ' vote' : ' votes') : '\u00a0');
|
|
609
|
+
b.append(big, small);
|
|
610
|
+
b.addEventListener('click', function () { cast(n); });
|
|
611
|
+
opts.appendChild(b);
|
|
612
|
+
});
|
|
613
|
+
var legend = document.createElement('div'); legend.className = 'legend';
|
|
614
|
+
var l1 = document.createElement('span'); l1.textContent = SKILL[0] ? SKILL[0].card.split(',')[0] : 'stays at the back';
|
|
615
|
+
var l2 = document.createElement('span'); var top = SKILL[v.options.length - 1]; l2.textContent = top ? top.card.split(',')[0] : '';
|
|
616
|
+
legend.append(l1, document.createElement('i'), l2);
|
|
617
|
+
var foot = document.createElement('div'); foot.className = 'foot';
|
|
618
|
+
foot.textContent = (v.voters || 0) + ' of ' + (v.of_total || 1) + ' voted · the middle vote wins';
|
|
619
|
+
voteCard.append(head, opts, legend, foot);
|
|
620
|
+
voteCard.hidden = false;
|
|
621
|
+
if (!voteTimer) voteTimer = setInterval(renderVote, 500);
|
|
622
|
+
}
|
|
623
|
+
document.addEventListener('keydown', function (e) {
|
|
624
|
+
if (!state.vote || !state.vote.open || voteCard.hidden || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
625
|
+
var n = Number(e.key);
|
|
626
|
+
if (state.vote.options.indexOf(n) >= 0) { e.preventDefault(); cast(n); }
|
|
627
|
+
});
|
|
449
628
|
|
|
450
629
|
// A named room (?room=, a friend's link, a test, the big screen's QR) skips the lobby; everyone else meets strangers.
|
|
451
630
|
// The page refuses a code it cannot use before it gets here, so an asked room is never swapped for a public one.
|
|
452
631
|
var askedRoom = params.get('room');
|
|
632
|
+
var lobbyQ = [];
|
|
633
|
+
if (state.server) lobbyQ.push('server=' + encodeURIComponent(state.server.id));
|
|
634
|
+
if (/^[A-Za-z0-9_,-]{1,140}$/.test(params.get('not') || '')) lobbyQ.push('not=' + encodeURIComponent(params.get('not')));
|
|
453
635
|
if (boot.room) start(boot.room);
|
|
454
636
|
else if (askedRoom !== null && /^[A-Za-z0-9_-]{1,32}$/.test(askedRoom)) start(askedRoom);
|
|
455
637
|
else if (askedRoom !== null) { say('that room link does not work'); }
|
|
456
|
-
else fetch('/' + boot.game + '/api/lobby' + (
|
|
638
|
+
else fetch('/' + boot.game + '/api/lobby' + (lobbyQ.length ? '?' + lobbyQ.join('&') : ''), { method: 'POST' })
|
|
457
639
|
.then(function (r) { return r.json(); })
|
|
458
|
-
.then(function (j) {
|
|
640
|
+
.then(function (j) {
|
|
641
|
+
// Every room this server may have is full: the fullest one, watched until a seat frees (the relay seats it).
|
|
642
|
+
if (j.full) { state.full = true; flash((state.server ? state.server.name : 'This server') + ' is full. You\u2019re next for a seat.'); }
|
|
643
|
+
start(j.room || 'main');
|
|
644
|
+
})
|
|
459
645
|
.catch(function () { start('main'); });
|
|
460
646
|
}());`;
|
|
461
647
|
|
|
@@ -481,6 +667,7 @@ const OWNER_CSS = `.owner { position: fixed; z-index: 10; top: max(8px, env(safe
|
|
|
481
667
|
.osheet .tag { font-size: 11px; padding: 1px 7px; border-radius: 999px; border: 1px solid rgba(255,255,255,.2); color: #aab3c7; margin-left: 6px; }
|
|
482
668
|
.osheet .tag.you { color: #c9b8ff; border-color: #c9b8ff; }
|
|
483
669
|
.osheet .tag.muted { color: #ff8a9a; border-color: rgba(255,107,125,.6); }
|
|
670
|
+
.osheet .tag.ai { color: #ffe7a8; border-color: rgba(255,207,90,.6); }
|
|
484
671
|
.osheet .bot { color: #6c7489; font-weight: 500; padding: 2px 10px 8px; }
|
|
485
672
|
.osheet .det { padding: 4px 2px; }
|
|
486
673
|
.osheet .det b { font-size: 18px; display: block; margin-bottom: 4px; overflow-wrap: anywhere; }
|
|
@@ -553,7 +740,7 @@ const OWNER_JS = String.raw`(function () {
|
|
|
553
740
|
var back = el('button', 'back', '← Everyone here'); back.type = 'button'; back.onclick = function () { view.pick = null; view.msg = ''; render(); };
|
|
554
741
|
var det = el('div', 'det');
|
|
555
742
|
det.appendChild(el('b', '', picked.name || 'Someone'));
|
|
556
|
-
det.appendChild(el('p', '', (picked.seat !== null ? 'Seat ' + (picked.seat + 1) : 'Watching') + ' · ' + (picked.device === 'phone' ? 'on a phone' : picked.device === 'tv' ? 'a big screen' : 'on a computer') + (picked.role === 'host' ? ' · hosting the room' : '')));
|
|
743
|
+
det.appendChild(el('p', '', (picked.seat !== null ? 'Seat ' + (picked.seat + 1) : 'Watching') + ' · ' + (picked.agent ? 'an AI (' + (picked.agent.role === 'guide' ? 'a guide' : 'a companion') + '), always marked AI' : picked.device === 'phone' ? 'on a phone' : picked.device === 'tv' ? 'a big screen' : 'on a computer') + (picked.role === 'host' ? ' · hosting the room' : '')));
|
|
557
744
|
if (me) det.appendChild(el('p', '', 'This is you.'));
|
|
558
745
|
if (picked.muted) det.appendChild(el('p', '', 'Muted: their chat and emotes reach nobody.'));
|
|
559
746
|
sheet.append(back, det);
|
|
@@ -579,12 +766,13 @@ const OWNER_JS = String.raw`(function () {
|
|
|
579
766
|
var left = el('span'); left.appendChild(el('span', '', c.name || 'Someone'));
|
|
580
767
|
if (c.seat !== null && c.seat === state.seat) left.appendChild(el('span', 'tag you', 'you'));
|
|
581
768
|
if (c.muted) left.appendChild(el('span', 'tag muted', 'muted'));
|
|
769
|
+
if (c.agent) left.appendChild(el('span', 'tag ai', 'AI' + (c.agent.role === 'guide' ? ' guide' : c.agent.role === 'party' ? ' companion' : '')));
|
|
582
770
|
if (c.role === 'host') left.appendChild(el('span', 'tag', 'host'));
|
|
583
771
|
row.append(left, el('small', '', c.seat !== null ? 'seat ' + (c.seat + 1) + ' · ' + (c.device === 'phone' ? 'phone' : c.device === 'tv' ? 'screen' : 'computer') : 'waiting'));
|
|
584
772
|
row.onclick = function () { view.pick = c.id; view.msg = ''; render(); };
|
|
585
773
|
sheet.appendChild(row);
|
|
586
774
|
});
|
|
587
|
-
var b = bots(); if (b.length) sheet.appendChild(el('div', 'bot', '
|
|
775
|
+
var b = bots(); if (b.length) sheet.appendChild(el('div', 'bot', 'AI bots: ' + b.map(function (s) { return s.name; }).join(', ')));
|
|
588
776
|
}
|
|
589
777
|
var form = el('form'); form.setAttribute('data-announce', '');
|
|
590
778
|
var ta = el('textarea'); ta.maxLength = 280; ta.placeholder = 'Announce something to the players'; ta.setAttribute('aria-label', 'Announcement');
|
|
@@ -695,6 +883,7 @@ iframe.game { position: absolute; inset: 0; width: 100%; height: 100%; border: 0
|
|
|
695
883
|
.chip .sc.lead { color: var(--hot); }
|
|
696
884
|
.chip svg { width: 1.1em; height: 1.1em; flex: none; }
|
|
697
885
|
.chip .tag { font-size: .68em; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--hot); }
|
|
886
|
+
.chip .tag.ai { padding: .1em .45em; border-radius: 999px; background: color-mix(in srgb, var(--hot) 20%, transparent); color: #fff; letter-spacing: .06em; }
|
|
698
887
|
.hint { flex: none; align-self: center; color: #7d8699; font-size: .76em; font-weight: 600; padding: 0 .2em; white-space: nowrap; }
|
|
699
888
|
@media (hover: none) { .chip .k, .hint { display: none; } }
|
|
700
889
|
/* A phone held upright: the caption over the strip, the strip scrolling sideways. */
|
|
@@ -895,7 +1084,7 @@ const WATCH_JS = String.raw`(function () {
|
|
|
895
1084
|
var scores = state.scores || {};
|
|
896
1085
|
var lead = null; var best = -Infinity;
|
|
897
1086
|
list.forEach(function (p) { var s = scores[p.seat]; if (typeof s === 'number' && s > best) { best = s; lead = p.seat; } });
|
|
898
|
-
var sig = JSON.stringify([choose, v.seat, v.following, list.map(function (p) { return [p.seat, p.name, p.colour, scores[p.seat]]; }), lead, state.follows]);
|
|
1087
|
+
var sig = JSON.stringify([choose, v.seat, v.following, list.map(function (p) { return [p.seat, p.name, p.colour, scores[p.seat], Boolean(p.agent)]; }), lead, state.follows]);
|
|
899
1088
|
if (sig === drawn) return;
|
|
900
1089
|
drawn = sig;
|
|
901
1090
|
strip.textContent = '';
|
|
@@ -917,6 +1106,8 @@ const WATCH_JS = String.raw`(function () {
|
|
|
917
1106
|
chip(p.seat, i < 9 ? String(i + 1) : '', function (b) {
|
|
918
1107
|
var dot = el('i', 'dot'); dot.style.background = colourOf(p); b.appendChild(dot);
|
|
919
1108
|
b.appendChild(el('span', 'nm', p.name || 'A player'));
|
|
1109
|
+
// An AI in the room is marked AI here too (section 17).
|
|
1110
|
+
if (p.agent) b.appendChild(el('span', 'tag ai', 'AI'));
|
|
920
1111
|
if (auto) b.appendChild(el('span', 'tag', 'auto'));
|
|
921
1112
|
if (typeof scores[p.seat] === 'number') b.appendChild(el('span', 'sc' + (p.seat === lead && list.length > 1 ? ' lead' : ''), scores[p.seat]));
|
|
922
1113
|
b.setAttribute('aria-label', (on || auto ? 'Watching ' : 'Watch ') + (p.name || 'a player'));
|
package/worker/players.mjs
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* A player sees, downloads (/api/player/export) and deletes (/api/player/delete) everything of theirs.
|
|
26
26
|
*/
|
|
27
27
|
import { handleFor } from './room.mjs';
|
|
28
|
+
import { isAiName } from './agents.mjs';
|
|
28
29
|
import { cookieValue } from './stats.mjs';
|
|
29
30
|
import { adoptStatements, bumpPlayerStats, dropPlayerData, fall, fallenList, getSave, listSaves, playerData, readPlayerStats, SAVE_LIMITS, savesSummary, wipeSaves, writeSaves } from './saves.mjs';
|
|
30
31
|
import { bytesToB64url, randomToken, sha256Hex, verifyAssertion, verifyRegistration } from './webauthn.mjs';
|
|
@@ -147,8 +148,9 @@ const ROLE_WORDS = /^(?:the\s+)?(?:admin|administrator|mod|moderator|owner|staff
|
|
|
147
148
|
/**
|
|
148
149
|
* A display name, by the same rules as a room's typed names and handles: one line of at most 24 characters,
|
|
149
150
|
* whitespace collapsed, no control, format or invisible characters (no zero-width or bidi tricks), at least one
|
|
150
|
-
* letter or digit; never a role (admin, moderator, owner…) or the studio's own name, except for the owner
|
|
151
|
-
*
|
|
151
|
+
* letter or digit; never a role (admin, moderator, owner…) or the studio's own name, except for the owner; and never
|
|
152
|
+
* ending in an AI or bot mark ("· AI", "(bot)"…), for anyone: only an agent's name says AI (0.16.0). It is always
|
|
153
|
+
* shown as text (escaped), never as markup. `max` lets a character's name run to 32.
|
|
152
154
|
*/
|
|
153
155
|
export function cleanName(raw, { studio = '', owner = false, max = 24 } = {}) {
|
|
154
156
|
const text = String(raw ?? '').normalize('NFKC')
|
|
@@ -161,6 +163,7 @@ export function cleanName(raw, { studio = '', owner = false, max = 24 } = {}) {
|
|
|
161
163
|
if (!owner && (ROLE_WORDS.test(name) || (studio && name.toLowerCase() === String(studio).trim().toLowerCase()))) {
|
|
162
164
|
return { ok: false, error: 'name-taken', message: 'that name belongs to the studio; pick another' };
|
|
163
165
|
}
|
|
166
|
+
if (isAiName(name)) return { ok: false, error: 'name-ai', message: 'a person\'s name cannot end in "AI" or "bot": only an AI player is marked AI' };
|
|
164
167
|
return { ok: true, name, cut: chars.length > max };
|
|
165
168
|
}
|
|
166
169
|
|
|
@@ -299,7 +302,10 @@ async function meOf(env, player) {
|
|
|
299
302
|
const e = await env.DB.prepare('SELECT email, verified_at FROM player_emails WHERE player = ?1').bind(player.id).first();
|
|
300
303
|
if (e) email = { address: e.email, verified: Boolean(e.verified_at) };
|
|
301
304
|
}
|
|
302
|
-
|
|
305
|
+
// The servers this player belongs to (0.16.0; none before migration 0006).
|
|
306
|
+
let servers = [];
|
|
307
|
+
try { servers = ((await env.DB.prepare('SELECT game, server, role, home FROM server_members WHERE player = ?1 ORDER BY home DESC, seen_at DESC LIMIT 50').bind(player.id).all()).results ?? []).map((r) => ({ game: r.game, server: r.server, role: r.role, home: Number(r.home) === 1 })); } catch { servers = []; }
|
|
308
|
+
return { id: player.id, name: player.name, named: Boolean(player.named), guest: Boolean(player.guest), owner: Boolean(player.owner), since: new Date(player.since).toISOString(), passkeys: Number(keys?.n) || 0, email, servers };
|
|
303
309
|
}
|
|
304
310
|
|
|
305
311
|
const features = (env) => ({ accounts: Boolean(env.DB) && env.HOMIE_PREVIEW !== '1', email: Boolean(env.PLAYER_MAIL && env.PLAYER_MAIL_FROM) });
|
|
@@ -315,6 +321,8 @@ async function removePlayer(env, id) {
|
|
|
315
321
|
env.DB.prepare('DELETE FROM player_emails WHERE player = ?1').bind(id),
|
|
316
322
|
env.DB.prepare('DELETE FROM players WHERE id = ?1').bind(id),
|
|
317
323
|
]);
|
|
324
|
+
// Their servers' memberships too (0.16.0); a studio before migration 0006 has none to delete.
|
|
325
|
+
try { await env.DB.prepare('DELETE FROM server_members WHERE player = ?1').bind(id).run(); } catch { /* not migrated */ }
|
|
318
326
|
await data.after();
|
|
319
327
|
}
|
|
320
328
|
|