@homie-rocks/studio 0.15.0 → 0.16.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 -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 +56 -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 +49 -0
- package/starters/ember-vale/src/main.ts +21 -7
- package/starters/gem-rush/src/main.ts +88 -20
- package/worker/account-page.mjs +26 -0
- package/worker/agents.mjs +238 -0
- package/worker/index.mjs +307 -74
- package/worker/office-page.mjs +414 -12
- package/worker/office.mjs +294 -25
- package/worker/pages.mjs +226 -17
- 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);
|
|
@@ -111,6 +113,10 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
|
|
|
111
113
|
.room.at-bottom-right { bottom: calc(max(8px, env(safe-area-inset-bottom)) + var(--dy)); right: calc(max(8px, env(safe-area-inset-right)) + var(--dx)); align-items: flex-end; flex-direction: column-reverse; }
|
|
112
114
|
.room.at-bottom-left { bottom: calc(max(8px, env(safe-area-inset-bottom)) + var(--dy)); left: calc(max(8px, env(safe-area-inset-left)) + var(--dx)); align-items: flex-start; flex-direction: column-reverse; }
|
|
113
115
|
.chip.chip-right { left: auto; right: max(10px, env(safe-area-inset-right)); }
|
|
116
|
+
/* The room button and the server pill share one band at the room button's place, side by side (the server pill on
|
|
117
|
+
the inner side), so the shell never covers more of the game than the place the game gave the room button. */
|
|
118
|
+
.room .pills { display: flex; align-items: center; gap: 8px; }
|
|
119
|
+
.room.at-top-right .pills, .room.at-bottom-right .pills { flex-direction: row-reverse; }
|
|
114
120
|
.pill { display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 11px 0 9px; border-radius: 999px; border: 1px solid rgba(255,255,255,.18); background: rgba(6,9,16,.62); color: inherit; font: inherit; cursor: pointer; touch-action: manipulation; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); transition: opacity .5s; }
|
|
115
121
|
.pill svg { width: 15px; height: 15px; flex: none; }
|
|
116
122
|
.pill.dim { opacity: .38; width: 34px; padding: 0; justify-content: center; }
|
|
@@ -148,16 +154,28 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
|
|
|
148
154
|
.notice .acts { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 16px; }
|
|
149
155
|
.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
156
|
.notice .acts a.primary { background: var(--hot); color: #0b0b10; border-color: transparent; }
|
|
151
|
-
[hidden] { display: none !important; }
|
|
157
|
+
[hidden] { display: none !important; }
|
|
158
|
+
${SERVER_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}`;
|
|
152
159
|
// The room button's place on each device (sharePlaces); the shell moves it to this browser's once it knows the device.
|
|
153
160
|
// A ticket (a game that is not public) and the owner's overlay ride along only for the browser they are for.
|
|
154
|
-
|
|
161
|
+
// The server this page plays on (0.16.0): its pool (the Lobby), its badge and line for the chip, its ceiling.
|
|
162
|
+
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;
|
|
163
|
+
// game.json "agents": { "vote": "game" } draws its own vote card; false turns the vote off (section 17).
|
|
164
|
+
const vote = g.agents?.vote === 'game' || g.agents?.vote === false ? g.agents.vote : 'shell';
|
|
165
|
+
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
166
|
// game.json "screen": { "join": "top-left" | "top-right" | "bottom-left" | "bottom-right" } keeps the card off the game's own HUD.
|
|
156
167
|
const joinCorner = corner('join', 'bottom-right');
|
|
157
168
|
const first = places.desk;
|
|
158
169
|
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
170
|
const share = screen ? '' : `<div class="room at-${first.at}" style="--dx:${first.x}px;--dy:${first.y}px" data-room-ui>
|
|
160
|
-
<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>
|
|
171
|
+
<div class="pills"><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>
|
|
172
|
+
<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></div>
|
|
173
|
+
<div class="sheet ssheet" id="server-sheet" role="dialog" aria-label="This server" data-server-sheet hidden>
|
|
174
|
+
<div class="code"><b data-server-name></b><span data-server-badge></span></div>
|
|
175
|
+
<p class="sline" data-server-line></p>
|
|
176
|
+
<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>
|
|
177
|
+
<label class="srow quiet-ai"><span>Quiet AI <small>hides AI chat on this screen</small></span><input type="checkbox" data-quiet-ai></label>
|
|
178
|
+
</div>
|
|
161
179
|
<div class="sheet" id="share-sheet" role="dialog" aria-label="This room" data-share-sheet hidden>
|
|
162
180
|
<div class="code"><b data-room-label>This room</b><span data-room-count></span></div>
|
|
163
181
|
<div class="acts">
|
|
@@ -173,6 +191,7 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
|
|
|
173
191
|
return layoutless(`${g.name} · play`, `
|
|
174
192
|
<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
193
|
<div class="chip" data-chip><span data-status>finding a room…</span></div>
|
|
194
|
+
<div class="vote" data-vote role="dialog" aria-label="How strong should the AI be?" data-keep-focus hidden></div>
|
|
176
195
|
<div class="card" data-results hidden></div>
|
|
177
196
|
<div class="card" data-screen hidden></div>
|
|
178
197
|
${joinCard}${share}
|
|
@@ -181,6 +200,49 @@ ${joinCard}${share}
|
|
|
181
200
|
<script>${SAVES_SHELL_JS}</script>` : ''}${owner ? `<style>${OWNER_CSS}</style><script>${OWNER_JS}</script>` : ''}`, css, frameAncestors(cat));
|
|
182
201
|
}
|
|
183
202
|
|
|
203
|
+
/*
|
|
204
|
+
* SERVERS AND THE AI DIAL (0.16.0, NETPLAY.md section 17): the server pill beside the room button, in the same band
|
|
205
|
+
* (its name and badge, or on a phone held upright its dot; its sheet has the policy's line, the AI's level with
|
|
206
|
+
* "Change", and Quiet AI), and the party's vote card on how strong the AI should be (an opaque bottom sheet on a
|
|
207
|
+
* phone; keys 1-5 on a computer). Nothing covers the middle of the screen except the card, for a few seconds, when a
|
|
208
|
+
* vote is open.
|
|
209
|
+
*/
|
|
210
|
+
const SERVER_CSS = `.spill .sdot { width: 8px; height: 8px; border-radius: 50%; background: var(--hot); flex: none; }
|
|
211
|
+
.spill.dim .sdot { display: block; }
|
|
212
|
+
/* A phone held upright has no width to spare at its top: the server pill is its dot from the start (its sheet says
|
|
213
|
+
the name, the badge and the line; the chip names the server), so the band is the room button's label and one dot. */
|
|
214
|
+
@media (max-width: 540px) {
|
|
215
|
+
.spill, .spill:hover, .spill:focus-visible, .spill[aria-expanded="true"] { width: 34px; padding: 0; justify-content: center; }
|
|
216
|
+
.spill span[data-server-label], .spill:hover span[data-server-label], .spill:focus-visible span[data-server-label], .spill[aria-expanded="true"] span[data-server-label] { display: none; }
|
|
217
|
+
}
|
|
218
|
+
.ssheet .sline { margin: 0 2px 10px; color: #c3cad9; font-weight: 500; line-height: 1.4; }
|
|
219
|
+
.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; }
|
|
220
|
+
.ssheet .srow small { display: block; color: #8b93a7; font-weight: 500; font-size: 11px; }
|
|
221
|
+
.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; }
|
|
222
|
+
.ssheet .srow input { width: 20px; height: 20px; accent-color: var(--hot); }
|
|
223
|
+
.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; }
|
|
224
|
+
.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: #080c16; 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; }
|
|
225
|
+
@keyframes rise { from { opacity: 0; transform: translate(-50%, -46%); } }
|
|
226
|
+
.vote .vh { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; margin: 0 2px 12px; }
|
|
227
|
+
.vote .vh b { font-size: 18px; letter-spacing: -.01em; }
|
|
228
|
+
.vote .vh span { color: #aab3c7; font-weight: 600; font-size: 12px; white-space: nowrap; }
|
|
229
|
+
.vote .opts { display: grid; grid-template-columns: repeat(var(--n, 5), 1fr); gap: 8px; }
|
|
230
|
+
.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; }
|
|
231
|
+
.vote .opts button b { font-size: 16px; }
|
|
232
|
+
.vote .opts button small { color: #9aa3b7; font-size: 11px; font-weight: 600; }
|
|
233
|
+
.vote .opts button[aria-pressed="true"] { background: var(--hot); color: #0b0b10; border-color: transparent; }
|
|
234
|
+
.vote .opts button[aria-pressed="true"] small { color: #3b3420; }
|
|
235
|
+
.vote .legend { display: flex; justify-content: space-between; gap: 10px; margin: 10px 4px 0; color: #8b93a7; font-size: 11px; font-weight: 600; }
|
|
236
|
+
.vote .legend i { flex: 1; border-bottom: 2px dotted rgba(255,255,255,.18); margin-bottom: 4px; }
|
|
237
|
+
.vote .foot { margin: 10px 2px 0; text-align: center; color: #c3cad9; font-weight: 600; }
|
|
238
|
+
@media (max-width: 540px) {
|
|
239
|
+
.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; }
|
|
240
|
+
@keyframes up { from { opacity: 0; transform: translateY(20px); } }
|
|
241
|
+
.vote .opts { grid-template-columns: repeat(3, 1fr); }
|
|
242
|
+
.vote .opts button { min-height: 64px; }
|
|
243
|
+
.vote .legend { display: none; }
|
|
244
|
+
}`;
|
|
245
|
+
|
|
184
246
|
/** The play page's own document (no site chrome: the game owns the whole screen). */
|
|
185
247
|
function layoutless(title, body, css, ancestors = "'self'") {
|
|
186
248
|
return new Response(`<!doctype html>
|
|
@@ -212,7 +274,7 @@ const SHELL_JS = String.raw`(function () {
|
|
|
212
274
|
var statusEl = document.querySelector('[data-status]');
|
|
213
275
|
var results = document.querySelector('[data-results]');
|
|
214
276
|
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 };
|
|
277
|
+
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
278
|
window.__shell = state;
|
|
217
279
|
// 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
280
|
// an owner's kick holds out of a room for a while (section 15); it says nothing about who the player is.
|
|
@@ -257,9 +319,27 @@ const SHELL_JS = String.raw`(function () {
|
|
|
257
319
|
}
|
|
258
320
|
place();
|
|
259
321
|
addEventListener('resize', place);
|
|
260
|
-
function labelOf(room) {
|
|
322
|
+
function labelOf(room) {
|
|
323
|
+
var m = /^pub-(\d+)$/.exec(room);
|
|
324
|
+
if (m) return 'Room ' + m[1];
|
|
325
|
+
// A room of this page's server (s-<server>-<n>) is "Room <n>" too: the server pill beside the button names it.
|
|
326
|
+
var at = state.server ? 's-' + state.server.id + '-' : null;
|
|
327
|
+
var n = at && room.indexOf(at) === 0 ? room.slice(at.length) : '';
|
|
328
|
+
return /^\d+$/.test(n) ? 'Room ' + n : room;
|
|
329
|
+
}
|
|
261
330
|
function flash(text) { if (!toast) return; toast.textContent = text; toast.hidden = false; clearTimeout(flash.t); flash.t = setTimeout(function () { toast.hidden = true; }, 1800); }
|
|
262
|
-
|
|
331
|
+
// The room pill and the server pill (in one band, at the room button's place) fade to dots after 6 s.
|
|
332
|
+
function wake() {
|
|
333
|
+
if (!toggle) return;
|
|
334
|
+
var sp = document.querySelector('[data-server-toggle]');
|
|
335
|
+
toggle.classList.remove('dim'); if (sp) sp.classList.remove('dim');
|
|
336
|
+
clearTimeout(dimTimer);
|
|
337
|
+
dimTimer = setTimeout(function () {
|
|
338
|
+
if (sheet.hidden) toggle.classList.add('dim');
|
|
339
|
+
var ss = document.querySelector('[data-server-sheet]');
|
|
340
|
+
if (sp && (!ss || ss.hidden)) sp.classList.add('dim');
|
|
341
|
+
}, 6000);
|
|
342
|
+
}
|
|
263
343
|
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
344
|
function copy(text, done) {
|
|
265
345
|
var ok = function () { flash(done || 'Link copied'); };
|
|
@@ -309,6 +389,8 @@ const SHELL_JS = String.raw`(function () {
|
|
|
309
389
|
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
390
|
}
|
|
311
391
|
if (params.get('debug') === '1') q.set('debug', '1');
|
|
392
|
+
// Quiet AI (this browser hides AI speech): the helper starts hushed, and hears a change by message.
|
|
393
|
+
if (state.quietAi) q.set('hush', '1');
|
|
312
394
|
// The frame cannot read this page's address (it is an opaque origin): hand it the game's own switches.
|
|
313
395
|
['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
396
|
frame.src = '/' + boot.game + '/__game/?' + q.toString();
|
|
@@ -323,7 +405,7 @@ const SHELL_JS = String.raw`(function () {
|
|
|
323
405
|
if (m.what === 'stats') state.stats = m.stats;
|
|
324
406
|
if (m.what === 'round') onRound(m.round);
|
|
325
407
|
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); }
|
|
408
|
+
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
409
|
// A game's net.pickPlayer(seat): only an owner's page listens (its overlay opens that player's card).
|
|
328
410
|
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
411
|
paint();
|
|
@@ -349,7 +431,9 @@ const SHELL_JS = String.raw`(function () {
|
|
|
349
431
|
var ol = document.createElement('ol');
|
|
350
432
|
r.results.slice(0, 6).forEach(function (row) {
|
|
351
433
|
var li = document.createElement('li');
|
|
352
|
-
li.textContent = row.name
|
|
434
|
+
li.textContent = String(row.name) + ' — ' + row.score;
|
|
435
|
+
// Every AI row (an agent, or a bot) carries the AI pill.
|
|
436
|
+
if (row.agent || row.bot) { var pill = document.createElement('span'); pill.className = 'aipill'; pill.textContent = 'AI'; li.append(pill); }
|
|
353
437
|
ol.append(li);
|
|
354
438
|
});
|
|
355
439
|
results.append(h, ol); results.hidden = params.get('debug') !== '1'; // the game draws its own results; the card is for ?debug=1
|
|
@@ -370,10 +454,13 @@ const SHELL_JS = String.raw`(function () {
|
|
|
370
454
|
if (m.t === 'closed') { notice('closed', m); return; }
|
|
371
455
|
if (m.t === 'announce') { banner(m); return; }
|
|
372
456
|
if (m.t === 'muted') { mutedNote(m); return; }
|
|
457
|
+
if (m.t === 'vote') { onVote(m); return; }
|
|
373
458
|
if (m.t !== 'net') return;
|
|
374
459
|
state.facts = m;
|
|
460
|
+
if (typeof m.st === 'number') state.offset = m.st - Date.now();
|
|
375
461
|
if (m.announce && m.announce.text) banner(m.announce, true);
|
|
376
462
|
if (state.facts && state.facts.round) onRound(state.facts.round);
|
|
463
|
+
if (m.vote) onVote(m.vote, true); else if (state.vote && state.vote.open) onVote({ open: false, id: state.vote.id }, true);
|
|
377
464
|
paint();
|
|
378
465
|
};
|
|
379
466
|
ws.onclose = function () { if (!state.notice) setTimeout(function () { watch(room); }, 1500); };
|
|
@@ -438,24 +525,141 @@ const SHELL_JS = String.raw`(function () {
|
|
|
438
525
|
var f = state.facts;
|
|
439
526
|
var n = f && f.counts ? f.counts.players : null;
|
|
440
527
|
var bits = [];
|
|
441
|
-
if (
|
|
442
|
-
if (
|
|
528
|
+
if (state.server) bits.push(state.server.name);
|
|
529
|
+
if (n !== null) bits.push(n + ' playing');
|
|
530
|
+
// Every AI is counted as AI (agents, the seats kept for them, and the game's bots), never as a player.
|
|
531
|
+
var aiN = f && f.counts ? (Number(f.counts.ai) || 0) + (Number(f.counts.bots) || 0) : 0;
|
|
532
|
+
if (aiN) bits.push(aiN + ' AI');
|
|
443
533
|
if (f && f.counts && f.counts.watchers) bits.push(f.counts.watchers + ' watching');
|
|
534
|
+
if (state.full) bits.push('waiting for a seat');
|
|
444
535
|
if (state.closed) bits.push(state.closed === 'replaced' ? 'opened in another tab' : 'reconnecting');
|
|
445
536
|
say(bits.join(' · ') || 'joining…');
|
|
446
537
|
var count = ui && ui.querySelector('[data-room-count]');
|
|
447
|
-
if (count && n !== null) count.textContent = n + (n === 1 ? ' player here' : ' players here');
|
|
538
|
+
if (count && n !== null) count.textContent = n + (n === 1 ? ' player here' : ' players here') + (aiN ? ' · ' + aiN + ' AI' : '');
|
|
539
|
+
serverUi();
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/* ---------------------------------------------------------- the server pill, Quiet AI and the vote (section 17) */
|
|
543
|
+
var SKILL = boot.skills || [];
|
|
544
|
+
var sToggle = document.querySelector('[data-server-toggle]');
|
|
545
|
+
var sSheet = document.querySelector('[data-server-sheet]');
|
|
546
|
+
var voteCard = document.querySelector('[data-vote]');
|
|
547
|
+
try { state.quietAi = localStorage.getItem('homie-quiet-ai') === '1'; } catch (e) { state.quietAi = false; }
|
|
548
|
+
function skillName(n) { var s = SKILL[n - 1]; return s ? s.name : 'level ' + n; }
|
|
549
|
+
function hasAi() { var f = state.facts; return Boolean(f && f.counts && (f.counts.ai || f.counts.bots || f.counts.agents)); }
|
|
550
|
+
function readsDial() { var f = state.facts; return Boolean(f && Array.isArray(f.caps) && f.caps.indexOf('skill') >= 0); }
|
|
551
|
+
// The room's live policy says the badge and line (the owner's change reaches an open page at once); the
|
|
552
|
+
// server's words from the page load are the fallback.
|
|
553
|
+
function policyText(pol) {
|
|
554
|
+
var w = pol && boot.words ? boot.words[pol.kind] : null;
|
|
555
|
+
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.' };
|
|
556
|
+
var n = pol.kind === 'hybrid' ? (Number(pol.aiSeats) || 0) : 0;
|
|
557
|
+
return { badge: pol.kind === 'hybrid' ? w.badge + ' · ' + n : w.badge, line: w.line.replace('{n}', String(n)) + (pol.kids && boot.kidsLine ? ' ' + boot.kidsLine : '') };
|
|
558
|
+
}
|
|
559
|
+
function serverUi() {
|
|
560
|
+
if (!sToggle || !sSheet) return;
|
|
561
|
+
var f = state.facts; var pol = f && f.policy ? f.policy : null;
|
|
562
|
+
var show = Boolean(state.server) || (hasAi() && readsDial());
|
|
563
|
+
sToggle.hidden = !show;
|
|
564
|
+
if (!show) return;
|
|
565
|
+
var name = state.server ? state.server.name : 'Quick play';
|
|
566
|
+
var words = policyText(pol);
|
|
567
|
+
var label = name + ' · ' + words.badge + (state.server && state.server.mentor ? ' · Mentor' : '');
|
|
568
|
+
var labelEl = sToggle.querySelector('[data-server-label]');
|
|
569
|
+
// A new policy (the owner's change) shows in full for a moment, then fades with the room pill.
|
|
570
|
+
if (labelEl.textContent !== label) { labelEl.textContent = label; sToggle.setAttribute('aria-label', label + ': this server'); wake(); }
|
|
571
|
+
sSheet.querySelector('[data-server-name]').textContent = name;
|
|
572
|
+
sSheet.querySelector('[data-server-badge]').textContent = words.badge;
|
|
573
|
+
sSheet.querySelector('[data-server-line]').textContent = words.line;
|
|
574
|
+
var row = sSheet.querySelector('[data-level-row]');
|
|
575
|
+
row.hidden = !(readsDial() && hasAi() && boot.vote !== false);
|
|
576
|
+
if (pol && pol.skill) sSheet.querySelector('[data-level-name]').textContent = pol.skill.name;
|
|
577
|
+
sSheet.querySelector('[data-quiet-ai]').checked = state.quietAi;
|
|
578
|
+
}
|
|
579
|
+
if (sToggle) {
|
|
580
|
+
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(); });
|
|
581
|
+
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);
|
|
582
|
+
sSheet.querySelector('[data-quiet-ai]').addEventListener('change', function (e) {
|
|
583
|
+
state.quietAi = e.target.checked;
|
|
584
|
+
try { localStorage.setItem('homie-quiet-ai', state.quietAi ? '1' : '0'); } catch (e2) {}
|
|
585
|
+
try { frame.contentWindow.postMessage({ t: 'homie-hush', on: state.quietAi }, '*'); } catch (e3) {}
|
|
586
|
+
flash(state.quietAi ? 'AI chat is hidden on this screen.' : 'AI chat shows again.');
|
|
587
|
+
});
|
|
588
|
+
sSheet.querySelector('[data-level-vote]').addEventListener('click', function () { sendVote({ open: true }); sSheet.hidden = true; sToggle.setAttribute('aria-expanded', 'false'); });
|
|
589
|
+
}
|
|
590
|
+
function sendVote(m) {
|
|
591
|
+
var ws = state.watchSocket;
|
|
592
|
+
if (!ws || ws.readyState !== 1) { flash('Not connected yet; try again in a moment.'); return; }
|
|
593
|
+
try { ws.send(JSON.stringify(Object.assign({ t: 'vote', of: 'skill' }, m))); } catch (e) {}
|
|
448
594
|
}
|
|
595
|
+
function cast(n) { if (!state.vote || !state.vote.open) return; state.myVote[state.vote.id] = n; sendVote({ n: n }); renderVote(); }
|
|
596
|
+
var shownResults = {};
|
|
597
|
+
function onVote(v, quiet) {
|
|
598
|
+
if (!v || typeof v !== 'object') return;
|
|
599
|
+
if (v.t === 'error' || v.code) return;
|
|
600
|
+
var prev = state.vote;
|
|
601
|
+
state.vote = v.options ? v : (prev && prev.id === v.id ? Object.assign({}, prev, { open: false }) : prev);
|
|
602
|
+
if (v.result && !v.open && !shownResults[v.id] && !quiet) {
|
|
603
|
+
shownResults[v.id] = true;
|
|
604
|
+
flash('The party set the AI to ' + v.result.name + ' (' + v.result.votes + (v.result.votes === 1 ? ' vote' : ' votes') + ').');
|
|
605
|
+
} else if (v.result && !v.open) shownResults[v.id] = true;
|
|
606
|
+
renderVote();
|
|
607
|
+
}
|
|
608
|
+
var voteTimer = null;
|
|
609
|
+
function renderVote() {
|
|
610
|
+
var v = state.vote;
|
|
611
|
+
if (!voteCard) return;
|
|
612
|
+
// A game that draws its own card (game.json "agents": { "vote": "game" }), or no vote: the shell shows none.
|
|
613
|
+
if (!v || !v.open || boot.vote !== 'shell' || boot.screen || state.seat === null || state.seat === undefined) { voteCard.hidden = true; clearInterval(voteTimer); voteTimer = null; return; }
|
|
614
|
+
var left = Math.max(0, Math.ceil((v.until - (Date.now() + (state.offset || 0))) / 1000));
|
|
615
|
+
var mine = state.myVote[v.id];
|
|
616
|
+
voteCard.textContent = '';
|
|
617
|
+
var head = document.createElement('div'); head.className = 'vh';
|
|
618
|
+
var t = document.createElement('b'); t.textContent = 'How strong should the AI be?';
|
|
619
|
+
var sub = document.createElement('span'); sub.textContent = 'for this party · ' + left + ' s';
|
|
620
|
+
head.append(t, sub);
|
|
621
|
+
var opts = document.createElement('div'); opts.className = 'opts'; opts.style.setProperty('--n', String(v.options.length));
|
|
622
|
+
v.options.forEach(function (n) {
|
|
623
|
+
var b = document.createElement('button'); b.type = 'button'; b.setAttribute('data-vote-n', String(n));
|
|
624
|
+
b.setAttribute('aria-pressed', mine === n ? 'true' : 'false');
|
|
625
|
+
var big = document.createElement('b'); big.textContent = n + ' ' + skillName(n);
|
|
626
|
+
var small = document.createElement('small'); small.textContent = (v.counts && v.counts[n] ? v.counts[n] + (v.counts[n] === 1 ? ' vote' : ' votes') : '\u00a0');
|
|
627
|
+
b.append(big, small);
|
|
628
|
+
b.addEventListener('click', function () { cast(n); });
|
|
629
|
+
opts.appendChild(b);
|
|
630
|
+
});
|
|
631
|
+
var legend = document.createElement('div'); legend.className = 'legend';
|
|
632
|
+
var l1 = document.createElement('span'); l1.textContent = SKILL[0] ? SKILL[0].card.split(',')[0] : 'stays at the back';
|
|
633
|
+
var l2 = document.createElement('span'); var top = SKILL[v.options.length - 1]; l2.textContent = top ? top.card.split(',')[0] : '';
|
|
634
|
+
legend.append(l1, document.createElement('i'), l2);
|
|
635
|
+
var foot = document.createElement('div'); foot.className = 'foot';
|
|
636
|
+
foot.textContent = (v.voters || 0) + ' of ' + (v.of_total || 1) + ' voted · the middle vote wins';
|
|
637
|
+
voteCard.append(head, opts, legend, foot);
|
|
638
|
+
voteCard.hidden = false;
|
|
639
|
+
if (!voteTimer) voteTimer = setInterval(renderVote, 500);
|
|
640
|
+
}
|
|
641
|
+
document.addEventListener('keydown', function (e) {
|
|
642
|
+
if (!state.vote || !state.vote.open || voteCard.hidden || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
643
|
+
var n = Number(e.key);
|
|
644
|
+
if (state.vote.options.indexOf(n) >= 0) { e.preventDefault(); cast(n); }
|
|
645
|
+
});
|
|
449
646
|
|
|
450
647
|
// A named room (?room=, a friend's link, a test, the big screen's QR) skips the lobby; everyone else meets strangers.
|
|
451
648
|
// The page refuses a code it cannot use before it gets here, so an asked room is never swapped for a public one.
|
|
452
649
|
var askedRoom = params.get('room');
|
|
650
|
+
var lobbyQ = [];
|
|
651
|
+
if (state.server) lobbyQ.push('server=' + encodeURIComponent(state.server.id));
|
|
652
|
+
if (/^[A-Za-z0-9_,-]{1,140}$/.test(params.get('not') || '')) lobbyQ.push('not=' + encodeURIComponent(params.get('not')));
|
|
453
653
|
if (boot.room) start(boot.room);
|
|
454
654
|
else if (askedRoom !== null && /^[A-Za-z0-9_-]{1,32}$/.test(askedRoom)) start(askedRoom);
|
|
455
655
|
else if (askedRoom !== null) { say('that room link does not work'); }
|
|
456
|
-
else fetch('/' + boot.game + '/api/lobby' + (
|
|
656
|
+
else fetch('/' + boot.game + '/api/lobby' + (lobbyQ.length ? '?' + lobbyQ.join('&') : ''), { method: 'POST' })
|
|
457
657
|
.then(function (r) { return r.json(); })
|
|
458
|
-
.then(function (j) {
|
|
658
|
+
.then(function (j) {
|
|
659
|
+
// Every room this server may have is full: the fullest one, watched until a seat frees (the relay seats it).
|
|
660
|
+
if (j.full) { state.full = true; flash((state.server ? state.server.name : 'This server') + ' is full. You\u2019re next for a seat.'); }
|
|
661
|
+
start(j.room || 'main');
|
|
662
|
+
})
|
|
459
663
|
.catch(function () { start('main'); });
|
|
460
664
|
}());`;
|
|
461
665
|
|
|
@@ -481,6 +685,7 @@ const OWNER_CSS = `.owner { position: fixed; z-index: 10; top: max(8px, env(safe
|
|
|
481
685
|
.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
686
|
.osheet .tag.you { color: #c9b8ff; border-color: #c9b8ff; }
|
|
483
687
|
.osheet .tag.muted { color: #ff8a9a; border-color: rgba(255,107,125,.6); }
|
|
688
|
+
.osheet .tag.ai { color: #ffe7a8; border-color: rgba(255,207,90,.6); }
|
|
484
689
|
.osheet .bot { color: #6c7489; font-weight: 500; padding: 2px 10px 8px; }
|
|
485
690
|
.osheet .det { padding: 4px 2px; }
|
|
486
691
|
.osheet .det b { font-size: 18px; display: block; margin-bottom: 4px; overflow-wrap: anywhere; }
|
|
@@ -553,7 +758,7 @@ const OWNER_JS = String.raw`(function () {
|
|
|
553
758
|
var back = el('button', 'back', '← Everyone here'); back.type = 'button'; back.onclick = function () { view.pick = null; view.msg = ''; render(); };
|
|
554
759
|
var det = el('div', 'det');
|
|
555
760
|
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' : '')));
|
|
761
|
+
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
762
|
if (me) det.appendChild(el('p', '', 'This is you.'));
|
|
558
763
|
if (picked.muted) det.appendChild(el('p', '', 'Muted: their chat and emotes reach nobody.'));
|
|
559
764
|
sheet.append(back, det);
|
|
@@ -579,12 +784,13 @@ const OWNER_JS = String.raw`(function () {
|
|
|
579
784
|
var left = el('span'); left.appendChild(el('span', '', c.name || 'Someone'));
|
|
580
785
|
if (c.seat !== null && c.seat === state.seat) left.appendChild(el('span', 'tag you', 'you'));
|
|
581
786
|
if (c.muted) left.appendChild(el('span', 'tag muted', 'muted'));
|
|
787
|
+
if (c.agent) left.appendChild(el('span', 'tag ai', 'AI' + (c.agent.role === 'guide' ? ' guide' : c.agent.role === 'party' ? ' companion' : '')));
|
|
582
788
|
if (c.role === 'host') left.appendChild(el('span', 'tag', 'host'));
|
|
583
789
|
row.append(left, el('small', '', c.seat !== null ? 'seat ' + (c.seat + 1) + ' · ' + (c.device === 'phone' ? 'phone' : c.device === 'tv' ? 'screen' : 'computer') : 'waiting'));
|
|
584
790
|
row.onclick = function () { view.pick = c.id; view.msg = ''; render(); };
|
|
585
791
|
sheet.appendChild(row);
|
|
586
792
|
});
|
|
587
|
-
var b = bots(); if (b.length) sheet.appendChild(el('div', 'bot', '
|
|
793
|
+
var b = bots(); if (b.length) sheet.appendChild(el('div', 'bot', 'AI bots: ' + b.map(function (s) { return s.name; }).join(', ')));
|
|
588
794
|
}
|
|
589
795
|
var form = el('form'); form.setAttribute('data-announce', '');
|
|
590
796
|
var ta = el('textarea'); ta.maxLength = 280; ta.placeholder = 'Announce something to the players'; ta.setAttribute('aria-label', 'Announcement');
|
|
@@ -695,6 +901,7 @@ iframe.game { position: absolute; inset: 0; width: 100%; height: 100%; border: 0
|
|
|
695
901
|
.chip .sc.lead { color: var(--hot); }
|
|
696
902
|
.chip svg { width: 1.1em; height: 1.1em; flex: none; }
|
|
697
903
|
.chip .tag { font-size: .68em; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--hot); }
|
|
904
|
+
.chip .tag.ai { padding: .1em .45em; border-radius: 999px; background: color-mix(in srgb, var(--hot) 20%, transparent); color: #fff; letter-spacing: .06em; }
|
|
698
905
|
.hint { flex: none; align-self: center; color: #7d8699; font-size: .76em; font-weight: 600; padding: 0 .2em; white-space: nowrap; }
|
|
699
906
|
@media (hover: none) { .chip .k, .hint { display: none; } }
|
|
700
907
|
/* A phone held upright: the caption over the strip, the strip scrolling sideways. */
|
|
@@ -895,7 +1102,7 @@ const WATCH_JS = String.raw`(function () {
|
|
|
895
1102
|
var scores = state.scores || {};
|
|
896
1103
|
var lead = null; var best = -Infinity;
|
|
897
1104
|
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]);
|
|
1105
|
+
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
1106
|
if (sig === drawn) return;
|
|
900
1107
|
drawn = sig;
|
|
901
1108
|
strip.textContent = '';
|
|
@@ -917,6 +1124,8 @@ const WATCH_JS = String.raw`(function () {
|
|
|
917
1124
|
chip(p.seat, i < 9 ? String(i + 1) : '', function (b) {
|
|
918
1125
|
var dot = el('i', 'dot'); dot.style.background = colourOf(p); b.appendChild(dot);
|
|
919
1126
|
b.appendChild(el('span', 'nm', p.name || 'A player'));
|
|
1127
|
+
// An AI in the room is marked AI here too (section 17).
|
|
1128
|
+
if (p.agent) b.appendChild(el('span', 'tag ai', 'AI'));
|
|
920
1129
|
if (auto) b.appendChild(el('span', 'tag', 'auto'));
|
|
921
1130
|
if (typeof scores[p.seat] === 'number') b.appendChild(el('span', 'sc' + (p.seat === lead && list.length > 1 ? ' lead' : ''), scores[p.seat]));
|
|
922
1131
|
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
|
|