@homie-rocks/studio 0.14.4 → 0.15.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.
@@ -14,6 +14,10 @@
14
14
  * The host decides who killed what and sends the killer a `loot` event; only the hero's own browser changes the
15
15
  * hero and saves it. A hardcore hero who falls becomes a memorial in the Hall of the Fallen, and its save is wiped
16
16
  * in the same step (saves.fall). Canvas 2D on purpose: the point is the pattern, in a few hundred lines.
17
+ *
18
+ * A watcher (/<game>/watch, contract revision 5) has no hero and makes none: `room.viewSeat()` is the hero it
19
+ * follows, drawn in gold as a hero's own browser draws itself, with that hero's panel; nobody followed, the vale.
20
+ * The host says who slew what (`slain`), so a watcher on Auto cuts to the kill.
17
21
  */
18
22
  import { createControls, createRoom, createSaves, exposePort, q, type BodyBase, type NetEvent } from '@homie-rocks/studio/port';
19
23
 
@@ -40,6 +44,9 @@ interface Slime { id: number; x: number; y: number; hp: number; maxHp: number; s
40
44
  type SlimeRow = [id: number, x: number, y: number, hp: number, size: number];
41
45
 
42
46
  /* ------------------------------------------------------------------ the save */
47
+ /** A spectator (a watcher, or a big screen) has no hero of its own: it never loads, makes or saves one. */
48
+ const shellCfg = (globalThis as { HOMIE_NET?: { want?: string; watch?: boolean } }).HOMIE_NET;
49
+ const lookOnly = shellCfg?.watch === true || shellCfg?.want === 'screen';
43
50
  const saves = createSaves({ game: 'ember-vale' });
44
51
  let hero: Hero | null = null;
45
52
  let heroLoaded = false;
@@ -50,6 +57,7 @@ function saveSoon(): void {
50
57
  saveTimer = setTimeout(() => { if (hero) void saves.set('hero', hero); }, 1000);
51
58
  }
52
59
  async function loadHero(): Promise<void> {
60
+ if (lookOnly) { heroLoaded = true; ui.paint(); return; }
53
61
  const h = await saves.get<Hero>('hero');
54
62
  hero = h && h.v === 1 ? h : null;
55
63
  heroLoaded = true;
@@ -140,6 +148,7 @@ net.on('event', (e: NetEvent) => {
140
148
  const d = (e.d ?? {}) as { xp?: number; gold?: number; cause?: string; to?: number };
141
149
  if (e.k === 'loot' && d.to === room.mySeat()) gain(Math.max(0, Number(d.xp) || 0), Math.max(0, Number(d.gold) || 0));
142
150
  if (e.k === 'down' && d.to === room.mySeat()) void fell(String(d.cause ?? 'a slime').slice(0, 40));
151
+ if (e.k === 'slain' && typeof (e.d as { seat?: unknown })?.seat === 'number') net.spotlight((e.d as { seat: number }).seat);
143
152
  });
144
153
  function tell(b: Body, k: 'loot' | 'down', d: Record<string, unknown>): void {
145
154
  if (b.bot || b.seat === null) return;
@@ -158,6 +167,8 @@ function strike(b: Body, now: number): void {
158
167
  const gold = s.size === 3 ? 50 : 2 + Math.floor(Math.random() * 4) * s.size;
159
168
  b.score += xp;
160
169
  tell(b, 'loot', { xp, gold });
170
+ // Everyone hears who slew it (a watcher on Auto cuts to them); the loot itself is the slayer's alone.
171
+ if (!b.bot && b.seat !== null) room.send('slain', { seat: b.seat });
161
172
  }
162
173
  }
163
174
  slimes = slimes.filter((s) => s.hp > 0);
@@ -299,10 +310,12 @@ function draw(t: number): void {
299
310
  ctx.fillStyle = 'rgba(0,0,0,.5)'; ctx.fillRect(x - r, y + r + 4, r * 2, 4);
300
311
  ctx.fillStyle = '#ff7a59'; ctx.fillRect(x - r, y + r + 4, (r * 2 * clamp(hp, 0, max)) / max, 4);
301
312
  }
302
- const mySeat = room.mySeat();
313
+ // The hero whose view this is: my own, or the one a watcher follows (null: the vale, nobody in gold).
314
+ const viewS = room.viewSeat();
303
315
  for (const b of room.view()) {
304
- const self = !b.bot && b.seat === mySeat;
305
- const x = self && !room.hosting ? me.x : b.x; const y = self && !room.hosting ? me.y : b.y;
316
+ const self = !b.bot && b.seat === viewS;
317
+ const local = self && !room.hosting && !net.watching;
318
+ const x = local ? me.x : b.x; const y = local ? me.y : b.y;
306
319
  const isDown = Boolean(b.flags & DOWN);
307
320
  ctx.globalAlpha = isDown ? 0.35 : 1;
308
321
  if ((b.flags & STRIKING) || (self && performance.now() - me.flashAt < 160)) { ctx.strokeStyle = 'rgba(255,207,110,.7)'; ctx.lineWidth = 4; ctx.beginPath(); ctx.arc(x, y, STRIKE_RANGE * 0.8, 0, Math.PI * 2); ctx.stroke(); }
@@ -325,8 +338,23 @@ function hud(t: number): void {
325
338
  const x0 = 14; const y0 = 14;
326
339
  ctx.fillStyle = 'rgba(10,14,9,.62)'; ctx.fillRect(x0, y0, 240, 62);
327
340
  ctx.textAlign = 'left'; ctx.font = '700 16px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#ffcf6e';
328
- ctx.fillText(hero ? `${hero.name}${hero.hardcore ? ' ☠' : ''} · Lv ${hero.level}` : heroLoaded ? 'No hero yet' : 'Loading your hero…', x0 + 10, y0 + 22);
329
- if (hero) {
341
+ if (lookOnly) {
342
+ // A watcher's panel is the followed hero's, as the room has them (their save stays in their own browser).
343
+ const v = room.viewBody();
344
+ if (v) {
345
+ ctx.fillText(`${v.name} · Lv ${v.level}`, x0 + 10, y0 + 22);
346
+ ctx.fillStyle = 'rgba(255,255,255,.12)'; ctx.fillRect(x0 + 10, y0 + 32, 140, 6);
347
+ ctx.fillStyle = '#7ad35a'; ctx.fillRect(x0 + 10, y0 + 32, (140 * clamp(v.hp, 0, v.maxHp)) / Math.max(1, v.maxHp), 6);
348
+ ctx.font = '600 13px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#e7d9b4';
349
+ ctx.fillText(`${v.score} xp tonight${v.flags & DOWN ? ' · down' : ''}`, x0 + 10, y0 + 54);
350
+ } else {
351
+ const heroes = room.view().filter((b) => !b.bot).length;
352
+ ctx.fillText('Watching the vale', x0 + 10, y0 + 22);
353
+ ctx.font = '600 13px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#e7d9b4';
354
+ ctx.fillText(`${heroes} ${heroes === 1 ? 'hero' : 'heroes'} · ${(room.fast() ?? []).length} slimes`, x0 + 10, y0 + 46);
355
+ }
356
+ } else ctx.fillText(hero ? `${hero.name}${hero.hardcore ? ' ☠' : ''} · Lv ${hero.level}` : heroLoaded ? 'No hero yet' : 'Loading your hero…', x0 + 10, y0 + 22);
357
+ if (hero && !lookOnly) {
330
358
  ctx.fillStyle = 'rgba(255,255,255,.12)'; ctx.fillRect(x0 + 10, y0 + 32, 140, 6);
331
359
  ctx.fillStyle = '#ffcf6e'; ctx.fillRect(x0 + 10, y0 + 32, (140 * hero.xp) / xpFor(hero.level), 6);
332
360
  ctx.font = '600 13px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#e7d9b4';
@@ -359,6 +387,7 @@ const ui = {
359
387
  paint(): void {
360
388
  const p = saves.player;
361
389
  $('who').textContent = p.local ? 'Playing here only' : p.signedIn ? `${p.name}${p.owner ? ' · owner' : ''}` : 'Guest · keep my hero';
390
+ $('who').hidden = lookOnly;
362
391
  },
363
392
  make(): void {
364
393
  ($('hero-name') as HTMLInputElement).value = '';
@@ -416,6 +445,9 @@ function frame(t: number): void {
416
445
  requestAnimationFrame(frame);
417
446
  }
418
447
 
448
+ // Tonight's experience per hero: the watch page's live scores, and who Auto follows when nobody is slaying.
449
+ net.expose({ scores: () => room.view().map((b) => ({ slot: b.slot, seat: b.seat, bot: b.bot, score: b.score })) });
450
+
419
451
  exposePort(net, {
420
452
  view: 'top',
421
453
  self: () => (me.has && room.mySeat() !== null ? { x: me.x, y: me.y } : null),
@@ -16,9 +16,15 @@
16
16
  * zone (gems inside it score double) is slow state on the keyed state channel,
17
17
  * so a joiner has it in its welcome and a promoted host inherits it.
18
18
  *
19
+ * WATCH ANY PLAYER (contract revision 5): a watcher (/<game>/watch) never
20
+ * takes a seat. `net.viewSeat` says whose view to draw; the camera follows that
21
+ * player's body as their own browser does, the board marks their row, and with
22
+ * nobody followed the whole arena shows. Waves call `net.spotlight(seat)`, so a
23
+ * watcher on Auto cuts to whoever just bumped.
24
+ *
19
25
  * Canvas 2D on purpose: the point is the contract, in ~700 readable lines.
20
26
  */
21
- import { createNetplay, Roster, q, lerp, capMove, type RoleChange, type RoundInfo, type RoundResult, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
27
+ import { createNetplay, Roster, q, lerp, capMove, PALETTE, type RoleChange, type RoundInfo, type RoundResult, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
22
28
  // The port toolkit's probe: what `homie-studio port check` reads for the owner tests (and sandbox + audio shims).
23
29
  import { exposePort } from '@homie-rocks/studio/port';
24
30
 
@@ -38,7 +44,8 @@ const KNOCK_RANGE = 110;
38
44
  const KNOCK_SPEED = 950;
39
45
  const KNOCK_MS = 420;
40
46
  const ZONE_MS = 12_000;
41
- const PALETTE = ['#8fe36a', '#ffd166', '#ef6f6c', '#6cb4ee', '#c792ea', '#f4a261', '#2ec4b6', '#ff8fab', '#a7c957', '#e9c46a', '#90e0ef', '#f28482'];
47
+ /** The contract's 12 colours (PALETTE, NETPLAY.md section 3): a person wears their seat's, so the watch page's strip matches. */
48
+ const colourOf = (slot: number, seat: number | null): string => PALETTE[(seat ?? slot) % PALETTE.length] as string;
42
49
  const BOT_NAMES = ['Rook', 'Vex', 'Moth', 'Kilo', 'Juno', 'Pike', 'Nyx', 'Ash'];
43
50
  const botName = (slot: number): string => BOT_NAMES[slot % BOT_NAMES.length] as string;
44
51
  const label = (name: string, bot: boolean): string => (bot ? `${name} · bot` : name);
@@ -85,6 +92,8 @@ let predictionError = 0;
85
92
  const me = { x: W / 2, y: H / 2, vx: 0, vy: 0, kvx: 0, kvy: 0, knockUntil: 0, has: false };
86
93
  /** Offline (no shell) plays as a local seat 0, with keys or touch. */
87
94
  const mySeat = (): number | null => (net.offline ? 0 : net.seat);
95
+ /** Whose view to draw: my own seat, or for a watcher the player it follows (null: the whole arena). */
96
+ const viewSeat = (): number | null => (net.offline ? 0 : net.viewSeat);
88
97
 
89
98
  /* ------------------------------------------------------- replica view */
90
99
  const drawn = new Map<number, { x: number; y: number; seat: number; score: number; slot: number }>();
@@ -342,6 +351,9 @@ function knock(b: Body, fx: number, fy: number): void {
342
351
  }
343
352
  function addWave(d: { slot: number }, isKnock = false): void {
344
353
  const pos = hosting ? bodies.get(d.slot) : [...drawn.values()].find((x) => x.slot === d.slot);
354
+ // The action, for a watcher on Auto: whoever waved (a person, never a bot).
355
+ const waver = pos && 'bot' in pos ? (pos.bot ? null : pos.seat) : pos && pos.seat >= 0 ? pos.seat : null;
356
+ if (!isKnock) net.spotlight(waver);
345
357
  const mineSlot = hosting ? [...bodies.values()].find((x) => x.seat !== null && x.seat === mySeat())?.slot : [...drawn.values()].find((x) => x.seat === net.seat)?.slot;
346
358
  const at = mineSlot === d.slot && me.has ? me : pos;
347
359
  if (!at) return;
@@ -527,7 +539,14 @@ function resize(): void {
527
539
  addEventListener('resize', resize);
528
540
  resize();
529
541
 
530
- const cam = { x: W / 2, y: H / 2 };
542
+ const cam = { x: W / 2, y: H / 2, scale: 0 };
543
+ /** Where a seat's body is drawn now (host: the real body; replica: interpolated), or null. */
544
+ function seatPos(seat: number): { x: number; y: number } | null {
545
+ if (seat === mySeat() && me.has && !net.watching) return me;
546
+ if (hosting) { const b = [...bodies.values()].find((x) => !x.bot && x.seat === seat); return b ? { x: b.x, y: b.y } : null; }
547
+ const d = [...drawn.values()].find((x) => x.seat === seat);
548
+ return d ? { x: d.x, y: d.y } : null;
549
+ }
531
550
  const gemGlow = (() => {
532
551
  const g = ctx.createRadialGradient(0, 0, 0, 0, 0, R_GEM * 2.8);
533
552
  g.addColorStop(0, 'rgba(255,209,102,0.55)'); g.addColorStop(0.4, 'rgba(255,170,60,0.18)'); g.addColorStop(1, 'rgba(255,150,40,0)');
@@ -536,9 +555,15 @@ const gemGlow = (() => {
536
555
  function draw(t: number): void {
537
556
  const cw = innerWidth; const ch = innerHeight;
538
557
  const phone = Math.min(cw, ch) <= 540;
539
- const spectating = mySeat() === null;
540
- const scale = spectating ? Math.min(cw / (W + 80), ch / (H + 80)) : Math.min(cw, ch) / (phone ? 560 : 820);
541
- const target = spectating || !me.has ? { x: W / 2, y: H / 2 } : me;
558
+ // The view: my own body, or (a watcher) the followed player's, drawn exactly as their own browser frames it.
559
+ const view = viewSeat();
560
+ const followed = net.watching && view !== null ? seatPos(view) : null;
561
+ const overview = net.watching ? !followed : mySeat() === null;
562
+ const wantScale = overview ? Math.min(cw / (W + 80), ch / (H + 80)) : Math.min(cw, ch) / (phone ? 560 : 820);
563
+ // A watcher's switch glides: the zoom and the pan ease over a few frames, never a cut.
564
+ cam.scale = cam.scale ? cam.scale + (wantScale - cam.scale) * 0.12 : wantScale;
565
+ const scale = cam.scale;
566
+ const target = net.watching ? (followed ?? { x: W / 2, y: H / 2 }) : overview || !me.has ? { x: W / 2, y: H / 2 } : me;
542
567
  cam.x += (target.x - cam.x) * 0.2; cam.y += (target.y - cam.y) * 0.2;
543
568
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
544
569
  const bg = ctx.createRadialGradient(cw / 2, ch / 2, 0, cw / 2, ch / 2, Math.max(cw, ch) * 0.8);
@@ -588,18 +613,20 @@ function draw(t: number): void {
588
613
 
589
614
  // avatars
590
615
  const names = new Map<number, Slot>((net.slots ?? roster.slots).map((s) => [s.slot, s]));
616
+ // `mine`: the body whose view this is: my own, or the player a watcher follows (named, never "You").
591
617
  const list: { slot: number; seat: number | null; x: number; y: number; bot: boolean; name: string; mine: boolean }[] = [];
618
+ const isView = (seat: number | null, bot: boolean): boolean => !bot && seat !== null && seat === view;
592
619
  if (hosting) {
593
- for (const b of bodies.values()) list.push({ slot: b.slot, seat: b.seat, x: b.x, y: b.y, bot: b.bot, name: b.name, mine: b.seat !== null && b.seat === mySeat() });
620
+ for (const b of bodies.values()) list.push({ slot: b.slot, seat: b.seat, x: b.x, y: b.y, bot: b.bot, name: b.name, mine: isView(b.seat, b.bot) });
594
621
  } else {
595
622
  for (const d of drawn.values()) {
596
- const mine = d.seat >= 0 && d.seat === net.seat;
623
+ const own = !net.watching && d.seat >= 0 && d.seat === net.seat;
597
624
  const s = names.get(d.slot);
598
- list.push({ slot: d.slot, seat: d.seat >= 0 ? d.seat : null, x: mine && me.has ? me.x : d.x, y: mine && me.has ? me.y : d.y, bot: d.seat < 0, name: s?.name ?? (d.seat >= 0 ? `Player ${d.seat + 1}` : botName(d.slot)), mine });
625
+ list.push({ slot: d.slot, seat: d.seat >= 0 ? d.seat : null, x: own && me.has ? me.x : d.x, y: own && me.has ? me.y : d.y, bot: d.seat < 0, name: s?.name ?? (d.seat >= 0 ? `Player ${d.seat + 1}` : botName(d.slot)), mine: isView(d.seat >= 0 ? d.seat : null, d.seat < 0) });
599
626
  }
600
627
  }
601
628
  for (const a of list) {
602
- const colour = PALETTE[a.slot % PALETTE.length] as string;
629
+ const colour = colourOf(a.slot, a.bot ? null : a.seat);
603
630
  ctx.globalAlpha = a.bot ? 0.62 : 1;
604
631
  ctx.fillStyle = colour; ctx.beginPath(); ctx.arc(a.x, a.y, R_AV, 0, Math.PI * 2); ctx.fill();
605
632
  ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.beginPath(); ctx.arc(a.x, a.y, R_AV * 0.45, 0, Math.PI * 2); ctx.fill();
@@ -607,7 +634,7 @@ function draw(t: number): void {
607
634
  ctx.globalAlpha = 1;
608
635
  ctx.font = `600 ${Math.round(15 / Math.max(0.6, scale))}px ui-sans-serif, system-ui, sans-serif`;
609
636
  ctx.textAlign = 'center'; ctx.fillStyle = a.mine ? '#ffffff' : 'rgba(232,236,245,0.8)';
610
- ctx.fillText(a.mine ? 'You' : label(a.name, a.bot), a.x, a.y - R_AV - 12);
637
+ ctx.fillText(a.mine && !net.watching ? 'You' : label(a.name, a.bot), a.x, a.y - R_AV - 12);
611
638
  }
612
639
  ctx.restore();
613
640
 
@@ -640,11 +667,11 @@ function hud(cw: number, ch: number, phone: boolean, list: { slot: number; name:
640
667
  ctx.textAlign = 'right';
641
668
  rows.forEach((a, i) => {
642
669
  ctx.fillStyle = a.mine ? '#ffffff' : a.bot ? 'rgba(232,236,245,0.55)' : 'rgba(232,236,245,0.85)';
643
- ctx.fillText(`${a.mine ? 'You' : label(a.name, a.bot)} ${scores.get(a.slot) ?? 0}`, cw - pad, top + 18 + i * (phone ? 20 : 22));
670
+ ctx.fillText(`${a.mine && !net.watching ? 'You' : label(a.name, a.bot)} ${scores.get(a.slot) ?? 0}`, cw - pad, top + 18 + i * (phone ? 20 : 22));
644
671
  });
645
672
  // role badge
646
673
  ctx.textAlign = 'left'; ctx.font = '600 11px ui-monospace, Menlo, monospace'; ctx.fillStyle = 'rgba(125,240,255,0.7)';
647
- ctx.fillText(net.offline ? 'OFFLINE HOST' : net.role.toUpperCase(), pad, ch - pad);
674
+ ctx.fillText(net.offline ? 'OFFLINE HOST' : net.watching ? 'WATCHING' : net.role.toUpperCase(), pad, ch - pad);
648
675
  if (r && r.phase === 'over' && r.results) {
649
676
  const w = Math.min(360, cw - 40); const h = 44 + Math.min(6, r.results.length) * 26;
650
677
  const x = (cw - w) / 2; const y = (ch - h) / 2;
@@ -653,10 +680,11 @@ function hud(cw: number, ch: number, phone: boolean, list: { slot: number; name:
653
680
  ctx.textAlign = 'center'; ctx.fillStyle = '#7df0ff'; ctx.font = '700 18px ui-sans-serif, system-ui, sans-serif';
654
681
  ctx.fillText(`Round ${r.n} results`, cw / 2, y + 28);
655
682
  ctx.font = '600 16px ui-sans-serif, system-ui, sans-serif';
683
+ const view = viewSeat();
656
684
  r.results.slice(0, 6).forEach((row, i) => {
657
- const mine = row.seat !== null && row.seat === mySeat();
685
+ const mine = !row.bot && row.seat !== null && row.seat === view;
658
686
  ctx.fillStyle = mine ? '#ffffff' : row.bot ? 'rgba(232,236,245,0.6)' : '#e8ecf5';
659
- ctx.fillText(`${row.place}. ${mine ? 'You' : label(row.name, row.bot)} — ${row.score}`, cw / 2, y + 56 + i * 26);
687
+ ctx.fillText(`${row.place}. ${mine && !net.watching ? 'You' : label(row.name, row.bot)}${mine && net.watching ? ' ◂' : ''} — ${row.score}`, cw / 2, y + 56 + i * 26);
660
688
  });
661
689
  }
662
690
  if (!net.offline && !net.connected && net.role !== 'host') {
@@ -699,6 +727,8 @@ net.expose({
699
727
  movement: () => MOVEMENT,
700
728
  controlResets: () => controlResets,
701
729
  predictionError: () => predictionError,
730
+ /** Where the camera looks and whose view it is (a watcher following a player: their body, under the camera). */
731
+ camera: () => ({ x: cam.x, y: cam.y, scale: cam.scale, view: viewSeat() }),
702
732
  /** performance.now() of the first keyed-state value and the first live snapshot this browser received. */
703
733
  arrivals: () => ({ firstStateAt, firstSnapAt }),
704
734
  /** Harness hook (host only): knock the body of `seat` back, toward the middle of the arena. */
package/worker/index.mjs CHANGED
@@ -16,10 +16,15 @@
16
16
  * room button shares it (Invite, Big screen, the room code)
17
17
  * /<game>/tv the big screen: the same room as a spectator, with a QR code phones scan to join
18
18
  * (also /<game>/play?screen=1)
19
+ * /<game>/watch?room= watch a live room from any player's view: the game itself, rendered by this browser
20
+ * as a watcher that never takes a seat, with a strip of the players to switch between
21
+ * (keys 1-9, A for Auto, O for the overview); no room: the busiest public room
22
+ * (NETPLAY.md section 16; game.json "watch": "overview" or false narrows it)
19
23
  * /<game>/__game/... the game's own files (index.html gets HOMIE_NET)
20
24
  * /<game>/__net?room= the room's netplay socket (Table Durable Object)
21
25
  * /<game>/__watch?room= the room's facts, for the shell
22
26
  * /<game>/api/lobby which public room to join (Lobby Durable Object)
27
+ * /<game>/api/watch which public room to watch: the busiest one now (nothing is reserved)
23
28
  * /api/games, /api/rooms the catalogue plus live counts; every public room playing now (/api/rooms may be
24
29
  * cached for 15 s)
25
30
  * /__homie/..., /<game>/__homie/... `not-a-homie`: the answer a game with Homie's arcade controls gets when it
@@ -51,17 +56,17 @@
51
56
  * after `homie-studio storage add` (R2 needs a payment method on the account),
52
57
  * MEDIA. Everything else runs on Cloudflare's free Workers plan.
53
58
  *
54
- * The relay is the netplay contract's own room.mjs (NETPLAY.md v1 rev 3), run
59
+ * The relay is the netplay contract's own room.mjs (NETPLAY.md v1 rev 5), run
55
60
  * unchanged inside the Table, so a game that plays in `homie-studio dev`
56
61
  * plays the same way here.
57
62
  */
58
- import { NetRoom } from './room.mjs';
59
- import { ROOM_ID, badRoomPage, frameAncestors, playPage } from './pages.mjs';
63
+ import { NetRoom, WATCH_POLICIES } from './room.mjs';
64
+ import { ROOM_ID, badRoomPage, frameAncestors, noWatchPage, playPage, watchPage } from './pages.mjs';
60
65
  import { qrSvg } from './qr.mjs';
61
66
  import { SEAT_MAX, perAddress, seatsOf } from './seats.mjs';
62
67
  import {
63
68
  SITE_JS, atomFeed, creditsPage, customPage, gameCover, gameLanding, gamesPage, homePage, jsonFeed, mediaArt, mediaIndexPage,
64
- notFoundPage, postPage, postsPage, roomView, roomsPage, sectionsOf, songPage, videoPage,
69
+ notFoundPage, postPage, postsPage, roomView, roomsPage, sectionsOf, songPage, videoPage, watchOf,
65
70
  } from './site.mjs';
66
71
  import { count, countVisit, counter, isQa, onlyOf, ownerAllowed, playedByGame, playedThisWeek, rangeOf, readStats, today } from './stats.mjs';
67
72
  import { ownerRoutes } from './stats-page.mjs';
@@ -307,7 +312,12 @@ async function gameDocument(request, env, url, game, meta, cat) {
307
312
  const res = await env.ASSETS.fetch(new Request(`${url.origin}/games/${game}/index.html`));
308
313
  if (!res.ok) return notFoundPage(`No build of "${game}" on this site yet.`, cat);
309
314
  const room = asked ?? 'main';
310
- const want = url.searchParams.get('want') === 'screen' ? 'screen' : 'play';
315
+ // A watcher (section 16): a screen that never takes a seat, following the player the watch page asks for.
316
+ const policy = watchOf(meta);
317
+ const watching = url.searchParams.get('watch') === '1';
318
+ if (watching && policy === 'off') return new Response(`${meta?.name ?? game} cannot be watched; play it instead.\n`, { status: 403, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } });
319
+ const follow = /^(?:auto|overview|\d{1,2})$/.test(url.searchParams.get('follow') ?? '') ? url.searchParams.get('follow') : 'auto';
320
+ const want = watching || url.searchParams.get('want') === 'screen' ? 'screen' : 'play';
311
321
  const device = ['phone', 'desk', 'tv'].includes(url.searchParams.get('device')) ? url.searchParams.get('device') : undefined;
312
322
  const wsBase = `${url.protocol === 'https:' ? 'wss' : 'ws'}://${url.host}`;
313
323
  // The browser's room key (b: what a kick holds out) and the play page's ticket (t: a game that is not public) ride
@@ -316,12 +326,13 @@ async function gameDocument(request, env, url, game, meta, cat) {
316
326
  const t = TICKET_TEXT.test(url.searchParams.get('t') ?? '') ? url.searchParams.get('t') : null;
317
327
  const cfg = {
318
328
  v: 1,
319
- url: `${wsBase}/${game}/__net?room=${encodeURIComponent(room)}${b ? `&b=${b}` : ''}${t ? `&t=${encodeURIComponent(t)}` : ''}`,
329
+ url: `${wsBase}/${game}/__net?room=${encodeURIComponent(room)}${b ? `&b=${b}` : ''}${t ? `&t=${encodeURIComponent(t)}` : ''}${watching ? '&w=1' : ''}`,
320
330
  room,
321
331
  ...(url.searchParams.get('k') ? { token: url.searchParams.get('k').slice(0, 128) } : {}),
322
332
  ...(url.searchParams.get('name') ? { name: url.searchParams.get('name').slice(0, 24) } : {}),
323
333
  ...(device ? { device } : {}),
324
334
  want,
335
+ ...(watching ? { watch: true, follow: /^\d+$/.test(follow) ? Number(follow) : follow, watchPolicy: policy } : {}),
325
336
  debug: url.searchParams.get('debug') === '1',
326
337
  ...(meta?.movement ? { movement: meta.movement } : {}),
327
338
  // game.json "saves": the play shell around this frame answers @homie-rocks/studio/saves (saves/SAVES.md).
@@ -619,7 +630,7 @@ async function route(request, env, ctx) {
619
630
  const max = seatsFor(meta, settings);
620
631
  const lobby = () => env.LOBBY.get(env.LOBBY.idFromName(game));
621
632
  if (sub === 'invite') return redeemInvite(request, env, url, cat, meta, settings);
622
- const door = launch !== 'public' && ['', 'live', 'credits', 'tv', 'play', 'api/lobby'].includes(sub) ? await accessOf(request, env, game, launch) : { ok: true, sub: null, owner: false };
633
+ const door = launch !== 'public' && ['', 'live', 'credits', 'tv', 'play', 'watch', 'api/lobby', 'api/watch'].includes(sub) ? await accessOf(request, env, game, launch) : { ok: true, sub: null, owner: false };
623
634
  const shut = () => gatePage(cat, meta, launch, { code: url.searchParams.get('invite') ?? '' });
624
635
  if (sub === '') {
625
636
  if (!door.ok) return shut();
@@ -672,6 +683,28 @@ async function route(request, env, ctx) {
672
683
  shareDaily(cat, url, ctx);
673
684
  return playPage(cat, meta, { ticket, owner, launch });
674
685
  }
686
+ if (sub === 'watch') {
687
+ // The same door as Play: a game that is private or an invite-only beta is watched only by whoever may play it.
688
+ const asked = url.searchParams.get('room');
689
+ if (asked !== null && !ROOM_ID.test(asked)) return badRoomPage(cat, meta, asked, { watch: true });
690
+ if (!door.ok) return shut();
691
+ const policy = watchOf(meta);
692
+ if (policy === 'off') return noWatchPage(cat, meta);
693
+ const owner = door.owner || (launch === 'public' && await isOwner(request, env));
694
+ const holder = joinHolders(door.sub ?? (owner ? 'o' : null), await accountSub(request, env));
695
+ const ticket = holder ? await ticketFor(env, game, holder) : null;
696
+ await countVisit(request, env, ctx, game, 'watch');
697
+ shareDaily(cat, url, ctx);
698
+ return watchPage(cat, meta, { room: asked, ticket, policy });
699
+ }
700
+ if (sub === 'api/watch') {
701
+ if (!door.ok || watchOf(meta) === 'off') return json({ ok: false, error: 'not-found' }, 404);
702
+ // The busiest public room now, to watch; nothing is reserved (a watcher takes no seat).
703
+ const not = String(url.searchParams.get('not') ?? '').split(',').filter((r) => ROOM_ID.test(r)).slice(0, 4).join(',');
704
+ let best = null;
705
+ try { best = await (await lobby().fetch(`https://lobby/busiest${not ? `?not=${encodeURIComponent(not)}` : ''}`)).json(); } catch { best = null; }
706
+ return json({ ok: true, game, room: best?.room ?? null, players: best?.players ?? 0, max }, 200, { 'cache-control': 'no-store' });
707
+ }
675
708
  if (sub === 'api/lobby') {
676
709
  if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
677
710
  // A browser held out of a room asks for any other (`not`), so the Lobby never sends it back there.
@@ -686,8 +719,13 @@ async function route(request, env, ctx) {
686
719
  const who = url.searchParams.get('t') ? await ticketSub(env, game, url.searchParams.get('t')) : null;
687
720
  if (launch !== 'public' && !(await ticketAllows(env, who, launch))) return new Response('this game is not open to you', { status: 403 });
688
721
  const b = BROWSER_KEY.test(url.searchParams.get('b') ?? '') ? url.searchParams.get('b') : '';
722
+ // A watcher's socket (section 16): what the game lets watchers see goes with every socket, so a helper that only
723
+ // says `watch` in its hello is held to it too; a game that cannot be watched refuses a watch door's socket here.
724
+ const policy = watchOf(meta);
725
+ const w = sub === '__net' && url.searchParams.get('w') === '1';
726
+ if (w && policy === 'off') return new Response('this game cannot be watched', { status: 403 });
689
727
  const stub = env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
690
- const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}${b ? `&b=${b}` : ''}${who ? `&via=${encodeURIComponent(who)}` : ''}`;
728
+ const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}${b ? `&b=${b}` : ''}${who ? `&via=${encodeURIComponent(who)}` : ''}${sub === '__net' ? `&wp=${policy}${w ? '&w=1' : ''}` : ''}`;
691
729
  return stub.fetch(new Request(target, request));
692
730
  }
693
731
  // The same knock, relative to the game's own page: the same answer (see notAHomie).
@@ -795,6 +833,9 @@ export class Table {
795
833
  browser: url.searchParams.get('b') || null,
796
834
  via: via || null,
797
835
  player: holders(via).find((p) => p.startsWith('p-'))?.slice(2) ?? null,
836
+ // A socket opened through the game's watch door, and what the game lets watchers see (section 16).
837
+ watch: url.searchParams.get('w') === '1',
838
+ watchPolicy: WATCH_POLICIES.includes(url.searchParams.get('wp')) ? url.searchParams.get('wp') : 'follow',
798
839
  // House QA and `homie-studio check` mark their browsers; their rooms, rounds and peaks are not the studio's numbers.
799
840
  qa: isQa(request),
800
841
  send: (text) => { try { server.send(text); } catch { /* closed */ } },
@@ -1018,6 +1059,17 @@ export class Lobby {
1018
1059
  return json({ ok: true });
1019
1060
  }
1020
1061
  if (url.pathname === '/rooms') return json({ rooms: [...this.rooms.values()].map(({ name, players, at }) => ({ name, players, at })) });
1062
+ if (url.pathname === '/busiest') {
1063
+ // The public room with the most players now, for a watcher (section 16): it reserves nothing, as a watcher takes
1064
+ // no seat. A closed room, or one named in `not`, is never the answer.
1065
+ const not = new Set(String(url.searchParams.get('not') ?? '').split(',').filter(Boolean));
1066
+ let best = null;
1067
+ for (const r of this.rooms.values()) {
1068
+ if (r.players <= 0 || not.has(r.name) || (this.closed.get(r.name) ?? 0) > now) continue;
1069
+ if (!best || r.players > best.players || (r.players === best.players && r.name < best.name)) best = r;
1070
+ }
1071
+ return json({ room: best?.name ?? null, players: best?.players ?? 0 });
1072
+ }
1021
1073
  if (url.pathname === '/office') {
1022
1074
  // The owner's office: every room with somebody seated in the last 3 minutes, public or named, and closed ones.
1023
1075
  for (const [name, r] of this.seen) if (now - r.at > 180_000) this.seen.delete(name);