@homie-rocks/studio 0.27.0 → 0.29.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.
@@ -163,6 +163,13 @@ button.small { min-height: 30px; padding: 4px 10px; font-size: 13px; border-radi
163
163
  .newsrv .frow > label, .newsrv .frow > .lbl { padding-top: 0; }
164
164
  .policies { grid-template-columns: 1fr; }
165
165
  }
166
+ /* The Lounge (0.29.0): its rules, play nights, moderators and last lines. */
167
+ .lounge { margin: 18px 0 6px; padding: 16px; border-radius: 16px; background: var(--panel); border: 1px solid var(--line); }
168
+ .lounge .lhead { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 10px; }
169
+ .lounge .lset { display: flex; flex-wrap: wrap; gap: 10px 18px; align-items: center; margin-top: 12px; font-size: 14px; }
170
+ .lounge .lset label { display: inline-flex; gap: 8px; align-items: center; }
171
+ .lounge .nightf { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 8px; }
172
+ .lounge .nightf input[type=text] { min-width: 220px; }
166
173
  @media (max-width: 720px) { .srvhead { grid-template-columns: 1fr 1fr; } }
167
174
  @media (max-width: 720px) {
168
175
  .rhead { grid-template-columns: 1fr 1fr 1fr; }
@@ -763,9 +770,100 @@ export const OFFICE_SCRIPT = String.raw`(function () {
763
770
  return box;
764
771
  }
765
772
 
773
+ /* ---------------------------------------------------------------- the Lounge (0.29.0, chat/LOUNGE.md) */
774
+ var loungeEl = document.getElementById('lounge');
775
+ var KEEP_DAYS = [[0, 'Nothing past a few minutes'], [1, '1 day'], [7, '7 days'], [14, '14 days'], [30, '30 days'], [90, '90 days']];
776
+ function loungeBox(L, now) {
777
+ var box = el('section', 'lounge'); box.id = 'lounge-box';
778
+ var r = L.rules || {};
779
+ add(box, add(el('div', 'lhead'), add(el('div'), el('h2', '', L.name), el('span', 'faint', (L.here ? L.here + ' here now · ' : 'quiet · ') + (r.mode === 'text' ? 'typing: ' + r.who : r.mode) + (r.slow ? ' · slow ' + r.slow + ' s' : '') + ' · ' + (r.history ? 'kept ' + r.history + (r.history === 1 ? ' day' : ' days') : 'nothing kept') + (r.hub ? ' · shown on homie.rocks' : '') + (L.kids ? ' · kids' : '') + ' · rules from ' + (L.from === 'office' ? 'you' : 'the defaults'))),
780
+ add(el('div', 'links'), Object.assign(el('a', '', 'Open the Lounge'), { href: L.page, target: '_blank', rel: 'noopener' }))));
781
+ var keep = sel(KEEP_DAYS, r.history || 0, 'Keep what is said');
782
+ keep.disabled = Boolean(L.kids);
783
+ keep.onchange = function () {
784
+ var n = Number(keep.value);
785
+ if (n < (r.history || 0) && !confirm(n ? 'Keep only ' + n + ' days? Anything older is deleted now.' : 'Keep nothing past a few minutes? Everything the Lounge kept is deleted now.')) { keep.value = String(r.history || 0); return; }
786
+ act('/_studio/api/lounge/rules', { history: n }, n ? 'The Lounge keeps what is said for ' + n + (n === 1 ? ' day.' : ' days.') : 'The Lounge keeps nothing now.');
787
+ };
788
+ var slow = sel([[0, 'Off'], [3, '3 s'], [10, '10 s'], [30, '30 s'], [120, '2 min']], r.slow || 0, 'Slow mode');
789
+ slow.onchange = function () { act('/_studio/api/lounge/rules', { slow: Number(slow.value) }, 'Slow mode set.'); };
790
+ var who = sel([['anyone', 'Anyone'], ['signed-in', 'Signed in']], r.who === 'anyone' ? 'anyone' : 'signed-in', 'Who may type');
791
+ who.onchange = function () { act('/_studio/api/lounge/rules', { who: who.value }, who.value === 'anyone' ? 'Guests may type too.' : 'Typing is for signed-in people.'); };
792
+ var hub = el('input'); hub.type = 'checkbox'; hub.checked = Boolean(r.hub);
793
+ hub.onchange = function () { act('/_studio/api/lounge/rules', { hub: hub.checked }, hub.checked ? 'homie.rocks shows the Lounge live.' : 'The Lounge stays on this site.'); };
794
+ add(box, add(el('div', 'lset'),
795
+ add(el('label'), el('span', 'dim', 'Keep what is said'), keep),
796
+ add(el('label'), el('span', 'dim', 'Slow mode'), slow),
797
+ add(el('label'), el('span', 'dim', 'Typing'), who),
798
+ add(el('label', 'check'), hub, el('span', '', 'Show it live on homie.rocks'))));
799
+ if (L.kids) box.appendChild(el('p', 'faint', 'A kids Lounge keeps chat to emoji and quick lines and keeps nothing.'));
800
+ // Play nights: the owner's own time in, everyone's own time out.
801
+ add(box, el('h3', '', 'Play nights'));
802
+ (L.nights || []).forEach(function (n) {
803
+ var rowEl = el('div', 'inv');
804
+ add(rowEl, el('b', '', n.title), el('span', '', new Date(n.at).toLocaleString(undefined, { weekday: 'short', day: 'numeric', month: 'short', hour: 'numeric', minute: '2-digit' })), el('span', 'faint', n.minutes + ' min' + (n.on ? ' · on now' : '') + (n.game && n.game.name ? ' · ' + n.game.name : '')),
805
+ armed('Remove', 'bad', function () { act('/_studio/api/lounge/night', { remove: n.id }, 'Play night removed.'); }));
806
+ box.appendChild(rowEl);
807
+ });
808
+ var key = 'lounge/night';
809
+ var dn = S.drafts[key] || (S.drafts[key] = {});
810
+ var title = el('input'); title.type = 'text'; title.placeholder = 'What it is ("Night Rush night")'; title.maxLength = 80; title.value = dn.title || ''; title.oninput = function () { dn.title = title.value; };
811
+ var when = el('input'); when.type = 'datetime-local'; when.value = dn.when || ''; when.setAttribute('aria-label', 'When (your time)'); when.oninput = function () { dn.when = when.value; };
812
+ var mins = sel([[60, '1 hour'], [90, '90 min'], [120, '2 hours'], [180, '3 hours']], dn.minutes || 120, 'How long'); mins.onchange = function () { dn.minutes = Number(mins.value); };
813
+ var game = sel([['', 'Any game']].concat((S.data && S.data.games || []).map(function (g) { return [g.id, g.name]; })), dn.game || '', 'Which game'); game.onchange = function () { dn.game = game.value; };
814
+ add(box, add(el('form', 'nightf'), title, when, mins, game, btn('Add', 'small', function () {
815
+ if (!title.value.trim() || !when.value) { toast('Give it a name and a time.', true); return; }
816
+ var at = new Date(when.value);
817
+ act('/_studio/api/lounge/night', { title: title.value, at: at.toISOString(), minutes: Number(mins.value), game: game.value || undefined }, 'Play night added: it shows in the Lounge in everyone\'s own time.').then(function (res) { if (res && res.ok) S.drafts[key] = {}; });
818
+ })));
819
+ // Moderators: accounts the owner trusts with Remove, Mute, Kick (an hour at most) and slow mode in the Lounge.
820
+ add(box, el('h3', '', 'Moderators'));
821
+ if (!(L.mods || []).length) box.appendChild(el('p', 'faint', 'None yet. Make someone a moderator from one of their lines below.'));
822
+ (L.mods || []).forEach(function (m) {
823
+ add(box, add(el('div', 'inv'), el('b', '', m.name || m.player), el('span', 'faint', 'since ' + new Date(m.addedAt).toLocaleDateString()),
824
+ armed('Remove', 'bad', function () { act('/_studio/api/lounge/mod', { player: m.player, remove: true, name: m.name }, (m.name || 'They') + ' is not a moderator now.'); })));
825
+ });
826
+ // The Lounge's last lines, with Remove, Mute, Kick, and Make moderator for a signed-in sender.
827
+ var lines = (L.lines || []).filter(function (l) { return l.kind !== 'react'; });
828
+ if (lines.length) {
829
+ var log = el('div', 'chatlog');
830
+ add(log, el('h4', '', 'Last ' + lines.length + (lines.length === 1 ? ' message' : ' messages')));
831
+ lines.slice().reverse().slice(0, 30).forEach(function (l) {
832
+ var rowEl = el('div', 'cl' + (l.by === 'studio' ? ' studio' : ''));
833
+ add(rowEl, el('b', '', l.by === 'studio' ? 'Studio' : l.name), el('span', 'txt', l.card ? 'Showed: ' + (l.card.title || '') + (l.card.studio ? ' by ' + l.card.studio : '') + (l.text && l.text.indexOf('Made: ') !== 0 ? ' · ' + l.text : '') : (l.text || '')),
834
+ el('span', 'faint', ago(now - l.at) + (l.by === 'hub' ? ' · on homie.rocks' : '') + (l.owner ? ' · you' : l.mod ? ' · mod' : l.acct ? ' · signed in' : ' · guest')));
835
+ var acts = el('span', 'pacts');
836
+ acts.appendChild(btn('Remove', 'ghost small', function () { act('/_studio/api/lounge/remove', { id: l.id }, 'Removed from every screen.'); }));
837
+ if (l.by !== 'studio' && !l.owner) {
838
+ acts.appendChild(btn('Mute', 'warn small', function () { act('/_studio/api/lounge/hold', { line: l.id, minutes: hold() }, (l.name || 'They') + ' is muted for ' + hold() + ' min; their messages are down.'); }));
839
+ acts.appendChild(armed('Kick', 'bad', function () { act('/_studio/api/lounge/hold', { line: l.id, kick: true, minutes: hold() }, (l.name || 'They') + ' is out of the Lounge for ' + hold() + ' min.'); }));
840
+ if (l.player && l.acct && !l.mod && !(L.mods || []).some(function (m) { return m.player === l.player; })) acts.appendChild(btn('Make moderator', 'ghost small', function () { if (confirm('Make ' + l.name + ' a moderator of the Lounge? They can take messages down, mute or kick someone for up to an hour, and set slow mode.')) act('/_studio/api/lounge/mod', { player: l.player, name: l.name }, l.name + ' is a moderator from their next visit.'); }));
841
+ }
842
+ rowEl.appendChild(acts);
843
+ log.appendChild(rowEl);
844
+ });
845
+ box.appendChild(log);
846
+ } else box.appendChild(el('p', 'faint', 'Nothing said in the Lounge lately.'));
847
+ var reps = L.reports || [];
848
+ if (reps.length) {
849
+ add(box, el('h3', '', 'Reports (' + reps.length + ')'));
850
+ reps.forEach(function (p) {
851
+ add(box, add(el('div', 'inv'), el('b', '', p.name || 'Someone'), el('span', '', '"' + p.text + '"'), el('span', 'chip muted', p.reasonText || p.reason), el('span', 'faint', ago(now - p.at)),
852
+ btn('Dismiss', 'ghost small', function () { act('/_studio/api/chat/report', { id: p.id }, 'Report dismissed (deleted).'); })));
853
+ });
854
+ }
855
+ return box;
856
+ }
857
+
766
858
  function render() {
767
859
  var d = S.data;
768
860
  if (!d) return;
861
+ // The Lounge, above the games (never redrawn under a typing owner or a control waiting for its second tap).
862
+ var lat = document.activeElement;
863
+ if (loungeEl && !(lat && loungeEl.contains(lat) && /INPUT|SELECT/.test(lat.tagName)) && !loungeEl.querySelector('[data-armed="1"]')) {
864
+ loungeEl.textContent = '';
865
+ if (d.lounge) loungeEl.appendChild(loungeBox(d.lounge, Date.now() - (S.skew || 0)));
866
+ }
769
867
  var now = Date.now() - (S.skew || 0);
770
868
  liveEl.textContent = d.playing ? d.playing + (d.playing === 1 ? ' playing now' : ' playing now') : 'nobody playing right now';
771
869
  document.getElementById('livedot').className = 'dot' + (d.playing ? ' on' : '');
@@ -849,6 +947,7 @@ export async function officePage(cat, headers = {}) {
849
947
  <label for="hold" style="margin-left:auto">Kick, mute and close for</label><select id="hold"><option value="5">5 min</option><option value="10" selected>10 min</option><option value="30">30 min</option><option value="60">1 hour</option><option value="1440">a day</option></select></form>
850
948
  <p class="dim" id="status" role="status"></p>
851
949
  <p class="dim" id="chatday"></p>
950
+ <div id="lounge"></div>
852
951
  <div id="games" aria-live="polite"><p class="empty">Reading the rooms…</p></div>
853
952
  <div class="toast" id="toast" role="status" hidden></div>
854
953
  <p class="note">Servers: each game's named room pools. Open: anyone, AI with a pass marked AI. Humans only: no AI at all. Hybrid: some seats in every room are AI companions; the party votes their level. Beginner: new players, AI guides, quick lines only. Changing a server reaches its live rooms at once; AI leave after the round when a server becomes humans-only.</p>
package/worker/office.mjs CHANGED
@@ -66,6 +66,7 @@ import { CHAT_MODES, CHAT_WHO, checkChatRules, publicChat } from './chat.mjs';
66
66
  import { chatDay, chatOf, chatRowsOf, clearChatRules, dismissReport, reportsOf, writeChatRules } from './chat-store.mjs';
67
67
  import { checkRefund, checkSettle, describeRefund, officeShopPage, ordersCsv, performRefund, settleReferrer, shopOffice, statementsOut, statementsSend } from './shop.mjs';
68
68
  import { orderById } from './shop-store.mjs';
69
+ import { checkLoungeAction, describeLounge, loungeNeedsAsk, loungeOffice, performLounge } from './lounge.mjs';
69
70
 
70
71
  export { OFFICE_MIGRATION, OFFICE_MIGRATION_FILE } from './office-schema.mjs';
71
72
 
@@ -630,6 +631,8 @@ export async function officeView(env, cat, origin) {
630
631
  agentsTalk: await talkConsented(env),
631
632
  brain: await brainDay(env),
632
633
  chat: await chatDay(env, today()),
634
+ // The Lounge (0.29.0): null when the studio has none.
635
+ lounge: await loungeOffice(env, cat, origin).catch(() => null),
633
636
  };
634
637
  }
635
638
  async function memberCountsOf(env, game) {
@@ -653,6 +656,9 @@ const DESTRUCTIVE = new Set(['kick', 'mute', 'close', 'game']);
653
656
 
654
657
  /** An action, checked against the catalogue: `{ ok, action }` or `{ ok: false, error, message }`. */
655
658
  function checkAction(cat, op, body) {
659
+ // The Lounge's controls (0.29.0, worker/lounge.mjs): its rules, play nights, moderators, its lines and their senders.
660
+ const lounge = checkLoungeAction(cat, op, body);
661
+ if (lounge) return lounge;
656
662
  const meta = (cat.games ?? []).find((g) => g.id === body.game);
657
663
  const room = typeof body.room === 'string' && ROOM_ID.test(body.room) ? body.room : null;
658
664
  const minutes = Math.max(1, Math.min(24 * 60, Math.floor(Number(body.minutes) || 10)));
@@ -788,6 +794,8 @@ export function chatOpensUp(before, f) {
788
794
  for (const k of ['who', 'react']) if (f[k] !== undefined && who[f[k]] > who[before[k]]) return true;
789
795
  if (f.slow !== undefined && f.slow < before.slow) return true;
790
796
  if (f.max !== undefined && f.max > before.max) return true;
797
+ // Keeping what people say for longer (history, 0.29.0) is the owner's to confirm; keeping it for less is not.
798
+ if (f.history !== undefined && f.history > (before.history ?? 0)) return true;
791
799
  if (f.links === 'allow' && before.links !== 'allow') return true;
792
800
  if (f.swears === 'allow' && before.swears !== 'allow') return true;
793
801
  for (const k of ['watchers', 'hub']) if (f[k] === true && !before[k]) return true;
@@ -800,6 +808,7 @@ export function chatOpensUp(before, f) {
800
808
 
801
809
  /** The action in plain words, for the owner's confirm page and the AI's card. */
802
810
  export function describe(cat, a) {
811
+ if (String(a.op).startsWith('lounge-')) return describeLounge(cat, a);
803
812
  const g = (cat.games ?? []).find((x) => x.id === a.game);
804
813
  const gname = g?.name ?? a.game;
805
814
  const where = a.room ? `${roomLabel(a.room)} of ${gname}` : gname;
@@ -867,6 +876,7 @@ export function describe(cat, a) {
867
876
 
868
877
  /** Whether an office key (the owner's AI) only ASKS for this (DESIGN D13): what takes something away, or consent. */
869
878
  export async function needsAsk(env, cat, a) {
879
+ if (String(a.op).startsWith('lounge-')) return loungeNeedsAsk(env, cat, a, chatOpensUp);
870
880
  if (DESTRUCTIVE.has(a.op)) return true;
871
881
  // Money: the owner's AI may only propose a refund or a settlement; the owner says yes.
872
882
  if (a.op === 'refund' || a.op === 'shop-settle') return true;
@@ -910,6 +920,7 @@ async function pushPolicy(env, meta, settings, server) {
910
920
 
911
921
  /** Do it: the owner's own session did, or the owner confirmed what the AI asked. */
912
922
  export async function perform(env, cat, a) {
923
+ if (String(a.op).startsWith('lounge-')) return performLounge(env, cat, a, a.origin ?? '');
913
924
  const settings = await settingsOf(env, { fresh: true });
914
925
  const meta = (cat.games ?? []).find((g) => g.id === a.game) ?? null;
915
926
  switch (a.op) {
@@ -1229,6 +1240,11 @@ async function api(request, env, url, cat) {
1229
1240
  const view = await officeView(env, { ...cat, games: metas }, url.origin);
1230
1241
  return json({ ok: true, day: view.chat, games: view.games.map((g) => ({ id: g.id, name: g.name, chat: g.chat, rooms: g.rooms.map((r) => ({ room: r.room, label: r.label, players: r.players, chat: r.chat })) })) });
1231
1242
  }
1243
+ // The Lounge (0.29.0): its rules, play nights, moderators, last lines and reports.
1244
+ if (path === '/_studio/api/lounge' && request.method === 'GET') {
1245
+ const lounge = await loungeOffice(env, cat, url.origin);
1246
+ return lounge ? json({ ok: true, lounge }) : json({ ok: false, error: 'no-lounge', message: 'This studio has no Lounge: add "lounge": true to studio.json and deploy.' }, 404);
1247
+ }
1232
1248
  // The shop (0.24.0): the owner's view, the accountant's CSV, the referral statements.
1233
1249
  if (path === '/_studio/api/shop' && request.method === 'GET') return json(await shopOffice(env, cat, url.origin));
1234
1250
  if (path === '/_studio/api/shop/orders.csv' && request.method === 'GET') {
@@ -1244,13 +1260,15 @@ async function api(request, env, url, cat) {
1244
1260
  '/_studio/api/servers': 'server-create', '/_studio/api/servers/set': 'server-set', '/_studio/api/servers/close': 'server-close', '/_studio/api/servers/member': 'member',
1245
1261
  '/_studio/api/agents/pass': 'pass', '/_studio/api/room-level': 'room-level', '/_studio/api/agents/brain': 'agents-brain',
1246
1262
  '/_studio/api/shop/refund': 'refund', '/_studio/api/shop/settle': 'shop-settle',
1263
+ '/_studio/api/lounge/rules': 'lounge-rules', '/_studio/api/lounge/night': 'lounge-night', '/_studio/api/lounge/mod': 'lounge-mod',
1264
+ '/_studio/api/lounge/remove': 'lounge-remove', '/_studio/api/lounge/hold': 'lounge-hold',
1247
1265
  };
1248
1266
  const op = OPS[path];
1249
1267
  if (!op || request.method !== 'POST') return json({ ok: false, error: 'not-found' }, 404);
1250
1268
  const checked = checkAction(cat, op, body);
1251
1269
  if (!checked.ok) return json(checked, 400);
1252
1270
  const action = checked.action;
1253
- if (op === 'server-create' || op === 'pass') action.origin = url.origin;
1271
+ if (op === 'server-create' || op === 'pass' || op === 'lounge-night') action.origin = url.origin;
1254
1272
  if (op === 'refund') {
1255
1273
  // The ask and the confirm page say what the order is (its item and price), read from the books now.
1256
1274
  const o = await orderById(env, action.order);
@@ -31,6 +31,7 @@ import { adoptStatements, bumpPlayerStats, dropPlayerData, fall, fallenList, get
31
31
  import { bytesToB64url, randomToken, sha256Hex, verifyAssertion, verifyRegistration } from './webauthn.mjs';
32
32
  import { ACCOUNT_JS, accountPage } from './account-page.mjs';
33
33
  import { forgetPlayerShop, livePurchases, shopDataOf } from './shop-store.mjs';
34
+ import { forgetPlayerHistory, playerHistory } from './lounge-store.mjs';
34
35
 
35
36
  export const PLAYERS_MIGRATION_FILE = '0004_players.sql';
36
37
  export const PLAYERS_MIGRATION = `-- Players and cloud saves (@homie-rocks/studio 0.12.0): accounts on this studio only (saves/SAVES.md).
@@ -326,6 +327,8 @@ async function removePlayer(env, id) {
326
327
  try { await env.DB.prepare('DELETE FROM server_members WHERE player = ?1').bind(id).run(); } catch { /* not migrated */ }
327
328
  // What they bought goes with them; the orders stay, without the player (0.24.0; none before migration 0008).
328
329
  try { await env.DB.batch(forgetPlayerShop(env, id)); } catch { /* not migrated */ }
330
+ // Every line they said in a room that keeps its chat, the Lounge too (0.29.0; none before migration 0009).
331
+ await forgetPlayerHistory(env, id);
329
332
  await data.after();
330
333
  }
331
334
 
@@ -513,6 +516,8 @@ async function apiRoute(request, env, ctx, url, parts, { catalogueOf, read, f })
513
516
  games: await playerData(env, s.player.id),
514
517
  // The shop (0.24.0): their orders, what they own and the age band they gave; never a card or an email.
515
518
  ...(await shopDataOf(env, s.player.id).then((x) => (x ? { shop: x } : {}))),
519
+ // What they said in a room that keeps its chat, the Lounge too (0.29.0): each line, where and when.
520
+ ...(await playerHistory(env, s.player.id).then((x) => (x.length ? { chat: x } : {}))),
516
521
  note: 'Everything this studio keeps about this player. A passkey\'s private key never left your device; this site keeps only its public key.',
517
522
  };
518
523
  return json(body, 200, { 'content-disposition': `attachment; filename="${s.player.id}.json"` });
package/worker/room.mjs CHANGED
@@ -45,7 +45,7 @@
45
45
  */
46
46
  import { aiName, skillPreset, stripAi } from './agents.mjs';
47
47
  import { DECIDE, checkArgs, checkDecide, talks, vocabularyOf } from './brain.mjs';
48
- import { CHAT_LIMITS, CHAT_RATES, HELD_WORDS, allows, cleanText, floor, normalizeChat, publicChat } from './chat.mjs';
48
+ import { CHAT_LIMITS, CHAT_RATES, HELD_WORDS, allows, cleanText, floor, normalizeChat, publicChat, windowMsOf } from './chat.mjs';
49
49
 
50
50
  export const NET_VERSION = 1;
51
51
  /** The contract revision this relay speaks (NETPLAY.md): optional fields, frames and refusals; the wire stays `v: 1`. */
@@ -198,6 +198,13 @@ function clampPresses(p) {
198
198
  return n ? out : null;
199
199
  }
200
200
 
201
+ /** A kept line the Worker read from the room's history (its id, name and sender's account), for a mute or a kick. */
202
+ function keptRef(a) {
203
+ const k = a.kept && typeof a.kept === 'object' ? a.kept : null;
204
+ if (!k || k.id !== a.line || !k.from || typeof k.from.player !== 'string') return null;
205
+ return { id: k.id, name: oneLine(k.name, CHAT_LIMITS.name) || 'Someone', seat: null, from: { client: null, token: null, browser: null, player: k.from.player } };
206
+ }
207
+
201
208
  export class NetRoom {
202
209
  /**
203
210
  * @param {object} o
@@ -301,6 +308,13 @@ export class NetRoom {
301
308
  this.chatReacts = [];
302
309
  this.chatPending = 0;
303
310
  this.review = null;
311
+ /**
312
+ * Kept chat (0.29.0): when the room's rules keep `history` days, the Table writes what is published (`onPublished`)
313
+ * and forgets what is taken down (`onUnsaid`), and puts the kept lines back in the window (`hydrate`) when it opens.
314
+ */
315
+ this.onPublished = null;
316
+ this.onUnsaid = null;
317
+ this.hydrated = false;
304
318
  /**
305
319
  * A game's own decisions (section 20): the host's typed questions, answered by the studio's decision model (the Table
306
320
  * sets `decider`; null: the host's floor answers), at most one every 3 s and 20 a minute a room, two at a time.
@@ -353,7 +367,8 @@ export class NetRoom {
353
367
  if (held) this.sendText(conn, JSON.stringify(held));
354
368
  // Room chat (section 19): the last few minutes, so a page that just opened has the conversation (never to a page the
355
369
  // studio holds out of the room, or a room it closed).
356
- if ((!held || held.t === 'muted') && (!conn.hub || this.chatRules().hub)) this.sendText(conn, JSON.stringify({ t: 'lines', lines: this.recentChat().map((r) => this.wireLine(r)) }));
370
+ // A line of this browser's or this account's own says so (`mine`), on this page's copy only: it may take it down.
371
+ if ((!held || held.t === 'muted') && (!conn.hub || this.chatRules().hub)) this.sendText(conn, JSON.stringify({ t: 'lines', lines: this.recentChat().map((r) => (this.ownLine(r, conn) ? { ...this.wireLine(r), mine: true } : this.wireLine(r))) }));
357
372
  const rate = [];
358
373
  return {
359
374
  onClose: () => this.watchers.delete(conn),
@@ -366,6 +381,8 @@ export class NetRoom {
366
381
  try { m = JSON.parse(String(text)); } catch { return; }
367
382
  // Room chat from the shell (the play page, the big screen's page, the watch page, a homie.rocks room page).
368
383
  if (m && (m.t === 'say' || m.t === 'react')) { this.onChat({ conn }, m); return; }
384
+ // Their own message, taken down by its sender (0.29.0).
385
+ if (m && m.t === 'unsay') { const r = this.unsayOwn(conn, m.id); if (!r.ok) this.sendText(conn, JSON.stringify({ t: 'held', why: 'unknown', message: r.error === 'not-yours' ? 'Only its sender (or the studio) can take a message down.' : 'That message is gone already.' })); return; }
369
386
  if (!m || m.t !== 'vote' || !conn.browser) return;
370
387
  const c = this.live().find((o) => o.browser === conn.browser && o.seat !== null && !o.agent && !o.watch);
371
388
  if (c) this.onVote(c, m, conn);
@@ -962,14 +979,28 @@ export class NetRoom {
962
979
 
963
980
  /** The window a page that just opened gets: the last `keep` messages of the last `keepMs`. */
964
981
  recentChat(now = this.now()) {
965
- return this.chatLog.filter((r) => now - r.at <= CHAT_LIMITS.keepMs).slice(-CHAT_LIMITS.keep);
982
+ const keep = windowMsOf(this.chatRules());
983
+ return this.chatLog.filter((r) => now - r.at <= keep).slice(-CHAT_LIMITS.keep);
984
+ }
985
+
986
+ /**
987
+ * The lines a room kept (its `history`, read back by the Table): into the window under what was said since, oldest
988
+ * first, never twice. A line taken down meanwhile is not in what the Table read.
989
+ */
990
+ hydrate(recs) {
991
+ const have = new Set(this.chatLog.map((r) => r.id));
992
+ const old = (Array.isArray(recs) ? recs : []).filter((r) => r && typeof r.id === 'string' && !have.has(r.id));
993
+ this.chatLog = [...old, ...this.chatLog].sort((a, b) => a.at - b.at).slice(-CHAT_LIMITS.keep);
994
+ this.hydrated = true;
995
+ return old.length;
966
996
  }
967
997
 
968
998
  /** A line or a reaction as every screen gets it (homie.rocks's room chat shapes: `line` and `react`). */
969
999
  wireLine(r) {
970
- const base = { id: r.id, at: r.at, name: r.name, seat: r.seat, colour: r.colour, by: r.by, ...(r.bubble ? { bubble: true } : {}), ...(r.acct ? { acct: true } : {}), ...(r.owner ? { owner: true } : {}) };
1000
+ const base = { id: r.id, at: r.at, name: r.name, seat: r.seat, colour: r.colour, by: r.by, ...(r.bubble ? { bubble: true } : {}), ...(r.acct ? { acct: true } : {}), ...(r.owner ? { owner: true } : {}), ...(r.mod ? { mod: true } : {}), ...(r.kept ? { kept: true } : {}) };
971
1001
  if (r.kind === 'react') return { t: 'react', ...base, react: r.react, glyph: r.glyph };
972
- return { t: 'line', ...base, text: r.text, ...(r.say ? { say: r.say } : {}) };
1002
+ // A show-what-you-made card (0.29.0): a page that knows cards draws one; any other shows its words.
1003
+ return { t: 'line', ...base, text: r.text, ...(r.say ? { say: r.say } : {}), ...(r.kind === 'card' && r.card ? { card: r.card } : {}) };
973
1004
  }
974
1005
 
975
1006
  watchIdOf(conn) {
@@ -993,10 +1024,13 @@ export class NetRoom {
993
1024
  const owner = via.split('~').includes('o');
994
1025
  const seat = g ? g.seat : null;
995
1026
  const handle = handleFor(g?.token || g?.browser || conn?.browser || `w-${this.watchIdOf(conn ?? g?.conn ?? {})}`);
996
- const name = seat !== null ? g.name : oneLine(g?.typed, CHAT_LIMITS.name) || handle;
1027
+ // A watcher's name: its game's, else the account name the Worker vouched for (the Lounge), else a handle.
1028
+ const name = seat !== null ? g.name : oneLine(g?.typed, CHAT_LIMITS.name) || oneLine(conn?.name, CHAT_LIMITS.name) || handle;
997
1029
  return {
998
1030
  key: g ? `c:${g.id}` : `w:${this.watchIdOf(conn)}`, client: g, seat, name, colour: seat !== null ? g.colour : null,
999
1031
  player: g?.player ?? conn?.player ?? null, acct: Boolean(g?.acct || conn?.acct), member: Boolean(g?.member || conn?.member) || owner,
1032
+ // A moderator the owner named (the Lounge): only the Worker's word, shown on their lines.
1033
+ mod: Boolean(conn?.mod) && !owner,
1000
1034
  owner, browser: g?.browser ?? conn?.browser ?? null, token: g?.token ?? null, ip: g?.ip ?? conn?.ip ?? null,
1001
1035
  watch: seat === null, hub: Boolean(conn?.hub) && !g, agent: Boolean(g?.agent), conn: conn ?? g?.conn ?? null,
1002
1036
  };
@@ -1075,7 +1109,7 @@ export class NetRoom {
1075
1109
  if (kind !== 'react') b.lastSayAt = now;
1076
1110
  Object.assign(rec, {
1077
1111
  name: who.name, seat: who.seat, colour: who.colour, by: who.hub ? 'hub' : who.watch ? 'watcher' : 'player',
1078
- bubble: m.bubble !== false && rules.bubbles && who.seat !== null, acct: who.acct, owner: who.owner,
1112
+ bubble: m.bubble !== false && rules.bubbles && who.seat !== null, acct: who.acct, owner: who.owner, ...(who.mod ? { mod: true } : {}),
1079
1113
  // Only the office sees these: who sent it, so a mute or a kick from the line reaches them (never an address).
1080
1114
  from: { client: who.client?.id ?? null, token: who.token, browser: who.browser, player: who.player },
1081
1115
  });
@@ -1114,7 +1148,8 @@ export class NetRoom {
1114
1148
  rec.id = randomId(6);
1115
1149
  rec.at = now;
1116
1150
  this.chatLog.push(rec);
1117
- while (this.chatLog.length > CHAT_LIMITS.keep || (this.chatLog.length && now - this.chatLog[0].at > CHAT_LIMITS.keepMs)) this.chatLog.shift();
1151
+ const keep = windowMsOf(this.chatRules());
1152
+ while (this.chatLog.length > CHAT_LIMITS.keep || (this.chatLog.length && now - this.chatLog[0].at > keep)) this.chatLog.shift();
1118
1153
  const msg = this.wireLine(rec);
1119
1154
  const text = JSON.stringify(msg);
1120
1155
  const mine = n ? JSON.stringify({ ...msg, n }) : text;
@@ -1123,6 +1158,8 @@ export class NetRoom {
1123
1158
  for (const w of this.watchers) if (!w.hub || hub) this.sendText(w, w === senderConn ? mine : text);
1124
1159
  if (rec.kind === 'react') this.stats.chatReacts = (this.stats.chatReacts ?? 0) + 1;
1125
1160
  else this.stats.chatLines = (this.stats.chatLines ?? 0) + 1;
1161
+ // Kept chat (0.29.0): a room whose rules keep history hands what was said to the Table (never a reaction).
1162
+ if (rec.kind !== 'react' && this.chatRules().history > 0 && typeof this.onPublished === 'function') { try { this.onPublished(rec); } catch { /* kept next time */ } }
1126
1163
  return { ok: true, id: rec.id };
1127
1164
  }
1128
1165
 
@@ -1134,9 +1171,32 @@ export class NetRoom {
1134
1171
  const text = JSON.stringify({ t: 'unline', ids: [...gone] });
1135
1172
  for (const o of this.live()) if (!this.lite(o)) this.sendText(o.conn, text);
1136
1173
  for (const w of this.watchers) this.sendText(w, text);
1174
+ // Kept chat: what is taken down here is deleted where it was kept, older lines than the window too.
1175
+ if (typeof this.onUnsaid === 'function' && gone.size) { try { this.onUnsaid([...gone]); } catch { /* best effort */ } }
1137
1176
  return before - this.chatLog.length;
1138
1177
  }
1139
1178
 
1179
+ /**
1180
+ * A person takes their own message down (0.29.0): a watching shell of the browser or the signed-in account that sent
1181
+ * it. Kept lines carry only the account, so a guest's own line comes down only while it is in this window.
1182
+ */
1183
+ /** Whether a line is this socket's own: its account's, or (a guest's) its browser's. */
1184
+ ownLine(r, conn) {
1185
+ const f = r?.from ?? {};
1186
+ if (!conn || r?.kind === 'studio') return false;
1187
+ return Boolean((conn.player && f.player && f.player === conn.player) || (conn.browser && f.browser && f.browser === conn.browser && !f.player));
1188
+ }
1189
+
1190
+ unsayOwn(conn, id) {
1191
+ if (typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,16}$/.test(id)) return { ok: false, error: 'no-line' };
1192
+ const r = this.chatLog.find((x) => x.id === id);
1193
+ if (!r || r.kind === 'studio') return { ok: false, error: 'no-line' };
1194
+ if (!this.ownLine(r, conn)) return { ok: false, error: 'not-yours' };
1195
+ this.unsay([id]);
1196
+ this.stats.chatOwnRemoved = (this.stats.chatOwnRemoved ?? 0) + 1;
1197
+ return { ok: true, removed: 1 };
1198
+ }
1199
+
1140
1200
  /**
1141
1201
  * A mute or a kick for a chat line whose sender holds no socket in this room now (a watcher, a homie.rocks page): held
1142
1202
  * by that browser and account, as a kick holds a player's other tabs.
@@ -1303,7 +1363,8 @@ export class NetRoom {
1303
1363
  this.reapSeats(now);
1304
1364
  this.seatWaiting();
1305
1365
  // The chat window is minutes long; buckets of senders who went quiet are dropped.
1306
- while (this.chatLog.length && now - this.chatLog[0].at > CHAT_LIMITS.keepMs) this.chatLog.shift();
1366
+ const keepMs = windowMsOf(this.chatRules());
1367
+ while (this.chatLog.length && now - this.chatLog[0].at > keepMs) this.chatLog.shift();
1307
1368
  if (this.chatBuckets.size > 512) this.chatBuckets.clear();
1308
1369
  if (this.chatAddress.size > 512) this.chatAddress.clear();
1309
1370
  if (!this.live().some((c) => !c.agent) && this.emptySince && now - this.emptySince > this.forgetMs) {
@@ -1315,6 +1376,8 @@ export class NetRoom {
1315
1376
  this.level = null; this.levelBy = null; this.vote = null; this.agentsOut = null;
1316
1377
  // Room chat (section 19): an empty room forgets what was said, as it forgets everything else.
1317
1378
  this.chatLog = []; this.chatBuckets.clear(); this.chatAddress.clear(); this.chatReacts = [];
1379
+ // A room that keeps history reads it back when somebody comes again (it is in D1, not here).
1380
+ this.hydrated = false;
1318
1381
  this.seatsDirty = false; this.persistDirty = false;
1319
1382
  try { this.store?.clear?.(); } catch { /* best effort */ }
1320
1383
  }
@@ -1633,8 +1696,9 @@ export class NetRoom {
1633
1696
  this.mutes = this.mutes.filter((m) => m.until > now);
1634
1697
  switch (op) {
1635
1698
  case 'kick': {
1636
- // From a chat line (section 19): its sender, in this room now or not (a watcher with no seat is held too).
1637
- const line = typeof a.line === 'string' ? this.chatLog.find((r) => r.id === a.line) : null;
1699
+ // From a chat line (section 19): its sender, in this room now or not (a watcher with no seat is held too). A line
1700
+ // older than the window comes from the room's kept history (the Worker read it: its sender's account only).
1701
+ const line = typeof a.line === 'string' ? this.chatLog.find((r) => r.id === a.line) ?? keptRef(a) : null;
1638
1702
  const target = this.findClient(a) ?? (line?.from?.client ? this.clients.get(line.from.client) ?? null : null);
1639
1703
  if (!target && line) return this.holdSender(line, minutes, now, { kick: true, message: oneLine(a.message, CONTROL_LIMITS.message) || 'The studio removed you from this room.', purge: a.purge === true });
1640
1704
  if (!target) return { ok: false, error: 'no-player', message: 'nobody is in that seat now' };
@@ -1664,7 +1728,7 @@ export class NetRoom {
1664
1728
  return { ok: true, op, name: ban.name, seat: ban.seat, sockets: out.length, until, ...(purged ? { removed: purged } : {}) };
1665
1729
  }
1666
1730
  case 'mute': {
1667
- const line = typeof a.line === 'string' ? this.chatLog.find((r) => r.id === a.line) : null;
1731
+ const line = typeof a.line === 'string' ? this.chatLog.find((r) => r.id === a.line) ?? keptRef(a) : null;
1668
1732
  const target = this.findClient(a) ?? (line?.from?.client ? this.clients.get(line.from.client) ?? null : null);
1669
1733
  if (!target && line && a.off !== true) return this.holdSender(line, minutes, now, { kick: false, purge: a.purge === true });
1670
1734
  if (!target) return { ok: false, error: 'no-player', message: 'nobody is in that seat now' };
@@ -1680,6 +1744,43 @@ export class NetRoom {
1680
1744
  this.tellWatchers();
1681
1745
  return { ok: true, op, name: line?.name ?? target.name, seat: target.seat, until, ...(purged ? { removed: purged } : {}) };
1682
1746
  }
1747
+ case 'card': {
1748
+ // Show what you made (0.29.0): a link card to a studio's game, which the Worker read from that studio's own
1749
+ // manifest and checked (its words passed the floor and the review there). Its sender's name and account are the
1750
+ // Worker's word; a mute holds a card as it holds a line.
1751
+ const c = a.card && typeof a.card === 'object' ? a.card : null;
1752
+ if (!c || typeof c.url !== 'string' || !/^https?:\/\//.test(c.url)) return { ok: false, error: 'card', message: 'no card' };
1753
+ const who = { token: null, browser: typeof a.browser === 'string' ? a.browser : null, player: typeof a.player === 'string' ? a.player : null, ip: null };
1754
+ if (a.owner !== true && this.banOf(who, null)) return { ok: false, error: 'kicked', message: 'The studio has held you out of this room for now.' };
1755
+ if (a.owner !== true && this.muteOf(who)) return { ok: false, error: 'muted', message: HELD_WORDS.muted };
1756
+ const card = { url: c.url.slice(0, 300), title: oneLine(c.title, 80), studio: oneLine(c.studio, 60), pitch: oneLine(c.pitch, 160), image: typeof c.image === 'string' && /^https:\/\//.test(c.image) ? c.image.slice(0, 400) : null, ...(typeof c.game === 'string' ? { game: c.game.slice(0, 40) } : {}) };
1757
+ const note = oneLine(a.note, CHAT_LIMITS.text);
1758
+ const rules = this.chatRules();
1759
+ if (rules.mode !== 'text' && a.owner !== true) return { ok: false, error: 'held', why: rules.mode === 'off' ? 'off' : 'lines', message: 'The Lounge isn\'t taking cards right now.' };
1760
+ // The card's words (the person's note, the game's own title and pitch) pass the floor like any typed line.
1761
+ const words = [note, card.title, card.pitch].filter(Boolean).join(' · ');
1762
+ const f = floor(words, { ...rules, links: 'block' });
1763
+ if (!f.ok && a.owner !== true) return { ok: false, error: 'held', why: f.why, message: HELD_WORDS[f.why] ?? HELD_WORDS.unknown };
1764
+ const rec = {
1765
+ kind: 'card', card, text: note || `Made: ${card.title}${card.studio ? ` by ${card.studio}` : ''}`,
1766
+ name: oneLine(a.name, CHAT_LIMITS.name) || 'Someone', seat: null, colour: null, by: 'watcher', bubble: false,
1767
+ acct: a.acct === true, owner: a.owner === true, ...(a.mod === true ? { mod: true } : {}),
1768
+ from: { client: null, token: null, browser: who.browser, player: who.player },
1769
+ };
1770
+ // And the studio's review, as a typed line gets it (the owner's own card is never reviewed).
1771
+ if (rules.ai && typeof this.review === 'function' && a.owner !== true) {
1772
+ return Promise.resolve()
1773
+ .then(() => this.review(words, { room: this.code, links: 'block' }))
1774
+ .then((v) => v, () => ({ ok: true, by: 'error' }))
1775
+ .then((v) => {
1776
+ if (v && v.ok === false) { this.stats.chatAiHeld = (this.stats.chatAiHeld ?? 0) + 1; return { ok: false, error: 'held', why: 'ai', message: HELD_WORDS.ai }; }
1777
+ rec.review = { by: v?.by ?? 'ai' };
1778
+ if (this.muteOf(who)) return { ok: false, error: 'muted', message: HELD_WORDS.muted };
1779
+ return { ...this.publishChat(rec), op };
1780
+ });
1781
+ }
1782
+ return { ...this.publishChat(rec), op };
1783
+ }
1683
1784
  case 'unsay': {
1684
1785
  // Room chat (section 19): the owner takes a message down (or every message: `all`), on every screen at once.
1685
1786
  const ids = a.all === true ? this.chatLog.map((r) => r.id) : (Array.isArray(a.ids) ? a.ids : [a.id]).filter((x) => typeof x === 'string' && /^[A-Za-z0-9_-]{1,16}$/.test(x)).slice(0, 64);
@@ -1863,6 +1964,7 @@ export class NetRoom {
1863
1964
  rules: this.chatRules(),
1864
1965
  pending: this.chatPending,
1865
1966
  lines: this.recentChat(now).map((r) => ({ ...this.wireLine(r), kind: r.kind, client: r.from?.client ?? null, player: r.from?.player ?? null, browser: browserTag(r.from?.browser), ...(r.review ? { review: r.review } : {}) })),
1967
+ history: this.chatRules().history ?? 0,
1866
1968
  },
1867
1969
  // A game's own decisions (section 20): how many, who answered, the model's time and neurons (never a state).
1868
1970
  ...(this.stats.decides ? { decides: { n: this.stats.decides, by: { ...this.stats.decided }, msP50: [...this.stats.decideMs].sort((x, y) => x - y)[Math.floor(this.stats.decideMs.length / 2)] ?? null, neurons: Math.round((this.stats.decideNeurons ?? 0) * 100) / 100 } } : {}),
@@ -1895,6 +1997,8 @@ export class NetRoom {
1895
1997
  screens: live.filter((c) => c.seat === null).length,
1896
1998
  waiting: live.filter((c) => c.waiting).length,
1897
1999
  watchers: live.filter((c) => c.watch).length,
2000
+ // Watching shells (pages with no game socket: the watch page, homie.rocks, the Lounge), 0.29.0.
2001
+ shells: this.watchers.size,
1898
2002
  humans: slots.filter((s) => s && !s.bot && !s.agent).length,
1899
2003
  bots: slots.filter((s) => s && s.bot && !s.agent).length,
1900
2004
  // Every AI body: a slot marked AI (an agent, or a seat kept for one), else the agents seated.
package/worker/site.mjs CHANGED
@@ -53,8 +53,11 @@ const I = {
53
53
  spark: '<path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5 18 18M6 18l2.5-2.5M15.5 8.5 18 6"/>',
54
54
  home: '<path d="M4 11.5 12 5l8 6.5V20H4z"/><path d="M10 20v-5h4v5"/>',
55
55
  eye: '<path d="M2 12s3.6-6.5 10-6.5S22 12 22 12s-3.6 6.5-10 6.5S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/>',
56
+ lounge: '<path d="M4.5 5h15A1.5 1.5 0 0 1 21 6.5v8a1.5 1.5 0 0 1-1.5 1.5H10l-4.5 3.5V16h-1A1.5 1.5 0 0 1 3 14.5v-8A1.5 1.5 0 0 1 4.5 5Z"/><path d="M8 9.5h8M8 12.5h5"/>',
57
+ calendar: '<rect x="3.5" y="5" width="17" height="15" rx="2.5"/><path d="M3.5 10h17M8 3v4M16 3v4"/>',
58
+ plus: '<path d="M12 5v14M5 12h14"/>',
56
59
  };
57
- const icon = (name, cls = 'ico') => `<svg class="${cls}" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${I[name] ?? ''}</svg>`;
60
+ export const icon = (name, cls = 'ico') => `<svg class="${cls}" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${I[name] ?? ''}</svg>`;
58
61
  const PLAY_ICON = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M7 4.8v14.4a1 1 0 0 0 1.5.86l12-7.2a1 1 0 0 0 0-1.72l-12-7.2A1 1 0 0 0 7 4.8Z" fill="currentColor"/></svg>';
59
62
  /** Homie's mark, for the "Made with Homie" footer. */
60
63
  const HOMIE_MARK = '<svg class="homie" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M3.5 11.2 12 4.5l8.5 6.7V20a.5.5 0 0 1-.5.5h-5v-5.5h-6v5.5H4a.5.5 0 0 1-.5-.5Z" fill="currentColor"/></svg>';
@@ -558,6 +561,8 @@ export function sectionsOf(cat) {
558
561
  (cat.videos ?? []).length > 0 && { key: 'videos', href: '/videos/', label: 'Videos' },
559
562
  games && { key: 'rooms', href: '/rooms/', label: 'Rooms' },
560
563
  (cat.posts ?? []).length > 0 && { key: 'posts', href: '/posts/', label: 'Posts' },
564
+ // The Lounge (0.29.0): the studio's own community room, when studio.json turns it on.
565
+ cat.studio?.lounge && { key: 'lounge', href: '/lounge/', label: cat.studio.lounge.tab ?? 'Lounge' },
561
566
  ].filter(Boolean);
562
567
  }
563
568
 
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.27.0';
2
+ export const STUDIO_VERSION_TAG = '0.29.0';