@homie-rocks/studio 0.28.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.
- package/CHANGELOG.md +30 -1
- package/README.md +30 -1
- package/bin/homie-studio.mjs +24 -0
- package/chat/CHAT.md +5 -1
- package/chat/LOUNGE.md +114 -0
- package/chat/OWNERS.md +6 -2
- package/lib/build.mjs +5 -0
- package/lib/lounge-cli.mjs +70 -0
- package/lib/mcp-tools.mjs +6 -0
- package/lib/scaffold.mjs +13 -2
- package/lib/template-history.json +40 -0
- package/mcp/ui/setup.js +2 -0
- package/netplay/NETPLAY.md +11 -1
- package/package.json +1 -1
- package/site/SITE.md +13 -0
- package/worker/chat-store.mjs +2 -1
- package/worker/chat.mjs +13 -1
- package/worker/discover.mjs +5 -0
- package/worker/index.mjs +38 -3
- package/worker/lounge-page.mjs +623 -0
- package/worker/lounge-store.mjs +281 -0
- package/worker/lounge.mjs +547 -0
- package/worker/office-page.mjs +99 -0
- package/worker/office.mjs +19 -1
- package/worker/players.mjs +5 -0
- package/worker/room.mjs +116 -12
- package/worker/site.mjs +6 -1
- package/worker/version.mjs +1 -1
package/worker/office-page.mjs
CHANGED
|
@@ -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);
|
package/worker/players.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/worker/version.mjs
CHANGED
|
@@ -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.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.29.0';
|