@homie-rocks/studio 0.24.3 → 0.24.5

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.
@@ -3,10 +3,11 @@
3
3
  * screen (the TV view) and the watch page. It speaks on the page's own watch socket, so every game has it, old builds
4
4
  * too, with no game code:
5
5
  *
6
- * play a Chat pill beside the room button (the corner game.json screen.share keeps clear of the game's HUD),
7
- * its sheet (the room's last minutes, the room's emoji, its quick lines, typing where the rules allow,
8
- * Report on a line, and for the studio's owner Remove, Mute and Kick), a short ticker of new lines where the
9
- * status chip sits, and reactions floating up the screen
6
+ * play a Chat pill beside the room button (the corner game.json screen.share keeps clear of the game's HUD; the
7
+ * round icon on a phone, as the room button is), its sheet (the room's last minutes, the room's emoji, its
8
+ * quick lines, typing where the rules allow, Report on a line, and for the studio's owner Remove, Mute and
9
+ * Kick), a short strip of new lines where the status chip sits or where game.json screen.chat puts it
10
+ * (chatPlaces; "sheet-only" keeps them in the sheet), and reactions floating up the screen
10
11
  * tv the room's lines in a corner (no typing: a television is read across a room) and the float
11
12
  * watch a Chat button in the watch page's band, its panel over the stage, and the float
12
13
  *
@@ -22,17 +23,86 @@ import { REACTIONS } from './chat.mjs';
22
23
  import { REPORT_REASONS, REPORT_WORDS } from './chat-store.mjs';
23
24
  import { NET_PALETTE } from './room.mjs';
24
25
 
26
+ /** Where room chat's strip of new lines may sit: a corner, or the middle of the top or bottom edge. */
27
+ export const CHAT_PLACES = ['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right'];
28
+ const CHAT_CORNERS = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
29
+ const CHAT_DEVICES = ['desk', 'phone', 'sideways', 'tv'];
30
+ const CHAT_FIELDS = ['at', 'x', 'y', 'lines'];
31
+
32
+ /** One entry of game.json `screen.chat` as written (a place, false, or an object), keeping only the fields that are right. */
33
+ function chatEntry(v) {
34
+ if (v === false) return { lines: 'sheet-only' };
35
+ if (typeof v === 'string') return CHAT_PLACES.includes(v) ? { at: v } : {};
36
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return {};
37
+ const o = {};
38
+ if (CHAT_PLACES.includes(v.at)) o.at = v.at;
39
+ for (const k of ['x', 'y']) if (v[k] !== undefined && v[k] !== null && v[k] !== '' && Number.isFinite(Number(v[k]))) o[k] = Math.round(Number(v[k]));
40
+ if (v.lines === 'strip' || v.lines === 'sheet-only') o.lines = v.lines;
41
+ return o;
42
+ }
43
+
44
+ /**
45
+ * Where room chat sits on each screen, from game.json `screen.chat` (0.24.5), so a game with a busy HUD keeps its chat
46
+ * off its clock, its title and its controls. The Chat pill itself sits in the room button's band (`screen.share`
47
+ * places both); this places the strip of new lines that shows for a moment, and the big screen's corner of lines:
48
+ *
49
+ * "chat": "bottom-right" every device's strip there (and the big screen's corner)
50
+ * "chat": false no strip: new lines wait in the sheet, the pill counts them
51
+ * "chat": { "at": "top-left", "y": 120 } moved 120 px in from its edge (x: from its side)
52
+ * "chat": { "lines": "strip", "phone": { "lines": "sheet-only" }, "tv": { "at": "top-right" } }
53
+ *
54
+ * `at` is a place from CHAT_PLACES (default: the bottom left, or the bottom right when the room button is there); `x`
55
+ * and `y` move it in, 0 to 600 px (in the middle of an edge, `x` moves it either way); `lines` is "strip" (the default)
56
+ * or "sheet-only". `desk`, `phone` (held upright), `sideways` (else as `phone`) and `tv` (the big screen) each change
57
+ * only what they name. The big screen has no sheet: it takes a corner only, its place follows the top level's when that
58
+ * is a corner, and its lines stay unless its own entry says "sheet-only" (then it shows only the float).
59
+ */
60
+ export function chatPlaces(value) {
61
+ const top = chatEntry(value);
62
+ const per = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
63
+ const done = (o) => {
64
+ const at = o.at ?? null;
65
+ const clamp = (n, lo) => Math.max(lo, Math.min(600, n ?? 0));
66
+ return { at, x: clamp(o.x, at && at.endsWith('center') ? -600 : 0), y: clamp(o.y, 0), lines: o.lines ?? 'strip' };
67
+ };
68
+ const phone = { ...top, ...chatEntry(per.phone) };
69
+ const { lines: _l, ...where } = top;
70
+ const own = chatEntry(per.tv);
71
+ if (own.at && !CHAT_CORNERS.includes(own.at)) delete own.at;
72
+ const tv = { ...(CHAT_CORNERS.includes(where.at) ? where : {}), ...own };
73
+ return { desk: done({ ...top, ...chatEntry(per.desk) }), phone: done(phone), sideways: done({ ...phone, ...chatEntry(per.sideways) }), tv: done(tv) };
74
+ }
75
+
76
+ /** game.json `screen.chat` as the build reads it: the problems in words (the page uses the default for anything wrong). */
77
+ export function screenChatProblems(value) {
78
+ if (value === undefined || value === null || value === false) return [];
79
+ const out = [];
80
+ const one = (v, name, top) => {
81
+ if (v === false) return;
82
+ if (typeof v === 'string') { if (!CHAT_PLACES.includes(v)) out.push(`${name} "${v}" is not a place (${CHAT_PLACES.join(', ')})`); return; }
83
+ if (!v || typeof v !== 'object' || Array.isArray(v)) { out.push(`${name} is a place, false, or { "at", "x", "y", "lines" }`); return; }
84
+ for (const k of Object.keys(v)) {
85
+ if (top && CHAT_DEVICES.includes(k)) continue;
86
+ if (!CHAT_FIELDS.includes(k)) out.push(`${name}.${k} is not a field (${k === 'corner' || k === 'place' ? 'the place is "at"' : `${CHAT_FIELDS.join(', ')}${top ? `, or ${CHAT_DEVICES.join(', ')}` : ''}`})`);
87
+ }
88
+ if (v.at !== undefined && !CHAT_PLACES.includes(v.at)) out.push(`${name}.at "${v.at}" is not a place (${CHAT_PLACES.join(', ')})`);
89
+ if (name.endsWith('.tv') && v.at !== undefined && !CHAT_CORNERS.includes(v.at)) out.push(`${name}.at: the big screen takes a corner`);
90
+ for (const k of ['x', 'y']) if (v[k] !== undefined && !Number.isFinite(Number(v[k]))) out.push(`${name}.${k} is a number of pixels`);
91
+ if (v.lines !== undefined && v.lines !== 'strip' && v.lines !== 'sheet-only') out.push(`${name}.lines is "strip" or "sheet-only"`);
92
+ };
93
+ one(value, 'screen.chat', true);
94
+ if (value && typeof value === 'object' && !Array.isArray(value)) for (const d of CHAT_DEVICES) if (value[d] !== undefined) one(value[d], `screen.chat.${d}`, false);
95
+ return out.slice(0, 8);
96
+ }
97
+
25
98
  /** What a page's chat starts from (`window.__HOMIE_CHAT`): its surface, the game, and who this browser is to the studio. */
26
- export function chatBoot(g, { surface = 'play', owner = false, acct = false, member = false, room = null, place = null } = {}) {
27
- const screen = g?.screen?.chat;
28
- const ticker = screen === false ? false : typeof screen === 'string' ? screen : screen && typeof screen === 'object' && typeof screen.at === 'string' ? screen.at : null;
99
+ export function chatBoot(g, { surface = 'play', owner = false, acct = false, member = false, room = null } = {}) {
29
100
  return {
30
101
  surface, game: g.id, name: g.name, owner: Boolean(owner), acct: Boolean(acct), member: Boolean(member), palette: NET_PALETTE,
31
102
  reactions: REACTIONS, reasons: REPORT_REASONS.map((k) => ({ k, text: REPORT_WORDS[k] })),
32
103
  ...(room ? { room } : {}),
33
- // game.json "screen": { "chat": "bottom-right" | false }: where new lines show for a moment (false: only the pill's count).
34
- ...(ticker !== null ? { ticker } : {}),
35
- ...(place ? { place } : {}),
104
+ // game.json "screen": { "chat": … }: where the strip of new lines sits on each device, or "sheet-only" (chatPlaces).
105
+ places: chatPlaces(g?.screen?.chat),
36
106
  account: '/account/',
37
107
  };
38
108
  }
@@ -42,7 +112,11 @@ export const CHAT_CSS = `
42
112
  .cpill .cico { width: 15px; height: 15px; flex: none; }
43
113
  .cpill .cbadge { min-width: 16px; height: 16px; padding: 0 4px; box-sizing: border-box; border-radius: 8px; background: var(--hot); color: #0b0b10; font: 800 10px/16px ui-sans-serif, system-ui, sans-serif; text-align: center; }
44
114
  .cpill.dim .cbadge { display: inline-block; }
115
+ /* The pill as the room button's round icon (on a phone, beside a room button kept an icon, or where its word would reach
116
+ the game's clock or title): its word goes, and the count rides its corner. */
117
+ .cpill.icon-only, .cpill.icon-only:hover, .cpill.icon-only:focus-visible, .cpill.icon-only[aria-expanded="true"] { position: relative; width: 34px; padding: 0; justify-content: center; }
45
118
  .cpill.icon-only span.cword { display: none; }
119
+ .cpill.icon-only .cbadge { position: absolute; top: -5px; right: -5px; min-width: 16px; box-shadow: 0 0 0 2px rgba(6,9,16,.85); }
46
120
  .csheet { display: flex; flex-direction: column; gap: 8px; width: min(340px, calc(100vw - 16px)); }
47
121
  .chead { display: flex; align-items: center; gap: 8px; margin: 0 2px; }
48
122
  .chead b { font: 800 15px/1.2 ui-sans-serif, system-ui, sans-serif; letter-spacing: -.01em; }
@@ -88,24 +162,25 @@ export const CHAT_CSS = `
88
162
  .creport { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin: 6px 0 2px; }
89
163
  .creport p { grid-column: 1 / -1; margin: 0; color: #c3cad9; font: 600 12.5px/1.35 ui-sans-serif, system-ui, sans-serif; }
90
164
  .creport button { min-height: 32px; padding: 0 8px; border-radius: 9px; border: 1px solid rgba(255,255,255,.16); background: transparent; color: inherit; font: 700 12px/1.2 ui-sans-serif, system-ui, sans-serif; cursor: pointer; text-align: left; }
91
- .ctick { position: fixed; z-index: 5; display: flex; flex-direction: column; gap: 4px; max-width: min(320px, calc(100vw - 24px)); pointer-events: none; }
92
- .ctick.at-bottom-left { left: max(10px, env(safe-area-inset-left)); bottom: calc(max(10px, env(safe-area-inset-bottom)) + 36px); align-items: flex-start; }
93
- .ctick.at-bottom-right { right: max(10px, env(safe-area-inset-right)); bottom: calc(max(10px, env(safe-area-inset-bottom)) + 36px); align-items: flex-end; }
94
- .ctick.at-bottom-center { left: 50%; transform: translateX(-50%); bottom: calc(max(10px, env(safe-area-inset-bottom)) + 36px); align-items: center; }
95
- .ctick.at-top-left { left: max(10px, env(safe-area-inset-left)); top: calc(max(10px, env(safe-area-inset-top)) + 48px); align-items: flex-start; flex-direction: column-reverse; }
96
- .ctick.at-top-right { right: max(10px, env(safe-area-inset-right)); top: calc(max(10px, env(safe-area-inset-top)) + 48px); align-items: flex-end; flex-direction: column-reverse; }
97
- .ctick.at-top-center { left: 50%; transform: translateX(-50%); top: calc(max(10px, env(safe-area-inset-top)) + 48px); align-items: center; flex-direction: column-reverse; }
165
+ /* The strip of new lines (game.json screen.chat: its place, moved in by --chat-x / --chat-y). */
166
+ .ctick { --chat-x: 0px; --chat-y: 0px; position: fixed; z-index: 5; display: flex; flex-direction: column; gap: 4px; max-width: min(320px, calc(100vw - 24px)); pointer-events: none; }
167
+ .ctick.at-bottom-left { left: calc(max(10px, env(safe-area-inset-left)) + var(--chat-x)); bottom: calc(max(10px, env(safe-area-inset-bottom)) + 36px + var(--chat-y)); align-items: flex-start; }
168
+ .ctick.at-bottom-right { right: calc(max(10px, env(safe-area-inset-right)) + var(--chat-x)); bottom: calc(max(10px, env(safe-area-inset-bottom)) + 36px + var(--chat-y)); align-items: flex-end; }
169
+ .ctick.at-bottom-center { left: calc(50% + var(--chat-x)); transform: translateX(-50%); bottom: calc(max(10px, env(safe-area-inset-bottom)) + 36px + var(--chat-y)); align-items: center; }
170
+ .ctick.at-top-left { left: calc(max(10px, env(safe-area-inset-left)) + var(--chat-x)); top: calc(max(10px, env(safe-area-inset-top)) + 48px + var(--chat-y)); align-items: flex-start; flex-direction: column-reverse; }
171
+ .ctick.at-top-right { right: calc(max(10px, env(safe-area-inset-right)) + var(--chat-x)); top: calc(max(10px, env(safe-area-inset-top)) + 48px + var(--chat-y)); align-items: flex-end; flex-direction: column-reverse; }
172
+ .ctick.at-top-center { left: calc(50% + var(--chat-x)); transform: translateX(-50%); top: calc(max(10px, env(safe-area-inset-top)) + 48px + var(--chat-y)); align-items: center; flex-direction: column-reverse; }
98
173
  .ctick div { padding: 5px 10px; border-radius: 12px; background: rgba(6,9,16,.66); color: #eef1f8; font: 600 13px/1.3 ui-sans-serif, system-ui, -apple-system, sans-serif; overflow-wrap: anywhere; backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); animation: ctin .2s ease-out; transition: opacity .5s; }
99
174
  .ctick div b { font-weight: 800; margin-right: 6px; }
100
175
  .ctick div.cout { opacity: 0; }
101
176
  @keyframes ctin { from { opacity: 0; transform: translateY(6px); } }
102
177
  .cfloat { position: fixed; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none; z-index: 4; }
103
- .ctv { position: fixed; z-index: 6; box-sizing: border-box; width: min(26rem, 30vw); max-height: 42vh; display: flex; flex-direction: column; justify-content: flex-end; gap: .3em; padding: .55em .8em; border-radius: 16px; background: rgba(8,12,22,.7); border: 1px solid rgba(255,255,255,.12); color: #eef1f8; font: 600 clamp(14px, 1.6vmin, 24px)/1.35 ui-sans-serif, system-ui, -apple-system, sans-serif; pointer-events: none; overflow: hidden; }
178
+ .ctv { --chat-x: 0px; --chat-y: 0px; position: fixed; z-index: 6; box-sizing: border-box; width: min(26rem, 30vw); max-height: 42vh; display: flex; flex-direction: column; justify-content: flex-end; gap: .3em; padding: .55em .8em; border-radius: 16px; background: rgba(8,12,22,.7); border: 1px solid rgba(255,255,255,.12); color: #eef1f8; font: 600 clamp(14px, 1.6vmin, 24px)/1.35 ui-sans-serif, system-ui, -apple-system, sans-serif; pointer-events: none; overflow: hidden; }
104
179
  .ctv:empty { display: none; }
105
- .ctv.at-bottom-left { left: max(16px, env(safe-area-inset-left)); bottom: max(16px, env(safe-area-inset-bottom)); }
106
- .ctv.at-bottom-right { right: max(16px, env(safe-area-inset-right)); bottom: max(16px, env(safe-area-inset-bottom)); }
107
- .ctv.at-top-left { left: max(16px, env(safe-area-inset-left)); top: max(16px, env(safe-area-inset-top)); justify-content: flex-start; }
108
- .ctv.at-top-right { right: max(16px, env(safe-area-inset-right)); top: max(16px, env(safe-area-inset-top)); justify-content: flex-start; }
180
+ .ctv.at-bottom-left { left: calc(max(16px, env(safe-area-inset-left)) + var(--chat-x)); bottom: calc(max(16px, env(safe-area-inset-bottom)) + var(--chat-y)); }
181
+ .ctv.at-bottom-right { right: calc(max(16px, env(safe-area-inset-right)) + var(--chat-x)); bottom: calc(max(16px, env(safe-area-inset-bottom)) + var(--chat-y)); }
182
+ .ctv.at-top-left { left: calc(max(16px, env(safe-area-inset-left)) + var(--chat-x)); top: calc(max(16px, env(safe-area-inset-top)) + var(--chat-y)); justify-content: flex-start; }
183
+ .ctv.at-top-right { right: calc(max(16px, env(safe-area-inset-right)) + var(--chat-x)); top: calc(max(16px, env(safe-area-inset-top)) + var(--chat-y)); justify-content: flex-start; }
109
184
  .ctv div { overflow-wrap: anywhere; transition: opacity 1s; }
110
185
  .ctv div.cout { opacity: 0; }
111
186
  .ctv b { font-weight: 800; margin-right: .4em; }
@@ -221,21 +296,41 @@ export const CHAT_JS = String.raw`(function () {
221
296
  if (parts.length) raf = requestAnimationFrame(paint);
222
297
  }
223
298
 
224
- /* ------------------------------------------------------------ the ticker: new lines, for a moment, where the chip sits */
299
+ /* ------------------------------------------------------------ where chat sits on this screen (game.json screen.chat) */
300
+ // This screen as the room button knows it (desk, phone held upright, phone turned sideways), else by its size.
301
+ function device() {
302
+ if (surface === 'tv') return 'tv';
303
+ var s = window.__shell && window.__shell.share;
304
+ if (s && s.device) return s.device;
305
+ var w = window.innerWidth || 0; var h = window.innerHeight || 0;
306
+ return w && h && Math.min(w, h) <= 540 ? (w > h ? 'sideways' : 'phone') : 'desk';
307
+ }
308
+ function placeHere() { var p = cfg.places || {}; return p[device()] || p.desk || { at: null, x: 0, y: 0, lines: 'strip' }; }
309
+
310
+ /* ------------------------------------------------------------ the strip: new lines, for a moment, where the chip sits */
225
311
  var tickEl = null;
226
312
  function tickPlace() {
227
- if (cfg.ticker) return cfg.ticker;
313
+ var p = placeHere();
314
+ if (p.at) return p.at;
228
315
  var s = window.__shell && window.__shell.share;
229
316
  return s && s.at === 'bottom-left' ? 'bottom-right' : 'bottom-left';
230
317
  }
231
318
  function tick(m) {
232
- if (surface === 'tv' || cfg.ticker === false || !st.show || st.open) return;
319
+ if (surface === 'tv' || !st.show || st.open) return;
320
+ // "sheet-only": a busy HUD keeps new lines in the sheet; the pill's count says they are there.
321
+ var p = placeHere();
322
+ if (p.lines === 'sheet-only') return;
233
323
  if (!tickEl) {
234
324
  var host = (surface === 'watch' ? document.querySelector('.stage') : null) || document.body;
235
325
  if (!host) return;
236
326
  tickEl = el('div', 'ctick'); tickEl.setAttribute('aria-hidden', 'true'); host.appendChild(tickEl); if (surface === 'watch') tickEl.style.position = 'absolute';
237
327
  }
238
- tickEl.className = 'ctick at-' + tickPlace();
328
+ var at = tickPlace();
329
+ tickEl.className = 'ctick at-' + at;
330
+ // Moved in by the game's x / y, and past the room button when the strip shares its place.
331
+ var s = window.__shell && window.__shell.share; var same = surface === 'play' && s && s.at === at;
332
+ tickEl.style.setProperty('--chat-x', ((p.x || 0) + (same ? s.x || 0 : 0)) + 'px');
333
+ tickEl.style.setProperty('--chat-y', ((p.y || 0) + (same ? s.y || 0 : 0)) + 'px');
239
334
  var row = el('div');
240
335
  if (m.name) { var b = el('b', '', m.name); b.style.color = colourOf(m); row.appendChild(b); }
241
336
  row.appendChild(document.createTextNode(m.glyph && m.react ? m.glyph : (m.text || '')));
@@ -247,13 +342,16 @@ export const CHAT_JS = String.raw`(function () {
247
342
  /* ------------------------------------------------------------ the TV's corner */
248
343
  var tvEl = null;
249
344
  function tvLine(m) {
250
- if (!st.show) return;
345
+ var p = placeHere();
346
+ if (!st.show || p.lines === 'sheet-only') return;
251
347
  if (!tvEl) {
252
348
  if (!document.body) return;
253
349
  tvEl = el('div', 'ctv');
254
350
  var join = document.querySelector('[data-join]');
255
351
  var at = join && /join-bottom-left/.test(join.className) ? 'bottom-right' : join && /join-top-left|join-top-right/.test(join.className) ? 'bottom-left' : 'bottom-left';
256
- tvEl.className = 'ctv at-' + (cfg.ticker && cfg.ticker !== 'bottom-center' && cfg.ticker !== 'top-center' ? cfg.ticker : at);
352
+ tvEl.className = 'ctv at-' + (p.at || at);
353
+ tvEl.style.setProperty('--chat-x', (p.x || 0) + 'px');
354
+ tvEl.style.setProperty('--chat-y', (p.y || 0) + 'px');
257
355
  document.body.appendChild(tvEl);
258
356
  }
259
357
  var row = el('div', m.t === 'react' ? 'creact' : '');
@@ -277,6 +375,7 @@ export const CHAT_JS = String.raw`(function () {
277
375
  pill = el('button', 'pill cpill'); pill.type = 'button'; pill.setAttribute('data-chat-toggle', ''); pill.setAttribute('aria-expanded', 'false'); pill.setAttribute('aria-controls', 'chat-sheet'); pill.setAttribute('aria-label', 'Room chat');
278
376
  pill.innerHTML = ICON; pill.appendChild(el('span', 'cword', 'Chat')); badge = el('b', 'cbadge'); badge.hidden = true; pill.appendChild(badge);
279
377
  band.appendChild(pill);
378
+ watchBand(band);
280
379
  sheet = el('div', 'sheet csheet'); sheet.id = 'chat-sheet'; sheet.setAttribute('role', 'dialog'); sheet.setAttribute('aria-label', 'Room chat'); sheet.hidden = true;
281
380
  ui.appendChild(sheet);
282
381
  // One sheet at a time: the room's and the server's close this one, and this one closes theirs.
@@ -318,6 +417,52 @@ export const CHAT_JS = String.raw`(function () {
318
417
  renderFeed();
319
418
  paintRules();
320
419
  }
420
+ /*
421
+ * The pill's word, as the room button's: on a phone (either way up), or beside a room button that game.json keeps an
422
+ * icon ("label": false), the pill is the round icon from the start. Elsewhere its word shows unless, with it, the band
423
+ * would leave the screen or reach from its corner into the middle third of the screen's width (where games keep a
424
+ * clock or a title); once it has, it stays the icon until the screen changes size, so it never flickers as the room
425
+ * button's own label comes and goes.
426
+ */
427
+ var narrow = false;
428
+ function shareHere() { var s = window.__shell && window.__shell.share; return s || {}; }
429
+ function wordCollides() {
430
+ var band = pill && pill.parentNode;
431
+ var w = window.innerWidth || 0;
432
+ if (!band || !band.getBoundingClientRect || !w) return false;
433
+ var had = pill.classList.contains('icon-only');
434
+ if (had) pill.classList.remove('icon-only');
435
+ var r = band.getBoundingClientRect();
436
+ if (had) pill.classList.add('icon-only');
437
+ if (!r.width) return false;
438
+ if (r.left < 4 || r.right > w - 4) return true;
439
+ var at = shareHere().at || 'top-right';
440
+ if (/center/.test(at)) return false;
441
+ return /right/.test(at) ? r.left < w * 2 / 3 : r.right > w / 3;
442
+ }
443
+ function fitPill() {
444
+ if (!pill || surface !== 'play') return;
445
+ var s = shareHere();
446
+ if (!narrow && s.device !== 'phone' && s.device !== 'sideways' && s.label !== false) narrow = wordCollides();
447
+ var icon = s.device === 'phone' || s.device === 'sideways' || s.label === false || narrow;
448
+ if (icon) { pill.classList.add('icon-only'); pill.title = 'Room chat'; } else { pill.classList.remove('icon-only'); pill.title = ''; }
449
+ st.compact = icon;
450
+ }
451
+ var fitQueued = false;
452
+ function fitSoon() {
453
+ if (fitQueued) return;
454
+ fitQueued = true;
455
+ var go = function () { fitQueued = false; fitPill(); };
456
+ if (window.requestAnimationFrame) window.requestAnimationFrame(go); else setTimeout(go, 16);
457
+ }
458
+ function watchBand(band) {
459
+ fitPill();
460
+ // The band changes as the room code arrives, the server's pill shows and the labels fade to dots: look again.
461
+ try { if (window.ResizeObserver) new ResizeObserver(fitSoon).observe(band); } catch (e) {}
462
+ // A new size (a turned phone, a resized window): the room button moves first, then the word is measured afresh.
463
+ window.addEventListener('resize', function () { setTimeout(function () { narrow = false; fitPill(); }, 60); });
464
+ }
465
+ st.fit = fitPill;
321
466
  function openChat(on, quiet) {
322
467
  if (!sheet) return;
323
468
  st.open = Boolean(on);
package/worker/chat.mjs CHANGED
@@ -14,12 +14,15 @@
14
14
  * links, swears, and the studio's own words. It always runs, and it needs nothing.
15
15
  * review typed text the floor let through, on the studio's own Workers AI: Cloudflare's Clef decision model
16
16
  * (`@cf/cloudflare/clef-flash`, launched 2026-10-01: a probability for each answer, about 40 ms), within a
17
- * day's budget of neurons. No binding, no budget left or an error: the floor alone decides.
17
+ * day's budget of neurons. Under `homie-studio dev` with no binding, the same questions go to the person's
18
+ * own Ollama (clef-flash) when dev found it there: free. No model, no budget left or an error: the floor
19
+ * alone decides.
18
20
  *
19
21
  * Emoji and quick lines are the studio's own words: they never need a review, so they reach every screen at once.
20
22
  * NOTHING is stored here: the relay keeps a short window in memory (section 19), and a report keeps only the message.
21
23
  */
22
24
  import { WORDS_B64 } from './chat-words.mjs';
25
+ import { clefRun, localAiOf } from './brain.mjs';
23
26
 
24
27
  export const CHAT_MODES = Object.freeze(['off', 'emoji', 'lines', 'text']);
25
28
  export const CHAT_WHO = Object.freeze(['anyone', 'signed-in', 'members']);
@@ -206,7 +209,10 @@ export function chatProblems(raw) {
206
209
  const out = [];
207
210
  if (raw.mode !== undefined && !CHAT_MODES.includes(raw.mode)) out.push('chat.mode is off, emoji, lines or text');
208
211
  for (const k of ['who', 'react']) if (raw[k] !== undefined && !CHAT_WHO.includes(raw[k])) out.push(`chat.${k} is anyone, signed-in or members`);
209
- if (raw.lines !== undefined && raw.lines !== null) {
212
+ // Where chat sits on the screen is the page's (game.json "screen": { "chat" }), never the room's rules.
213
+ for (const k of ['corner', 'place', 'at']) if (raw[k] !== undefined && (k !== 'at' || typeof raw.at === 'string')) out.push(`chat.${k}: where chat sits on the screen goes in "screen": { "chat": { "at": … } }`);
214
+ if (raw.lines === 'strip' || raw.lines === 'sheet-only') out.push(`chat.lines is the quick lines ({ "id": "text" }); "${raw.lines}" goes in "screen": { "chat": { "lines": "${raw.lines}" } }`);
215
+ else if (raw.lines !== undefined && raw.lines !== null) {
210
216
  const pairs = Array.isArray(raw.lines) ? raw.lines.map((x) => [x?.id, x?.text]) : typeof raw.lines === 'object' ? Object.entries(raw.lines) : null;
211
217
  if (!pairs) out.push('chat.lines is { "id": "text" }');
212
218
  else {
@@ -387,9 +393,11 @@ const timeout = (p, ms) => Promise.race([p, new Promise((_, no) => setTimeout(()
387
393
  * (`why` is the most likely other answer). Throws when there is no binding, the model fails or it takes too long:
388
394
  * the caller lets the floor decide.
389
395
  */
390
- export async function reviewChat(env, text, { model = env?.HOMIE_CHAT_MODEL || CHAT_MODEL, ms = CHAT_LIMITS.reviewMs, threshold = REVIEW_THRESHOLD, links = 'block', now = () => Date.now() } = {}) {
391
- if (!env?.AI || typeof env.AI.run !== 'function') throw new Error('this Worker has no Workers AI binding (AI)');
392
- const spec = CHAT_MODELS[model] ?? CHAT_MODELS[CHAT_MODEL];
396
+ export async function reviewChat(env, text, { model = env?.HOMIE_CHAT_MODEL || CHAT_MODEL, ms = CHAT_LIMITS.reviewMs, threshold = REVIEW_THRESHOLD, links = 'block', now = () => Date.now(), fetch: fetchImpl = null } = {}) {
397
+ // No binding: the person's own Ollama (`homie-studio dev` found clef-flash there), with the same question. Free.
398
+ const local = env?.AI && typeof env.AI.run === 'function' ? null : localAiOf(env);
399
+ if (!local && (!env?.AI || typeof env.AI.run !== 'function')) throw new Error('this Worker has no Workers AI binding (AI)');
400
+ const spec = local ? { kind: 'clef', name: local.model, input: 0, output: 0 } : CHAT_MODELS[model] ?? CHAT_MODELS[CHAT_MODEL];
393
401
  const at = now();
394
402
  if (spec.kind === 'guard') {
395
403
  const r = await timeout(env.AI.run(model, { messages: [{ role: 'user', content: text }], temperature: 0, max_tokens: 16, response_format: { type: 'json_object' } }), ms);
@@ -402,7 +410,9 @@ export async function reviewChat(env, text, { model = env?.HOMIE_CHAT_MODEL || C
402
410
  }
403
411
  // A room that allows links says so, so a plain link is not read as spam.
404
412
  const verdict = links === 'allow' ? { ...VERDICT, criteria: { ...VERDICT.criteria, spam: 'Ads, scams or spam (a plain link is fine here)' } } : VERDICT;
405
- const r = await timeout(env.AI.run(model, { model: spec.name, state: text, questions: { verdict } }), ms);
413
+ const r = local
414
+ ? await clefRun(env, { state: text, questions: { verdict }, ms: Math.max(ms, 4000), local, fetch: fetchImpl })
415
+ : await timeout(env.AI.run(model, { model: spec.name, state: text, questions: { verdict } }), ms);
406
416
  const a = r?.answers?.verdict;
407
417
  if (!a || typeof a !== 'object') throw new Error('the review answered nothing it could use');
408
418
  const probs = a.probabilities && typeof a.probabilities === 'object' ? a.probabilities : null;
@@ -415,5 +425,5 @@ export async function reviewChat(env, text, { model = env?.HOMIE_CHAT_MODEL || C
415
425
  why = why ?? 'held';
416
426
  }
417
427
  const input = Number(r?.usage?.input_tokens ?? r?.usage?.prompt_tokens) || Math.ceil((text.length + VERDICT_CHARS) / 4);
418
- return { ok: pOk >= threshold, why, p: Math.round(pOk * 1000) / 1000, neurons: (input * spec.input) / 1e6, ms: now() - at, model };
428
+ return { ok: pOk >= threshold, why, p: Math.round(pOk * 1000) / 1000, neurons: (input * spec.input) / 1e6, ms: now() - at, model: local ? local.model : model, ...(local ? { local: true } : {}) };
419
429
  }
package/worker/index.mjs CHANGED
@@ -86,7 +86,7 @@ import {
86
86
  setMembership,
87
87
  } from './servers.mjs';
88
88
  import { BRAIN_BUDGET, HouseAgents, agentFacts, aiName, decodeFacts, encodeFacts, passById, passRefusal, sitRoute } from './agents.mjs';
89
- import { talks } from './brain.mjs';
89
+ import { CLEF, DECIDE, clefRun, costOf, localAiOf, picksOf, talks } from './brain.mjs';
90
90
  import { REACTIONS, reviewChat } from './chat.mjs';
91
91
  import { REPORT_REASONS, chatDay, chatOf, chatRowsOf, fileReport } from './chat-store.mjs';
92
92
  import { doorPage, serverPage, serversPage } from './site.mjs';
@@ -1163,16 +1163,71 @@ export class Table {
1163
1163
  this.vocabRead = this.readVocab(game).catch(() => false);
1164
1164
  // Room chat (section 19): typed text the floor let through waits for the studio's own Workers AI.
1165
1165
  this.room.review = (text, opts) => this.reviewChat(text, opts);
1166
+ // A game's own decisions (section 20): the host's typed questions, for a game whose game.json opts in.
1167
+ this.room.decider = (state, questions, opts) => this.decide(state, questions, opts);
1166
1168
  return this.room;
1167
1169
  }
1168
1170
 
1171
+ /** Whether this game asked for decisions (game.json "decide": true, in the built catalogue), read once a minute. */
1172
+ async decideOn() {
1173
+ if (this.decideRead && Date.now() - this.decideRead.at < 60_000) return this.decideRead.on;
1174
+ let on = false;
1175
+ try {
1176
+ const res = this.env.ASSETS ? await this.env.ASSETS.fetch(new Request('https://assets.local/games.json')) : null;
1177
+ const cat = res?.ok ? await res.json() : null;
1178
+ const g = (cat?.games ?? []).find((x) => x.id === this.game);
1179
+ on = Boolean(g && (g.decide === true || (g.decide && typeof g.decide === 'object' && g.decide.on !== false)));
1180
+ } catch { on = false; }
1181
+ this.decideRead = { at: Date.now(), on };
1182
+ return on;
1183
+ }
1184
+
1185
+ /**
1186
+ * One of a game's own decisions (worker/brain.mjs checkDecide, picksOf): Clef on the studio's Workers AI (model
1187
+ * HOMIE_DECIDE_MODEL, else clef-flash), or the person's own Ollama under `homie-studio dev`, within the AI brains' day
1188
+ * (the same budget the guides spend: decisions and guides are one day of neurons). Not opted in, no AI, over budget, too
1189
+ * slow or failed: `{ ok: false, why }`, and the host's floor answers.
1190
+ */
1191
+ async decide(state, questions) {
1192
+ if (!(await this.decideOn())) return { ok: false, why: 'off' };
1193
+ const local = this.env.AI ? null : localAiOf(this.env);
1194
+ if (!this.env.AI && !local) return { ok: false, why: 'no-ai' };
1195
+ if (this.env.AI) {
1196
+ if (!this.brainDay || Date.now() - this.brainReadAt > 60_000) await this.readBrainDay().catch(() => {});
1197
+ if (this.brainCap.neurons - this.brainUsed.neurons <= 0) return { ok: false, why: 'budget' };
1198
+ }
1199
+ const model = CLEF.models[this.env.HOMIE_DECIDE_MODEL] ? this.env.HOMIE_DECIDE_MODEL : '@cf/cloudflare/clef-flash';
1200
+ const t0 = Date.now();
1201
+ try {
1202
+ const r = await clefRun(this.env, { state, questions, model, ms: DECIDE.ms, local });
1203
+ const ms = Date.now() - t0;
1204
+ const usage = r.usage ?? { input_tokens: Math.ceil(JSON.stringify({ state, questions }).length / 4) + 400, output_tokens: 0 };
1205
+ const cost = costOf({ provider: r.engine === 'local' ? 'local' : 'workers-ai', model: r.model, usage });
1206
+ this.brainSpend({ neurons: cost.neurons, micros: 0, provider: r.engine === 'local' ? 'decide-local' : 'decide' });
1207
+ this.flushSoon();
1208
+ const { picks, p } = picksOf(r.answers, questions);
1209
+ return { ok: true, by: r.engine === 'local' ? 'local' : 'ai', picks, p, ms, neurons: cost.neurons };
1210
+ } catch (error) {
1211
+ return { ok: false, why: /took over/.test(String(error?.message ?? error)) ? 'slow' : 'error' };
1212
+ }
1213
+ }
1214
+
1215
+ /** The day's counters go to D1 on an alarm: set one a minute out when none is set (a house guide's alarm also flushes). */
1216
+ flushSoon() {
1217
+ this.ctx.waitUntil((async () => { if ((await this.ctx.storage.getAlarm()) === null) await this.ctx.storage.setAlarm(Date.now() + 60_000); })().catch(() => {}));
1218
+ }
1219
+
1169
1220
  /**
1170
1221
  * One typed chat message, reviewed by the studio's decision model (worker/chat.mjs reviewChat) within the day's
1171
1222
  * budget for the whole studio. No binding (`homie-studio dev` without --remote-ai), no budget left, or an error: the
1172
1223
  * floor alone has decided (`by` says which), and the message goes out.
1173
1224
  */
1174
1225
  async reviewChat(text, { links = 'block' } = {}) {
1175
- if (!this.env.AI) return { ok: true, by: 'none' };
1226
+ // Under `homie-studio dev` with no binding, the person's own Ollama reviews (free, so no budget).
1227
+ if (!this.env.AI) {
1228
+ if (!localAiOf(this.env)) return { ok: true, by: 'none' };
1229
+ try { const v = await reviewChat(this.env, text, { links }); return { ok: v.ok, by: 'local', why: v.why, ms: v.ms, p: v.p }; } catch { return { ok: true, by: 'error' }; }
1230
+ }
1176
1231
  if (this.chatCap === null || Date.now() - this.chatReadAt > 60_000) await this.readChatDay().catch(() => {});
1177
1232
  if (this.chatUsed >= (this.chatCap ?? 0)) return { ok: true, by: 'budget' };
1178
1233
  try {
package/worker/office.mjs CHANGED
@@ -56,9 +56,9 @@
56
56
  */
57
57
  import { SEAT_MAX, seatsOf } from './seats.mjs';
58
58
  import { PUBLIC_SERVER, SERVER_LIMITS, checkServer, levelName, narrows, policyOf, roomServer, rowFor, serverView, serversOf, serverPassCookie, writeServer } from './servers.mjs';
59
- import { BRAIN_BUDGET, aiName, fillSpot, passById, passCreate, passList, passRevoke } from './agents.mjs';
60
- import { DEFAULT_MODEL, OWNER_MODEL } from './brain.mjs';
61
- import { OWNER_COOKIE, cookieValues, ownerAllowed, ownerSession, today } from './stats.mjs';
59
+ import { BRAIN_BUDGET, aiName, brainFor, fillSpot, passById, passCreate, passList, passRevoke, think } from './agents.mjs';
60
+ import { DEFAULT_MODEL, NEURONS_PER_M, OWNER_MODEL, vocabularyOf } from './brain.mjs';
61
+ import { OWNER_COOKIE, cookieValues, counter, ownerAllowed, ownerSession, today } from './stats.mjs';
62
62
  import { esc, layout, notFoundPage } from './site.mjs';
63
63
  import { confirmPage, lockedPage, officePage } from './office-page.mjs';
64
64
  import { licenseOf } from './license.mjs';
@@ -473,6 +473,8 @@ async function roomRow(env, meta, room, max) {
473
473
  clients, bans: office.bans ?? [], mutes: office.mutes ?? [],
474
474
  // Room chat (0.23.0): the room's last minutes, with who sent each line (a client id and account; never an address).
475
475
  chat: office.chat ? { mode: office.chat.rules?.mode ?? null, pending: office.chat.pending ?? 0, lines: (office.chat.lines ?? []).slice(-50).map((l) => ({ id: l.id, at: l.at, t: l.t, kind: l.kind, name: oneLine(l.name, 40), seat: l.seat ?? null, by: l.by, text: l.text ?? null, glyph: l.glyph ?? null, react: l.react ?? null, say: l.say ?? null, acct: Boolean(l.acct), owner: Boolean(l.owner), client: l.client ?? null, player: l.player ?? null, browser: l.browser ?? null, ...(l.review ? { review: l.review } : {}) })) } : null,
476
+ // A game's own decisions (0.24.4, net.decide): how many, who answered, the model's median time, the neurons.
477
+ ...(office.decides && typeof office.decides === 'object' ? { decides: { n: Number(office.decides.n) || 0, by: office.decides.by ?? {}, msP50: office.decides.msP50 ?? null, neurons: Number(office.decides.neurons) || 0 } } : {}),
476
478
  // The house guides' brains (0.17.0): which brain, why not when it is not, and the last decisions (seats and ids).
477
479
  ...(f.brains && typeof f.brains === 'object' ? { brains: {
478
480
  brain: String(f.brains.brain ?? ''), why: f.brains.why ? oneLine(f.brains.why, 120) : null, calls: Number(f.brains.calls) || 0,
@@ -482,6 +484,62 @@ async function roomRow(env, meta, room, max) {
482
484
  };
483
485
  }
484
486
 
487
+ /**
488
+ * `agents try` (0.24.4): a guide's brain on one moment the owner gives, with no seat taken and nobody in a room: the
489
+ * game's own vocabulary, the view, the asks and the party's lines in; the decision out, with the brain's own pick
490
+ * before the fixed rules, Clef's probabilities, the model's time and the neurons. It runs exactly what a house guide
491
+ * runs (agents.mjs think) and spends from the same day's budget, so a creator can test an agents.json, and compare a
492
+ * model (`model`: one of the models this toolkit prices) before setting it.
493
+ */
494
+ export async function agentsTry(env, cat, body) {
495
+ const bad = (message) => ({ ok: false, error: 'bad-request', message });
496
+ const meta = (cat.games ?? []).find((g) => g.id === body.game);
497
+ if (!meta) return bad('game is one of this studio\'s game ids');
498
+ const mode = body.mode ?? 'workers-ai';
499
+ if (!['workers-ai', 'owner-key', 'script'].includes(mode)) return bad('mode is workers-ai, owner-key or script');
500
+ if (body.model !== undefined && !(typeof body.model === 'string' && NEURONS_PER_M[body.model])) return bad(`model is one of ${Object.keys(NEURONS_PER_M).join(', ')}`);
501
+ const view = body.view && typeof body.view === 'object' && !Array.isArray(body.view) ? body.view : null;
502
+ if (!view || JSON.stringify(view).length > 2048) return bad('view is the game state a guide sees (an object, under 2 KB)');
503
+ const int = (v) => Number.isInteger(v) && v >= 0 && v < 64;
504
+ const asks = (Array.isArray(body.asks) ? body.asks : []).slice(0, 3).filter((a) => a && typeof a.k === 'string' && int(a.from)).map((a, i) => ({ k: a.k, args: a.args && typeof a.args === 'object' ? a.args : {}, from: a.from, at: Date.now() - i }));
505
+ const party = (Array.isArray(body.party) ? body.party : []).slice(-3).filter((p) => p && int(p.seat) && typeof p.line === 'string').map((p) => ({ seat: p.seat, line: p.line, args: p.args ?? {} }));
506
+ const seats = (list) => (Array.isArray(list) ? list.filter(int).slice(0, 32) : []);
507
+ let vocab = null;
508
+ try {
509
+ const res = env.ASSETS ? await env.ASSETS.fetch(new Request(`https://assets.local/games/${meta.id}/agents.json`)) : null;
510
+ if (res?.ok) vocab = vocabularyOf(await res.json()).vocab;
511
+ } catch { vocab = null; }
512
+ if (!vocab) return { ok: false, error: 'no-vocabulary', message: `${meta.name} has no agents.json in this build: its guides have nothing to choose from.` };
513
+ const day = await brainDay(env);
514
+ const budget = { left: (k) => (k === 'neurons' ? day.budget.neurons - day.used.neurons : day.budget.usd * 1e6 - day.used.usd * 1e6) };
515
+ const e = body.model ? { ...env, HOMIE_BRAIN_MODEL: body.model } : env;
516
+ const brain = brainFor(mode, e, { budget });
517
+ const ctx = {
518
+ view, me: { seat: int(body.seat) ? body.seat : 7 }, goal: view.goal ?? null, asks, party, speech: ['game', 'lines', 'off'].includes(body.speech) ? body.speech : 'lines',
519
+ kids: body.kids === true, quiet: body.quiet === true, players: seats(body.players ?? (Array.isArray(view.party) ? view.party.map((p) => p?.seat) : [])), avoid: seats(body.avoid),
520
+ // What a house guide remembers: who is new to it, and its own last lines (ids, arguments, how long ago).
521
+ newHere: seats(body.newHere),
522
+ said: (Array.isArray(body.said) ? body.said : []).slice(-4).filter((x) => x && typeof x.line === 'string' && vocab.lines[x.line]).map((x) => ({ line: x.line, args: x.args && typeof x.args === 'object' ? x.args : {}, ago: Math.max(0, Number(x.agoMs) || 0) })),
523
+ };
524
+ const t = await think(vocab, { mode, brain, ctx });
525
+ if (t.engine && env.DB) {
526
+ const rows = [counter(env, { metric: 'brain-calls', subject: meta.id, source: 'try' })];
527
+ const n = Math.round(t.cost.neurons);
528
+ if (n > 0) rows.push(counter(env, { metric: 'brain-neurons', subject: meta.id, n }));
529
+ if (t.cost.micros >= 1) rows.push(counter(env, { metric: 'brain-microdollars', subject: meta.id, n: Math.round(t.cost.micros) }));
530
+ await env.DB.batch(rows.filter(Boolean)).catch(() => {});
531
+ }
532
+ return {
533
+ ok: true, game: meta.id, mode, model: t.model ?? (mode === 'workers-ai' ? e.HOMIE_BRAIN_MODEL || DEFAULT_MODEL : null), engine: t.engine, provider: t.provider,
534
+ decision: t.decision, own: t.own, overruled: /\+floor$/.test(t.provider), why: t.why, p: t.p, ms: t.ms,
535
+ // A decision model's whole answer: every option's probability, per question (ids and numbers only).
536
+ ...(t.answers ? { answers: Object.fromEntries(Object.entries(t.answers).map(([k, a]) => [k, a?.probabilities ? Object.fromEntries(Object.entries(a.probabilities).map(([o, v]) => [o, Math.round(Number(v) * 100) / 100])) : a?.noul ?? a?.score ?? null])) } : {}),
537
+ ...(t.usage ? { tokens: { input: Number(t.usage.input_tokens ?? t.usage.prompt_tokens) || null, output: Number(t.usage.output_tokens ?? t.usage.completion_tokens) || 0 } } : {}),
538
+ neurons: Math.round(t.cost.neurons * 1000) / 1000, micros: Math.round(t.cost.micros),
539
+ budget: { neurons: day.budget.neurons, usedBefore: Math.round(day.used.neurons) },
540
+ };
541
+ }
542
+
485
543
  /** The AI brains' day for the whole studio: the budget (meta brain_budget) and what today used (stats_daily). */
486
544
  export async function brainDay(env) {
487
545
  const budget = { neurons: BRAIN_BUDGET.neurons, usd: BRAIN_BUDGET.usd };
@@ -1163,6 +1221,8 @@ async function api(request, env, url, cat) {
1163
1221
  return json({ ok: true, game, server, members });
1164
1222
  } catch (error) { return json({ ok: false, error: 'not-migrated', message: `Servers need migration 0006_studio_servers.sql (npm run deploy). (${String(error?.message ?? error).slice(0, 120)})` }, 503); }
1165
1223
  }
1224
+ // A guide's brain on a moment the owner gives (0.24.4): what it would decide, why, how sure, how long and what it cost.
1225
+ if (path === '/_studio/api/agents/try' && request.method === 'POST') return json(await agentsTry(env, cat, body));
1166
1226
  // Room chat (0.23.0): every game's rules, every live room's last minutes, the reports, the review's day.
1167
1227
  if (path === '/_studio/api/chat' && request.method === 'GET') {
1168
1228
  const metas = (cat.games ?? []).filter((g) => !url.searchParams.get('game') || g.id === url.searchParams.get('game'));