@homie-rocks/studio 0.12.1 → 0.14.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/worker/room.mjs CHANGED
@@ -41,6 +41,25 @@ export const EV_RATES = Object.freeze({ host: 30, replica: 10, screen: 2 });
41
41
  /** The keyed state channel: at most this many keys and bytes per room. */
42
42
  export const STATE_CAPS = Object.freeze({ keys: 64, bytes: 65536 });
43
43
 
44
+ /**
45
+ * THE OWNER'S CONTROLS (revision 4, NETPLAY.md section 15). The studio's Worker, acting for the signed-in owner,
46
+ * hands the room a control: kick a player out (and hold the door for some minutes), mute one, announce a line to
47
+ * everyone, close the room, or change its seats. The Table verifies the owner's signature before it calls
48
+ * `control()`; this file only applies one. What a player sees of them is new optional frames and two new refusals,
49
+ * so a v1 game that knows none of it keeps playing (its shell shows the notice and the banner).
50
+ */
51
+ export const CONTROL_LIMITS = Object.freeze({ minutes: 24 * 60, announce: 280, announceSeconds: 3600, bans: 200, mutes: 200, message: 200 });
52
+ /** What a muted player cannot say: an `ev` whose kind starts with one of these (a game's chat, quick lines, emotes). */
53
+ export const SPEECH = /^(?:say|chat|emote)/i;
54
+ const oneLine = (v, max) => String(v ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
55
+ /** A short tag for a browser's room key: enough for the owner to see "the same browser", never the key itself. */
56
+ export function browserTag(key) {
57
+ if (!key) return null;
58
+ let h = 2166136261;
59
+ for (const ch of String(key)) h = Math.imul(h ^ ch.charCodeAt(0), 16777619) >>> 0;
60
+ return h.toString(36).padStart(6, '0').slice(-6);
61
+ }
62
+
44
63
  /**
45
64
  * A seat's handle when its player typed no name: two words, varied per seat and per room ("Velvet Comet"), never
46
65
  * "Player 1" (a game that falls back to a list by seat number made every room's first player the same name).
@@ -152,6 +171,17 @@ export class NetRoom {
152
171
  kicked: 0, refused: 0, persists: 0, restoredFrom: null, bytesIn: 0, bytesOut: 0, elections: [], promotions: 0,
153
172
  };
154
173
  this.snapTimes = [];
174
+ /** The owner's controls (section 15): who is held out of this room until when, who is muted, the banner, a closed door. */
175
+ this.bans = [];
176
+ this.mutes = [];
177
+ this.announcement = null;
178
+ this.closedUntil = 0;
179
+ this.closedWhy = '';
180
+ /** When the room's first seat was taken (its uptime), and what the first hello asked for (`hello.max`). */
181
+ this.openedAt = 0;
182
+ this.askedMax = null;
183
+ /** A launch change waiting for the round to finish: who may stay (ticket kinds), and when it applies at the latest. */
184
+ this.regate = null;
155
185
  }
156
186
 
157
187
  /* ------------------------------------------------------------ sockets */
@@ -160,7 +190,7 @@ export class NetRoom {
160
190
  attach(conn) {
161
191
  const now = this.now();
162
192
  const c = {
163
- id: randomId(6), conn, ip: conn.ip ?? null, helloed: false, seat: null, token: null, name: '', typed: '', colour: 0,
193
+ id: randomId(6), conn, ip: conn.ip ?? null, browser: conn.browser ?? null, player: conn.player ?? null, via: conn.via ?? null, helloed: false, seat: null, token: null, name: '', typed: '', colour: 0,
164
194
  device: 'desk', want: 'play', canHost: true, hidden: false, waiting: false, joinedAt: now, lastSeen: now, lastSnapAt: 0,
165
195
  rates: new Map(), drops: [], errAt: 0,
166
196
  };
@@ -175,6 +205,9 @@ export class NetRoom {
175
205
  watch(conn) {
176
206
  this.watchers.add(conn);
177
207
  this.sendText(conn, JSON.stringify(this.facts()));
208
+ // A shell that comes back to a room it was kicked from (or that is closed) hears so at once.
209
+ const held = this.heldNotice(conn);
210
+ if (held) this.sendText(conn, JSON.stringify(held));
178
211
  return { onClose: () => this.watchers.delete(conn) };
179
212
  }
180
213
 
@@ -184,13 +217,13 @@ export class NetRoom {
184
217
  try { conn.send(text); this.stats.bytesOut += text.length; } catch { /* closing */ }
185
218
  }
186
219
  send(c, msg) { this.sendText(c.conn, JSON.stringify(msg)); }
187
- error(c, code, message) { this.send(c, { t: 'error', code, message }); }
220
+ error(c, code, message, extra = {}) { this.send(c, { t: 'error', code, message, ...extra }); }
188
221
  live() { return [...this.clients.values()].filter((c) => c.helloed); }
189
222
  others(c) { return this.live().filter((o) => o !== c); }
190
223
  host() { return this.hostId ? this.clients.get(this.hostId) ?? null : null; }
191
224
  hostRef() { const h = this.host(); return h ? { id: h.id, seat: h.seat } : null; }
192
225
  roleOf(c) { return c.id === this.hostId ? 'host' : c.seat === null ? 'screen' : 'replica'; }
193
- peer(c) { return { id: c.id, seat: c.seat, name: c.name, colour: c.colour, device: c.device, want: c.want, role: this.roleOf(c) }; }
226
+ peer(c) { return { id: c.id, seat: c.seat, name: c.name, colour: c.colour, device: c.device, want: c.want, role: this.roleOf(c), ...(this.muteOf(c) ? { muted: true } : {}) }; }
194
227
  peers() { return this.live().map((c) => this.peer(c)); }
195
228
  stateObject() { const o = {}; for (const [k, v] of this.state) o[k] = v.d; return o; }
196
229
 
@@ -268,6 +301,8 @@ export class NetRoom {
268
301
  }
269
302
  case 'ev': {
270
303
  const kind = String(m.k ?? '').slice(0, 64);
304
+ // A muted player's speech goes nowhere (a host relays everyone's, so only a non-host's own is dropped).
305
+ if (!isHost && SPEECH.test(kind) && this.muteOf(c)) { this.stats.mutedDrops = (this.stats.mutedDrops ?? 0) + 1; return; }
271
306
  this.stats.evs += 1;
272
307
  const out = { t: 'ev', from: c.seat, k: kind, d: m.d ?? null };
273
308
  if (isHost) {
@@ -314,6 +349,8 @@ export class NetRoom {
314
349
  case 'round': {
315
350
  if (!isHost || !m.round || typeof m.round !== 'object') return;
316
351
  this.lastRound = m.round;
352
+ // The round a launch change waits for is over: everyone sees its results for a moment, then the room re-gates.
353
+ if (this.regate && m.round.phase === 'over' && (this.regate.roundN === null || Number(m.round.n) >= this.regate.roundN)) this.regate.until = Math.min(this.regate.until, this.now() + 5000);
317
354
  this.stats.rounds += 1;
318
355
  this.persistDirty = true;
319
356
  for (const o of this.others(c)) this.send(o, { t: 'round', round: m.round });
@@ -370,12 +407,16 @@ export class NetRoom {
370
407
  return;
371
408
  }
372
409
  const live = this.live();
373
- const refuse = (code, message) => {
410
+ const refuse = (code, message, extra = {}) => {
374
411
  this.stats.refused += 1;
375
- this.error(c, code, message);
412
+ this.error(c, code, message, extra);
376
413
  this.clients.delete(c.id);
377
414
  try { c.conn.close(1008, code); } catch { /* gone */ }
378
415
  };
416
+ // The owner closed this room, or held this player out of it (section 15): refused, with when it ends.
417
+ if (this.closedUntil > this.now()) return refuse('room-closed', this.closedWhy || 'The studio closed this room.', { until: this.closedUntil });
418
+ const ban = this.banOf(c, typeof m.token === 'string' ? m.token : '');
419
+ if (ban) return refuse('kicked', ban.message, { until: ban.until });
379
420
  if (this.perIp && c.ip && live.filter((o) => o.ip === c.ip).length >= this.perIp) return refuse('too-many', `at most ${this.perIp} sockets per address in one room`);
380
421
  if (live.length >= this.maxPlayers + this.maxScreens) return refuse('room-full', 'this room is full; try another');
381
422
  const now = this.now();
@@ -385,13 +426,14 @@ export class NetRoom {
385
426
  c.game = typeof m.game === 'string' ? m.game.slice(0, 64) : '';
386
427
  c.typed = typeof m.name === 'string' ? m.name.replace(/\s+/g, ' ').trim().slice(0, 24) : '';
387
428
  // The manifest's player count, from the first visitor of an empty room (the site's Table reads the manifest).
388
- if (Number.isInteger(m.max) && !this.seats.size && !live.length) this.maxPlayers = Math.max(1, Math.min(this.seatCap, m.max));
429
+ if (Number.isInteger(m.max) && !this.seats.size && !live.length) { this.askedMax = m.max; this.maxPlayers = Math.max(1, Math.min(this.seatCap, m.max)); }
389
430
  this.reapSeats(now);
390
431
  let full = false;
391
432
  if (c.want === 'play' && !this.seatClient(c, typeof m.token === 'string' ? m.token : '')) { full = true; c.waiting = true; }
392
433
  if (c.seat === null) c.name = c.typed || (c.want === 'screen' ? 'Screen' : 'Watcher');
393
434
  c.helloed = true;
394
435
  c.joinedAt = now;
436
+ if (!this.openedAt) this.openedAt = now;
395
437
  this.emptySince = 0;
396
438
  this.lastJoinAt = now;
397
439
 
@@ -426,6 +468,7 @@ export class NetRoom {
426
468
  round: this.lastRound, roster: this.lastRoster, snap: this.lastSnap, state: this.stateObject(),
427
469
  ...(role === 'host' ? { ckpt: this.lastCkpt } : {}),
428
470
  ...(full ? { full: true } : {}),
471
+ ...(this.liveAnnouncement() ? { announce: this.liveAnnouncement() } : {}),
429
472
  });
430
473
  for (const o of this.others(c)) this.send(o, { t: 'join', peer: this.peer(c) });
431
474
  if (deposed) {
@@ -593,12 +636,13 @@ export class NetRoom {
593
636
  this.preferHost = null;
594
637
  if (this.live().some((c) => c.canHost)) this.elect(null, 'host-left');
595
638
  }
639
+ if (this.regate && now >= this.regate.until) this.applyRegate(now);
596
640
  this.reapSeats(now);
597
641
  this.seatWaiting();
598
642
  if (!this.live().length && this.emptySince && now - this.emptySince > this.forgetMs) {
599
643
  this.lastSnap = null; this.lastSnapText = null; this.lastCkpt = null; this.lastRound = null; this.lastRoster = null;
600
644
  this.state.clear(); this.stateBytes = 0; this.seats.clear(); this.preferHost = null;
601
- this.emptySince = 0;
645
+ this.emptySince = 0; this.openedAt = 0; this.askedMax = null;
602
646
  this.seatsDirty = false; this.persistDirty = false;
603
647
  try { this.store?.clear?.(); } catch { /* best effort */ }
604
648
  }
@@ -621,7 +665,7 @@ export class NetRoom {
621
665
 
622
666
  saved(now = this.now()) {
623
667
  return {
624
- v: NET_VERSION, room: this.code, savedAt: now, maxPlayers: this.maxPlayers, hostSeat: this.host()?.seat ?? null,
668
+ v: NET_VERSION, room: this.code, savedAt: now, maxPlayers: this.maxPlayers, hostSeat: this.host()?.seat ?? null, openedAt: this.openedAt || null,
625
669
  seats: [...this.seats].map(([seat, s]) => [seat, s.token, s.name]),
626
670
  ckpt: this.lastCkpt, round: this.lastRound, roster: this.lastRoster, state: this.stateObject(),
627
671
  };
@@ -636,6 +680,7 @@ export class NetRoom {
636
680
  const now = this.now();
637
681
  if (!saved || saved.v !== NET_VERSION || !(now - Number(saved.savedAt) < 120_000)) return false;
638
682
  if (Number.isInteger(saved.maxPlayers)) this.maxPlayers = Math.max(1, Math.min(this.seatCap, saved.maxPlayers));
683
+ if (Number.isFinite(saved.openedAt) && saved.openedAt > 0) this.openedAt = saved.openedAt;
639
684
  for (const [seat, token, name] of saved.seats ?? []) this.seats.set(seat, { token, name, since: now, present: false });
640
685
  this.lastCkpt = saved.ckpt ?? null;
641
686
  this.lastRound = saved.round ?? null;
@@ -652,6 +697,251 @@ export class NetRoom {
652
697
  return true;
653
698
  }
654
699
 
700
+ /* ------------------------------------------------------------ the owner's controls (section 15) */
701
+
702
+ /** A live client by socket id, else the present client in a seat. */
703
+ findClient({ id = null, seat = null } = {}) {
704
+ if (typeof id === 'string' && id) { const c = this.clients.get(id); if (c && c.helloed) return c; }
705
+ if (Number.isInteger(seat)) return this.live().find((c) => c.seat === seat) ?? null;
706
+ return null;
707
+ }
708
+
709
+ /** The hold that keeps this client out, if any: by its seat token, its browser's room key, its account, or (only when the owner asked) its address. */
710
+ banOf(c, token = c.token) {
711
+ const now = this.now();
712
+ return this.bans.find((b) => b.until > now && ((b.token && b.token === token) || (b.browser && b.browser === c.browser) || (b.player && b.player === c.player) || (b.ip && b.ip === c.ip))) ?? null;
713
+ }
714
+
715
+ muteOf(c) {
716
+ const now = this.now();
717
+ return this.mutes.find((m) => m.until > now && ((m.token && m.token === c.token) || (m.browser && m.browser === c.browser) || (m.player && m.player === c.player))) ?? null;
718
+ }
719
+
720
+ liveAnnouncement() {
721
+ const a = this.announcement;
722
+ return a && a.until > this.now() ? a : null;
723
+ }
724
+
725
+ /** What a watching shell is told on arrival: the room is closed, or its browser is held out or muted. */
726
+ heldNotice(conn) {
727
+ const now = this.now();
728
+ if (this.closedUntil > now) return { t: 'closed', room: this.code, until: this.closedUntil, message: this.closedWhy || 'The studio closed this room.' };
729
+ const probe = { token: null, browser: conn.browser ?? null, player: conn.player ?? null, ip: null };
730
+ if (!probe.browser && !probe.player) return null;
731
+ const ban = this.banOf(probe, null);
732
+ if (ban) return { t: 'kicked', room: this.code, until: ban.until, message: ban.message };
733
+ const mute = this.muteOf(probe);
734
+ return mute ? { t: 'muted', room: this.code, until: mute.until } : null;
735
+ }
736
+
737
+ /** Tell the watching shells of one browser (or account) something meant for that player alone. */
738
+ tellBrowser(who, msg) {
739
+ const text = JSON.stringify(msg);
740
+ for (const w of this.watchers) if ((who.browser && w.browser === who.browser) || (who.player && w.player === who.player)) this.sendText(w, text);
741
+ }
742
+
743
+ /**
744
+ * One control, already verified by the caller (the Table checks the owner's signature). Returns what happened:
745
+ * `{ ok: true, ... }`, or `{ ok: false, error }` when there is nobody to act on.
746
+ */
747
+ control(op, a = {}) {
748
+ const now = this.now();
749
+ const minutes = Math.max(1, Math.min(CONTROL_LIMITS.minutes, Math.floor(Number(a.minutes) || 10)));
750
+ this.bans = this.bans.filter((b) => b.until > now);
751
+ this.mutes = this.mutes.filter((m) => m.until > now);
752
+ switch (op) {
753
+ case 'kick': {
754
+ const target = this.findClient(a);
755
+ if (!target) return { ok: false, error: 'no-player', message: 'nobody is in that seat now' };
756
+ const until = now + minutes * 60_000;
757
+ // The message is the studio's words; `until` says when (the shell says it in the player's own time).
758
+ const message = oneLine(a.message, CONTROL_LIMITS.message) || 'The studio removed you from this room.';
759
+ const ban = { token: target.token, browser: target.browser, player: target.player, ip: a.address ? target.ip : null, name: target.name, seat: target.seat, until, at: now, message };
760
+ this.bans = [...this.bans, ban].slice(-CONTROL_LIMITS.bans);
761
+ // Every socket of that player in this room: the seat itself, and its browser's or account's other tabs.
762
+ const out = this.live().filter((o) => o === target || (ban.browser && o.browser === ban.browser) || (ban.player && o.player === ban.player));
763
+ for (const o of out) {
764
+ const seat = o.seat;
765
+ const token = o.token;
766
+ this.error(o, 'kicked', message, { until });
767
+ this.onClose(o, 'kicked');
768
+ // The seat is free for somebody else at once, and its token resumes nothing.
769
+ const s = seat === null ? null : this.seats.get(seat);
770
+ if (s && s.token === token) { this.seats.delete(seat); this.seatsDirty = true; }
771
+ try { o.conn.close(1008, 'kicked'); } catch { /* gone */ }
772
+ }
773
+ this.stats.ownerKicks = (this.stats.ownerKicks ?? 0) + 1;
774
+ this.tellBrowser(ban, { t: 'kicked', room: this.code, until, message });
775
+ this.persist(now);
776
+ this.tellWatchers();
777
+ return { ok: true, op, name: ban.name, seat: ban.seat, sockets: out.length, until };
778
+ }
779
+ case 'mute': {
780
+ const target = this.findClient(a);
781
+ if (!target) return { ok: false, error: 'no-player', message: 'nobody is in that seat now' };
782
+ const off = a.off === true;
783
+ const same = (m) => (m.token && m.token === target.token) || (m.browser && m.browser === target.browser) || (m.player && m.player === target.player);
784
+ this.mutes = this.mutes.filter((m) => !same(m));
785
+ const until = off ? 0 : now + minutes * 60_000;
786
+ if (!off) this.mutes = [...this.mutes, { token: target.token, browser: target.browser, player: target.player, name: target.name, seat: target.seat, until, at: now }].slice(-CONTROL_LIMITS.mutes);
787
+ const note = { t: 'mute', id: target.id, seat: target.seat, until };
788
+ for (const o of this.live()) this.send(o, note);
789
+ this.tellBrowser(target, { t: 'muted', room: this.code, until });
790
+ this.tellWatchers();
791
+ return { ok: true, op, name: target.name, seat: target.seat, until };
792
+ }
793
+ case 'announce': {
794
+ const text = oneLine(a.text, CONTROL_LIMITS.announce);
795
+ const prev = this.announcement;
796
+ if (!text) {
797
+ this.announcement = null;
798
+ if (prev) { const clear = JSON.stringify({ t: 'announce', id: prev.id, text: null }); for (const o of this.live()) this.sendText(o.conn, clear); for (const w of this.watchers) this.sendText(w, clear); }
799
+ this.tellWatchers();
800
+ return { ok: true, op, cleared: Boolean(prev) };
801
+ }
802
+ const seconds = Math.max(5, Math.min(CONTROL_LIMITS.announceSeconds, Math.floor(Number(a.seconds) || 30)));
803
+ this.announcement = { id: typeof a.id === 'string' && /^[A-Za-z0-9_-]{1,24}$/.test(a.id) ? a.id : randomId(6), text, at: now, until: now + seconds * 1000, from: 'studio' };
804
+ const msg = JSON.stringify({ t: 'announce', ...this.announcement });
805
+ for (const o of this.live()) this.sendText(o.conn, msg);
806
+ for (const w of this.watchers) this.sendText(w, msg);
807
+ this.tellWatchers();
808
+ return { ok: true, op, id: this.announcement.id, people: this.live().length, until: this.announcement.until };
809
+ }
810
+ case 'close': {
811
+ if (a.reopen === true) { this.closedUntil = 0; this.closedWhy = ''; this.tellWatchers(); return { ok: true, op, reopened: true }; }
812
+ // `seconds` (10 s to a day) is a short close: everyone comes back through the game's door (a launch state change).
813
+ const seconds = Math.floor(Number(a.seconds));
814
+ const until = now + (seconds >= 10 ? Math.min(seconds, CONTROL_LIMITS.minutes * 60) * 1000 : minutes * 60_000);
815
+ const message = oneLine(a.message, CONTROL_LIMITS.message) || 'The studio closed this room. Thanks for playing!';
816
+ this.closedUntil = until;
817
+ this.closedWhy = message;
818
+ const people = this.live().length;
819
+ for (const c of [...this.clients.values()]) {
820
+ if (c.helloed) this.error(c, 'room-closed', message, { until });
821
+ this.clients.delete(c.id);
822
+ try { c.conn.close(1000, 'room-closed'); } catch { /* gone */ }
823
+ }
824
+ // Everything the room held goes, as when an empty room forgets (section 4).
825
+ this.hostId = null;
826
+ this.lastSnap = null; this.lastSnapText = null; this.lastCkpt = null; this.lastRound = null; this.lastRoster = null;
827
+ this.state.clear(); this.stateBytes = 0; this.seats.clear(); this.preferHost = null; this.openedAt = 0; this.askedMax = null;
828
+ this.emptySince = now; this.seatsDirty = false; this.persistDirty = false;
829
+ try { this.store?.clear?.(); } catch { /* best effort */ }
830
+ const msg = JSON.stringify({ t: 'closed', room: this.code, until, message });
831
+ for (const w of this.watchers) this.sendText(w, msg);
832
+ this.tellWatchers();
833
+ return { ok: true, op, people, until };
834
+ }
835
+ case 'regate': {
836
+ // A game's launch state changed (NETPLAY.md section 15): who may play is now narrower (`allow`: ticket kinds o,
837
+ // i, p), or anyone again (`allow` absent: a pending change is cancelled). The current round finishes first.
838
+ if (!Array.isArray(a.allow)) { const had = Boolean(this.regate); this.regate = null; this.tellWatchers(); return { ok: true, op, cancelled: had }; }
839
+ const allow = a.allow.filter((k) => ['o', 'i', 'p'].includes(k));
840
+ const leaving = this.live().filter((c) => !this.allowedAfter(c, allow)).length;
841
+ if (!leaving) { this.regate = null; this.tellWatchers(); return { ok: true, op, leaving: 0 }; }
842
+ const r = this.lastRound;
843
+ const playing = Boolean(r && r.phase === 'live' && Number(r.endsAt) > now);
844
+ const until = Math.min(now + 15 * 60_000, playing ? Number(r.endsAt) + 20_000 : now + 30_000);
845
+ this.regate = { allow, roundN: playing ? Number(r.n) : null, until, at: now, message: oneLine(a.message, CONTROL_LIMITS.message) || 'Thanks for playing!' };
846
+ const notice = oneLine(a.notice, CONTROL_LIMITS.announce);
847
+ if (notice) this.control('announce', { text: notice, seconds: Math.ceil((until - now) / 1000) + 5 });
848
+ this.tellWatchers();
849
+ return { ok: true, op, leaving, until, afterRound: playing ? Number(r.n) : null };
850
+ }
851
+ case 'seats': {
852
+ const n = Math.floor(Number(a.max));
853
+ if (!(n >= 1)) return { ok: false, error: 'max', message: 'seats is a number of at least 1' };
854
+ this.setSeats(n);
855
+ this.tellWatchers();
856
+ return { ok: true, op, max: this.maxPlayers };
857
+ }
858
+ default:
859
+ return { ok: false, error: 'op', message: `no control called ${String(op).slice(0, 24)}` };
860
+ }
861
+ }
862
+
863
+ /** Whether a client may stay once the room re-gates: one of its ticket's holders (o, i-…, p-…) is of an allowed kind. */
864
+ allowedAfter(c, allow) {
865
+ return String(c.via ?? '').split('~').some((part) => (part === 'o' && allow.includes('o')) || (part.startsWith('i-') && allow.includes('i')) || (part.startsWith('p-') && allow.includes('p')));
866
+ }
867
+
868
+ /** The launch change, now: everyone it leaves out is sent out of the room with the studio's words; the rest play on. */
869
+ applyRegate(now = this.now()) {
870
+ const g = this.regate;
871
+ if (!g) return 0;
872
+ this.regate = null;
873
+ // The holder of the room's storage keeps the owner's state (the Table writes it on its next tick).
874
+ this.officeDirty = true;
875
+ const out = this.live().filter((c) => !this.allowedAfter(c, g.allow));
876
+ for (const c of out) {
877
+ const seat = c.seat;
878
+ const token = c.token;
879
+ this.error(c, 'room-closed', g.message);
880
+ this.onClose(c, 'regated');
881
+ const s = seat === null ? null : this.seats.get(seat);
882
+ if (s && s.token === token) { this.seats.delete(seat); this.seatsDirty = true; }
883
+ try { c.conn.close(1000, 'room-closed'); } catch { /* gone */ }
884
+ this.tellBrowser(c, { t: 'closed', room: this.code, message: g.message });
885
+ }
886
+ this.persist(now);
887
+ this.tellWatchers();
888
+ return out.length;
889
+ }
890
+
891
+ /**
892
+ * The room's seats, changed while it runs (the owner's "max players per room"). Players already seated above the
893
+ * new number keep their seat until they leave; nobody new is seated there. Never above the room's own cap.
894
+ */
895
+ setSeats(n, perIp = null) {
896
+ this.seatCap = Math.max(1, Math.floor(n));
897
+ this.maxPlayers = Math.max(1, Math.min(this.seatCap, this.askedMax ?? this.seatCap));
898
+ if (Number.isFinite(perIp) && perIp > 0) this.perIp = perIp;
899
+ }
900
+
901
+ /** The owner's holds, the banner and a closed door: kept apart from the room's play (they outlive an empty room). */
902
+ officeSaved() {
903
+ const now = this.now();
904
+ return {
905
+ bans: this.bans.filter((b) => b.until > now), mutes: this.mutes.filter((m) => m.until > now),
906
+ announcement: this.liveAnnouncement(), closedUntil: this.closedUntil > now ? this.closedUntil : 0, closedWhy: this.closedWhy,
907
+ regate: this.regate,
908
+ };
909
+ }
910
+
911
+ restoreOffice(o) {
912
+ if (!o || typeof o !== 'object') return;
913
+ const now = this.now();
914
+ this.bans = (Array.isArray(o.bans) ? o.bans : []).filter((b) => b && b.until > now).slice(-CONTROL_LIMITS.bans);
915
+ this.mutes = (Array.isArray(o.mutes) ? o.mutes : []).filter((m) => m && m.until > now).slice(-CONTROL_LIMITS.mutes);
916
+ this.announcement = o.announcement && o.announcement.until > now ? o.announcement : null;
917
+ this.closedUntil = Number(o.closedUntil) > now ? Number(o.closedUntil) : 0;
918
+ this.closedWhy = this.closedUntil ? String(o.closedWhy ?? '') : '';
919
+ // A launch change still waiting comes back; one long past is over (whoever it was for has gone).
920
+ this.regate = o.regate && Array.isArray(o.regate.allow) && Number(o.regate.until) > now - 120_000 ? o.regate : null;
921
+ }
922
+
923
+ /** The room as its owner sees it: the facts, plus who each player is to the studio (never an address). */
924
+ officeFacts() {
925
+ const f = this.facts();
926
+ const now = this.now();
927
+ const byId = new Map(this.live().map((c) => [c.id, c]));
928
+ return {
929
+ ...f,
930
+ office: {
931
+ openedAt: this.openedAt || null,
932
+ closedUntil: this.closedUntil > now ? this.closedUntil : null,
933
+ regate: this.regate ? { allow: this.regate.allow, until: this.regate.until, afterRound: this.regate.roundN } : null,
934
+ clients: f.clients.map((p) => {
935
+ const c = byId.get(p.id);
936
+ const mute = c ? this.muteOf(c) : null;
937
+ return { ...p, joinedAt: c?.joinedAt ?? null, lastSeen: c?.lastSeen ?? null, browser: browserTag(c?.browser), player: c?.player ?? null, via: c?.via ?? null, mutedUntil: mute ? mute.until : null };
938
+ }),
939
+ bans: this.bans.filter((b) => b.until > now).map((b) => ({ name: b.name, seat: b.seat, until: b.until, at: b.at, address: Boolean(b.ip), browser: browserTag(b.browser) })),
940
+ mutes: this.mutes.filter((m) => m.until > now).map((m) => ({ name: m.name, seat: m.seat, until: m.until })),
941
+ },
942
+ };
943
+ }
944
+
655
945
  /* ------------------------------------------------------------ the shell's view */
656
946
 
657
947
  facts() {
@@ -662,6 +952,9 @@ export class NetRoom {
662
952
  return {
663
953
  t: 'net', v: NET_VERSION, room: this.code, st: now,
664
954
  host: this.hostRef(),
955
+ openedAt: this.openedAt || null,
956
+ announce: this.liveAnnouncement(),
957
+ ...(this.closedUntil > now ? { closedUntil: this.closedUntil } : {}),
665
958
  clients: live.map((c) => ({ ...this.peer(c), hidden: c.hidden, waiting: c.waiting })),
666
959
  round: this.lastRound,
667
960
  roster: this.lastRoster,
package/worker/site.mjs CHANGED
@@ -692,6 +692,7 @@ export function homePage(cat, { origin = '', rooms = [], live = {} } = {}) {
692
692
  const f = featuredOf(cat);
693
693
  const tagline = cat.studio?.tagline ?? '';
694
694
  let hero;
695
+ let soon = '';
695
696
  if (f) {
696
697
  const playing = live[f.id] ?? 0;
697
698
  hero = `<section class="hero home" aria-labelledby="hero-title">
@@ -704,6 +705,27 @@ export function homePage(cat, { origin = '', rooms = [], live = {} } = {}) {
704
705
  <a class="also" href="/${esc(f.id)}/">${icon('arrow')}<span>About ${esc(f.name)}</span></a>${games.length > 1 ? `<a class="also" href="/games/">${icon('games')}<span>All ${games.length} games</span></a>` : ''}
705
706
  </div>
706
707
  </section>`;
708
+ } else if (!videos.length && !songs.length) {
709
+ // A new studio with nothing published yet: its own Home, its name, "First game coming soon", and what is on the
710
+ // way (a studio is never given a starter game it did not ask for). Its posts show below as soon as it has any.
711
+ hero = `<section class="hero home soon" aria-labelledby="hero-title">
712
+ ${heroMedia({}, { word: name })}
713
+ <div class="hero-copy">
714
+ <p class="kicker">${esc(tagline || 'A Homie studio')}</p>
715
+ <h1 class="title ${titleClass(name)}" id="hero-title">${esc(name)}</h1>
716
+ <p class="line">First game coming soon.</p>
717
+ <p class="live"><span class="live-dot" aria-hidden="true"></span><span>${posts.length ? 'In the making · the latest news is below' : 'In the making'}</span></p>
718
+ </div>
719
+ </section>`;
720
+ const next = [
721
+ ['Games', 'The first game', 'It opens here with a Play button: a live public room on a phone, a computer or a TV, with bots in the empty seats.'],
722
+ ['Posts', 'News and drops', 'The studio\'s posts land on this page, with feeds to follow.'],
723
+ ['Music and videos', 'Songs and trailers', 'Scores, songs and trailers get pages of their own.'],
724
+ ];
725
+ soon = `<section class="band glowband" aria-labelledby="soon-title"><div class="band-in">
726
+ <div class="head-row reveal"><div><p class="kicker">On the way</p><h2 class="small-h" id="soon-title">${esc(`Coming to ${name}`)}</h2></div></div>
727
+ <div class="cards">${next.map(([k, t, d]) => `<div class="pcard reveal" data-soon><p class="post-meta">${esc(k)}</p><h3>${esc(t)}</h3><p>${esc(d)}</p></div>`).join('')}</div>
728
+ </div></section>`;
707
729
  } else {
708
730
  const lead = videos[0] ?? songs[0] ?? null;
709
731
  const art = lead ? mediaArt(lead, videos[0] ? 'videos' : 'music', cat) : null;
@@ -736,10 +758,10 @@ export function homePage(cat, { origin = '', rooms = [], live = {} } = {}) {
736
758
  const extra = partial(cat, 'home');
737
759
  return layout(cat, {
738
760
  title: tagline ? `${name} — ${tagline}` : name,
739
- description: tagline || (f ? `${name}: ${games.map((g) => g.name).join(', ')}. Free in your browser, on a phone, a computer or a TV.` : name),
761
+ description: tagline || (f ? `${name}: ${games.map((g) => g.name).join(', ')}. Free in your browser, on a phone, a computer or a TV.` : soon ? `${name}: first game coming soon.` : name),
740
762
  origin, path: '/', image: f ? (f.landing?.hero?.wideImage ?? coverOf(f)) : null, page: 'home', hero: true,
741
763
  head: f?.landing?.hero?.wideImage ? `<link rel="preload" as="image" href="${esc(f.landing.hero.tallImage ?? f.landing.hero.wideImage)}">` : '',
742
- main: `${hero}${extra ? `<section class="band">${extra}</section>` : ''}${roomsBand}${gamesBand}${postsBand}${mediaBand(videos, 'videos', 'Trailers and clips')}${mediaBand(songs, 'music', 'Songs and scores')}`,
764
+ main: `${hero}${extra ? `<section class="band">${extra}</section>` : ''}${roomsBand}${gamesBand}${postsBand}${soon}${mediaBand(videos, 'videos', 'Trailers and clips')}${mediaBand(songs, 'music', 'Songs and scores')}`,
743
765
  });
744
766
  }
745
767
 
@@ -10,9 +10,13 @@
10
10
  import { OWNER_COOKIE, endSession, ownerAllowed, rangeOf, readStats, spendSignin, onlyOf } from './stats.mjs';
11
11
  import { CODEX_SCRIPT } from './codex-script.mjs';
12
12
 
13
- /** Where a sign-in may send the owner next: the stats, or one game's codex. */
14
- const NEXT = /^\/_studio\/(?:stats|codex\/[a-z0-9][a-z0-9-]{0,39}\/)$/;
13
+ /**
14
+ * Where a sign-in may send the owner next: the stats, one game's codex, the back office, an ask waiting for the
15
+ * owner's tap, or one of the studio's games (a private game's own secret road for the owner's phone).
16
+ */
17
+ const NEXT = /^\/(?:_studio\/(?:stats|office|codex\/[a-z0-9][a-z0-9-]{0,39}\/|confirm\/ask_[a-f0-9]{16})|[a-z0-9][a-z0-9-]{0,39}\/(?:play)?)$/;
15
18
  const nextOf = (url) => { const to = url.searchParams.get('to') ?? ''; return NEXT.test(to) ? to : '/_studio/stats'; };
19
+ const signinWord = (to) => (to.startsWith('/_studio/codex/') ? 'Open the codex' : to === '/_studio/office' ? 'Open the office' : to.startsWith('/_studio/confirm/') ? 'Sign in and see what your AI asks' : to.startsWith('/_studio/stats') ? 'Open the stats' : 'Sign in and play');
16
20
  let scriptHash = null;
17
21
  async function codexScriptHash() {
18
22
  if (!scriptHash) {
@@ -99,7 +103,7 @@ function statsBody(cat, s, url) {
99
103
  const videoRows = s.videos.map((e) => `<tr><td><a href="${esc(q({ video: e.slug, game: null, song: null }))}">${esc(e.title)}</a></td><td>${fmt(e.visits)}</td><td>${fmt(e.views)}</td></tr>`).join('');
100
104
  const refRows = s.referrers.map((r) => `<tr><td>${esc(r.from)} <span class="kind">${esc(KIND_WORD[r.kind] ?? r.kind)}</span></td><td>${fmt(r.visits)}</td><td>${fmt(r.plays)}</td></tr>`).join('');
101
105
  return `<header><h1>${esc(cat.studio?.name ?? 'Studio')} stats${s.only ? ` <span class="dim">· ${esc(s.only.kind)} ${esc(s.only.id)}</span>` : ''}</h1>
102
- <form method="post" action="/_studio/signout"><button class="ghost" type="submit">Sign out</button></form></header>
106
+ <span><a href="/_studio/office">Office</a> &nbsp; <form method="post" action="/_studio/signout"><button class="ghost" type="submit">Sign out</button></form></span></header>
103
107
  <p class="dim">${esc(s.range.from)} to ${esc(s.range.to)} (UTC)${s.only ? ` · <a href="${esc(q({ game: null, song: null, video: null }))}">whole studio</a>` : ''}</p>
104
108
  <nav class="ranges">${ranges}</nav>
105
109
  <section class="tiles">
@@ -132,18 +136,23 @@ export async function ownerRoutes(request, env, url, { catalogueOf }) {
132
136
  if (request.method === 'GET') {
133
137
  // A GET spends nothing: a chat app's link preview must not use up the owner's one-time link.
134
138
  return page('Sign in · stats', `<h1>${esc(cat.studio?.name ?? 'Studio')} stats</h1>
135
- <p class="dim">Open this studio's private stats in this browser. The link works once.</p>
136
- <form method="post" action="/_studio/signin?k=${esc(encodeURIComponent(key))}${nextOf(url) !== '/_studio/stats' ? `&amp;to=${esc(encodeURIComponent(nextOf(url)))}` : ''}"><button type="submit">${nextOf(url).startsWith('/_studio/codex/') ? 'Open the codex' : 'Open the stats'}</button></form>`);
139
+ <p class="dim">Sign this browser in as the studio's owner: the private stats and office, and the owner's tools in your own games. The link works once.</p>
140
+ <form method="post" action="/_studio/signin?k=${esc(encodeURIComponent(key))}${nextOf(url) !== '/_studio/stats' ? `&amp;to=${esc(encodeURIComponent(nextOf(url)))}` : ''}"><button type="submit">${signinWord(nextOf(url))}</button></form>`);
137
141
  }
138
142
  if (request.method !== 'POST' || !sameOrigin(request, url)) return page('Not allowed', '<h1>Not allowed</h1>', 403);
139
143
  const session = await spendSignin(env, key);
140
144
  if (!session) return page('Link used or expired', `<h1>That link has been used or has expired</h1><p class="note">A sign-in link works once, for 30 minutes. Ask your AI for a new one: <code>npx --no-install homie-studio stats link</code>.</p>`, 403);
141
- return new Response(null, { status: 303, headers: { location: nextOf(url), 'cache-control': 'no-store', 'set-cookie': `${OWNER_COOKIE}=${session}; Path=/_studio/; HttpOnly; SameSite=Lax; Max-Age=${30 * 24 * 3600}${secure}` } });
145
+ // The session lives at / (0.13.0): the owner is recognised in their own games, never readable by a page or a game.
146
+ return new Response(null, { status: 303, headers: { location: nextOf(url), 'cache-control': 'no-store', 'set-cookie': `${OWNER_COOKIE}=${session}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${30 * 24 * 3600}${secure}` } });
142
147
  }
143
148
  if (path === '/_studio/signout') {
144
149
  if (request.method !== 'POST' || !sameOrigin(request, url)) return page('Not allowed', '<h1>Not allowed</h1>', 403);
145
150
  await endSession(env, request);
146
- return new Response(null, { status: 303, headers: { location: '/_studio/stats', 'cache-control': 'no-store', 'set-cookie': `${OWNER_COOKIE}=; Path=/_studio/; HttpOnly; SameSite=Lax; Max-Age=0${secure}` } });
151
+ const headers = new Headers({ location: '/_studio/stats', 'cache-control': 'no-store' });
152
+ // Both places a session cookie has lived (/ from 0.13.0, /_studio/ before).
153
+ headers.append('set-cookie', `${OWNER_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`);
154
+ headers.append('set-cookie', `${OWNER_COOKIE}=; Path=/_studio/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`);
155
+ return new Response(null, { status: 303, headers });
147
156
  }
148
157
  if (path === '/_studio/stats' || path === '/_studio/stats/') {
149
158
  if (request.method !== 'GET') return page('Not allowed', '<h1>Not allowed</h1>', 405);
package/worker/stats.mjs CHANGED
@@ -25,8 +25,10 @@
25
25
  * `homie-studio stats` prints them (a ten-minute read key, minted and dropped by the CLI)
26
26
  * `homie-studio stats key` a read key for the Homie MCP tool `studio_stats` (1 hour by default)
27
27
  * `homie-studio stats link` a one-time sign-in link: /_studio/stats in the owner's own browser
28
+ * `homie-studio office key` a back-office key (it reads these too): worker/office.mjs
28
29
  * A key is 24 random bytes; D1 keeps only its SHA-256. The page's session cookie is the owner's alone
29
- * (HttpOnly, SameSite=Lax, only under /_studio/). There is no other way in.
30
+ * (HttpOnly, SameSite=Lax; from 0.13.0 at / so the owner is recognised in their own games, never readable by a
31
+ * page or a game). There is no other way in.
30
32
  *
31
33
  * FREE PLAN: D1 allows 100,000 row writes and 5 million row reads a day. A visit, a play, a room and a
32
34
  * song or video start are one write each; a finished round is three (its row in `rounds`, the round and the
@@ -134,30 +136,54 @@ export async function countVisit(request, env, ctx, subject, metric = 'visit') {
134
136
 
135
137
  /* ------------------------------------------------------------------ the owner's keys */
136
138
 
137
- /** Whether a presented key (Bearer header) or the owner's page session is valid. */
139
+ /**
140
+ * Whether a presented key (Bearer header) or the owner's page session is valid, and which: `read` (a stats read
141
+ * key), `office` (a back-office key: the stats, the live rooms, and asking for the owner's controls; worker/office.mjs)
142
+ * or `session` (the owner's signed-in browser). A key's kind is fixed when it is minted; an office key also reads
143
+ * the stats.
144
+ */
138
145
  export async function ownerAllowed(request, env, { kinds = ['read', 'session'] } = {}) {
139
146
  if (!env?.DB) return false;
140
147
  const auth = request.headers.get('authorization') ?? '';
141
148
  const bearer = /^Bearer\s+(\S+)$/i.exec(auth)?.[1] ?? null;
142
- const cookie = cookieValue(request, OWNER_COOKIE);
143
149
  const tries = [];
144
- if (bearer && KEY.test(bearer) && kinds.includes('read')) tries.push(['read', bearer]);
145
- if (cookie && SESSION.test(cookie) && kinds.includes('session')) tries.push(['session', cookie]);
146
- for (const [kind, value] of tries) {
150
+ if (bearer && KEY.test(bearer) && (kinds.includes('read') || kinds.includes('office'))) tries.push([['read', 'office'].filter((k) => kinds.includes(k)), bearer]);
151
+ // The session cookie lives at / from 0.13.0 (the owner is recognised in their own game) and at /_studio/ before
152
+ // it: a browser may send both, so each is tried.
153
+ if (kinds.includes('session')) for (const cookie of cookieValues(request, OWNER_COOKIE).filter((v) => SESSION.test(v)).slice(0, 3)) tries.push([['session'], cookie]);
154
+ for (const [allowed, value] of tries) {
147
155
  try {
148
156
  const row = await env.DB.prepare('SELECT kind, expires_at FROM stats_keys WHERE hash = ?1').bind(await sha256(value)).first();
149
- if (row && row.kind === kind && Number(row.expires_at) > Date.now()) return kind;
157
+ if (row && allowed.includes(row.kind) && Number(row.expires_at) > Date.now()) return row.kind;
150
158
  } catch { return false; }
151
159
  }
152
160
  return false;
153
161
  }
154
162
 
163
+ /** The owner's valid page session value this request carries, or null (to carry an older /_studio/ cookie to /). */
164
+ export async function ownerSession(request, env) {
165
+ if (!env?.DB) return null;
166
+ for (const cookie of cookieValues(request, OWNER_COOKIE).filter((v) => SESSION.test(v)).slice(0, 3)) {
167
+ try {
168
+ const row = await env.DB.prepare('SELECT kind, expires_at FROM stats_keys WHERE hash = ?1').bind(await sha256(cookie)).first();
169
+ if (row && row.kind === 'session' && Number(row.expires_at) > Date.now()) return cookie;
170
+ } catch { return null; }
171
+ }
172
+ return null;
173
+ }
174
+
155
175
  export function cookieValue(request, name) {
176
+ return cookieValues(request, name)[0] ?? null;
177
+ }
178
+
179
+ /** Every value a request carries for one cookie name (the same name can arrive from two paths). */
180
+ export function cookieValues(request, name) {
181
+ const out = [];
156
182
  for (const part of (request.headers.get('cookie') ?? '').split(';')) {
157
183
  const [k, ...v] = part.trim().split('=');
158
- if (k === name) return v.join('=');
184
+ if (k === name) out.push(v.join('='));
159
185
  }
160
- return null;
186
+ return out;
161
187
  }
162
188
 
163
189
  /** A one-time sign-in key turns into a 30-day page session (the key is spent). Returns the session value or null. */
@@ -178,9 +204,10 @@ export async function spendSignin(env, key) {
178
204
  }
179
205
 
180
206
  export async function endSession(env, request) {
181
- const cookie = cookieValue(request, OWNER_COOKIE);
182
- if (!env?.DB || !cookie || !SESSION.test(cookie)) return;
183
- try { await env.DB.prepare("DELETE FROM stats_keys WHERE hash = ?1 AND kind = 'session'").bind(await sha256(cookie)).run(); } catch { /* gone */ }
207
+ if (!env?.DB) return;
208
+ for (const cookie of cookieValues(request, OWNER_COOKIE).filter((v) => SESSION.test(v)).slice(0, 3)) {
209
+ try { await env.DB.prepare("DELETE FROM stats_keys WHERE hash = ?1 AND kind = 'session'").bind(await sha256(cookie)).run(); } catch { /* gone */ }
210
+ }
184
211
  }
185
212
 
186
213
  /* ------------------------------------------------------------------ reading */
@@ -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.12.1';
2
+ export const STUDIO_VERSION_TAG = '0.14.0';