@homie-rocks/studio 0.14.3 → 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,15 +16,21 @@
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
26
31
  * knocks for a Homie box, so it stops knocking (a studio site is not a box)
27
32
  * /.well-known/homie-studio.json what the homie.rocks directory reads
33
+ * /games/<id>/source.json a public game's source for remixing, with its credit and licence (worker/license.mjs)
28
34
  * /media/<key> the studio's large media, from R2 (once `storage add` bound it), with byte ranges
29
35
  * /api/stats the studio's numbers, for its owner only (a read key, or the owner's page session)
30
36
  * /_studio/stats the owner's private stats page (one-time sign-in link from `homie-studio stats link`)
@@ -50,17 +56,17 @@
50
56
  * after `homie-studio storage add` (R2 needs a payment method on the account),
51
57
  * MEDIA. Everything else runs on Cloudflare's free Workers plan.
52
58
  *
53
- * 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
54
60
  * unchanged inside the Table, so a game that plays in `homie-studio dev`
55
61
  * plays the same way here.
56
62
  */
57
- import { NetRoom } from './room.mjs';
58
- 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';
59
65
  import { qrSvg } from './qr.mjs';
60
66
  import { SEAT_MAX, perAddress, seatsOf } from './seats.mjs';
61
67
  import {
62
68
  SITE_JS, atomFeed, creditsPage, customPage, gameCover, gameLanding, gamesPage, homePage, jsonFeed, mediaArt, mediaIndexPage,
63
- notFoundPage, postPage, postsPage, roomView, roomsPage, sectionsOf, songPage, videoPage,
69
+ notFoundPage, postPage, postsPage, roomView, roomsPage, sectionsOf, songPage, videoPage, watchOf,
64
70
  } from './site.mjs';
65
71
  import { count, countVisit, counter, isQa, onlyOf, ownerAllowed, playedByGame, playedThisWeek, rangeOf, readStats, today } from './stats.mjs';
66
72
  import { ownerRoutes } from './stats-page.mjs';
@@ -74,6 +80,7 @@ import {
74
80
  // every device, and the owner's own account (`homie-studio players owner`) counts as the owner.
75
81
  usePlayers(playerAccounts);
76
82
  import { STUDIO_VERSION_TAG } from './version.mjs';
83
+ import { licenseOf, remixAllowed, remixRow } from './license.mjs';
77
84
 
78
85
  export { SEAT_MAX } from './seats.mjs';
79
86
  /** For a studio whose Worker has player accounts: hand their server API to the back office once (worker/office.mjs). */
@@ -305,7 +312,12 @@ async function gameDocument(request, env, url, game, meta, cat) {
305
312
  const res = await env.ASSETS.fetch(new Request(`${url.origin}/games/${game}/index.html`));
306
313
  if (!res.ok) return notFoundPage(`No build of "${game}" on this site yet.`, cat);
307
314
  const room = asked ?? 'main';
308
- 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';
309
321
  const device = ['phone', 'desk', 'tv'].includes(url.searchParams.get('device')) ? url.searchParams.get('device') : undefined;
310
322
  const wsBase = `${url.protocol === 'https:' ? 'wss' : 'ws'}://${url.host}`;
311
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
@@ -314,12 +326,13 @@ async function gameDocument(request, env, url, game, meta, cat) {
314
326
  const t = TICKET_TEXT.test(url.searchParams.get('t') ?? '') ? url.searchParams.get('t') : null;
315
327
  const cfg = {
316
328
  v: 1,
317
- 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' : ''}`,
318
330
  room,
319
331
  ...(url.searchParams.get('k') ? { token: url.searchParams.get('k').slice(0, 128) } : {}),
320
332
  ...(url.searchParams.get('name') ? { name: url.searchParams.get('name').slice(0, 24) } : {}),
321
333
  ...(device ? { device } : {}),
322
334
  want,
335
+ ...(watching ? { watch: true, follow: /^\d+$/.test(follow) ? Number(follow) : follow, watchPolicy: policy } : {}),
323
336
  debug: url.searchParams.get('debug') === '1',
324
337
  ...(meta?.movement ? { movement: meta.movement } : {}),
325
338
  // game.json "saves": the play shell around this frame answers @homie-rocks/studio/saves (saves/SAVES.md).
@@ -463,12 +476,17 @@ async function route(request, env, ctx) {
463
476
  games: await Promise.all((cat.games ?? []).map(async (g) => {
464
477
  // The card picture is the landing's hero still (what the landing leads with), else the game's cover.
465
478
  const cover = gameCover(g);
466
- const remix = remixOf(g, await settingsOf(env));
479
+ // Open to remix: the owner's switch is on and the licence the owner picked allows it (worker/license.mjs).
480
+ const license = licenseOf(g.license);
481
+ const remix = remixOf(g, await settingsOf(env)) && remixAllowed(license);
482
+ const lineage = remixRow(g.remixOf);
467
483
  return {
468
484
  id: g.id, name: g.name, blurb: g.blurb ?? '', players: g.players ?? null, roundSeconds: g.roundSeconds ?? null,
469
485
  page: `${url.origin}/${g.id}/`, play: `${url.origin}/${g.id}/play`, cover: cover ? (cover.startsWith('/') ? `${url.origin}${cover}` : cover) : null,
470
486
  // The owner's remix switch (0.13.0): whether the source is open for other studios to remix, and where.
471
- remix, ...(remix ? { source: `${url.origin}/games/${g.id}/source.json` } : {}),
487
+ remix, ...(remix ? { source: `${url.origin}/games/${g.id}/source.json` } : {}), license,
488
+ // A remix names what it is a remix of (its game.json `remixOf`): "Remix of <name> by <studio>", linked.
489
+ ...(lineage ? { remixOf: lineage } : {}),
472
490
  // Shared only with `stats.share`: this game's own Play presses and rounds with people, this week.
473
491
  ...(byGame ? { played: byGame[g.id] ?? { days: 7, plays: 0, rounds: 0 } } : {}),
474
492
  };
@@ -507,7 +525,9 @@ async function route(request, env, ctx) {
507
525
  return path.endsWith('.xml') ? atomFeed(cat, posts, url.origin) : jsonFeed(cat, posts, url.origin);
508
526
  }
509
527
 
510
- // A game's remix source is served while the game is public and its owner has not withdrawn it (the remix switch).
528
+ // A game's remix source is served while the game is public and its owner has not withdrawn it (the remix switch),
529
+ // with who made it as this site says it (the studio's name, the game's name and page here) and the owner's licence
530
+ // (worker/license.mjs): the remix flow credits the first and refuses a game whose licence says no remix.
511
531
  const src = /^\/games\/([a-z0-9][a-z0-9-]{0,39})\/source\.json$/.exec(path);
512
532
  if (src) {
513
533
  const all = await getAll();
@@ -515,6 +535,16 @@ async function route(request, env, ctx) {
515
535
  if (meta) {
516
536
  const settings = await settingsOf(env, { fresh: true });
517
537
  if (launchOf(meta, settings, env) !== 'public' || !remixOf(meta, settings)) return json({ ok: false, error: 'not-shared', message: 'This game\'s source is not shared for remixing.' }, 404);
538
+ if (request.method === 'GET') {
539
+ const res = await env.ASSETS.fetch(new Request(`${url.origin}${path}`));
540
+ let body = null;
541
+ if (res.ok) { try { body = await res.json(); } catch { body = null; } }
542
+ if (body?.kind === 'homie-game-source' && body.files && typeof body.files === 'object') {
543
+ const credit = { studio: all.studio?.name ?? body.credit?.studio ?? null, game: meta.name ?? body.credit?.game ?? meta.id, page: `${url.origin}/${meta.id}/` };
544
+ const { files, ...head } = body;
545
+ return json({ ...head, credit, license: licenseOf(body.license ?? meta.license), files }, 200, { 'access-control-allow-origin': '*' });
546
+ }
547
+ }
518
548
  }
519
549
  }
520
550
 
@@ -600,7 +630,7 @@ async function route(request, env, ctx) {
600
630
  const max = seatsFor(meta, settings);
601
631
  const lobby = () => env.LOBBY.get(env.LOBBY.idFromName(game));
602
632
  if (sub === 'invite') return redeemInvite(request, env, url, cat, meta, settings);
603
- 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 };
604
634
  const shut = () => gatePage(cat, meta, launch, { code: url.searchParams.get('invite') ?? '' });
605
635
  if (sub === '') {
606
636
  if (!door.ok) return shut();
@@ -608,7 +638,7 @@ async function route(request, env, ctx) {
608
638
  shareDaily(cat, url, ctx);
609
639
  const { rooms, live } = await roomsOf(env, [meta]);
610
640
  const week = cat.studio?.stats?.share ? await weekOf(env, game) : null;
611
- return gameLanding(cat, meta, { origin: url.origin, rooms, playing: live[game] ?? 0, week });
641
+ return gameLanding(cat, meta, { origin: url.origin, rooms, playing: live[game] ?? 0, week, remix: launch === 'public' && remixOf(meta, settings) });
612
642
  }
613
643
  if (sub === 'live') {
614
644
  if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
@@ -653,6 +683,28 @@ async function route(request, env, ctx) {
653
683
  shareDaily(cat, url, ctx);
654
684
  return playPage(cat, meta, { ticket, owner, launch });
655
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
+ }
656
708
  if (sub === 'api/lobby') {
657
709
  if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
658
710
  // A browser held out of a room asks for any other (`not`), so the Lobby never sends it back there.
@@ -667,8 +719,13 @@ async function route(request, env, ctx) {
667
719
  const who = url.searchParams.get('t') ? await ticketSub(env, game, url.searchParams.get('t')) : null;
668
720
  if (launch !== 'public' && !(await ticketAllows(env, who, launch))) return new Response('this game is not open to you', { status: 403 });
669
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 });
670
727
  const stub = env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
671
- 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' : ''}` : ''}`;
672
729
  return stub.fetch(new Request(target, request));
673
730
  }
674
731
  // The same knock, relative to the game's own page: the same answer (see notAHomie).
@@ -776,6 +833,9 @@ export class Table {
776
833
  browser: url.searchParams.get('b') || null,
777
834
  via: via || null,
778
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',
779
839
  // House QA and `homie-studio check` mark their browsers; their rooms, rounds and peaks are not the studio's numbers.
780
840
  qa: isQa(request),
781
841
  send: (text) => { try { server.send(text); } catch { /* closed */ } },
@@ -999,6 +1059,17 @@ export class Lobby {
999
1059
  return json({ ok: true });
1000
1060
  }
1001
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
+ }
1002
1073
  if (url.pathname === '/office') {
1003
1074
  // The owner's office: every room with somebody seated in the last 3 minutes, public or named, and closed ones.
1004
1075
  for (const [name, r] of this.seen) if (now - r.at > 180_000) this.seen.delete(name);
@@ -0,0 +1,74 @@
1
+ /**
2
+ * A GAME'S SOURCE LICENCE AND LINEAGE, shared by the site Worker (worker/index.mjs, worker/site.mjs) and the CLI
3
+ * (lib/build.mjs, lib/studio.mjs).
4
+ *
5
+ * The licence is the owner's pick (game.json `"license"`), carried in the game's source.json beside who made it
6
+ * (`credit`: studio, game, page), so a remix knows what it may do and whom to credit:
7
+ *
8
+ * "remix-with-credit" the default: remix it, and the remix says "Remix of <game> by <studio>" with a link back
9
+ * "remix-freely" remix it; the credit is welcome, not asked for (the remix flow still writes it)
10
+ * "no-remix" the source can be read, not remixed: the remix flow refuses
11
+ *
12
+ * Either the word, or { "kind": "<word>", "spdx": "<SPDX id>" } to name a licence too (e.g. "MIT", "CC-BY-4.0"); a
13
+ * bare SPDX id ("MIT") is the default kind with that id. Anything else is the default.
14
+ *
15
+ * A remix's game.json `remixOf` (written by `homie-studio game remix`) is its lineage: the original's name, studio
16
+ * and page, shown on the remix's landing ("Remix of <game> by <studio>") and in its credits.
17
+ */
18
+ export const LICENSE_KINDS = Object.freeze({
19
+ 'remix-with-credit': 'Remix with credit',
20
+ 'remix-freely': 'Remix freely',
21
+ 'no-remix': 'Not for remixing',
22
+ });
23
+ export const DEFAULT_LICENSE = 'remix-with-credit';
24
+ const SPDX = /^[A-Za-z0-9][A-Za-z0-9.+-]{0,63}$/;
25
+
26
+ /** { kind, spdx } from what game.json (or a source.json) says. */
27
+ export function licenseOf(value) {
28
+ const v = typeof value === 'string'
29
+ ? (Object.hasOwn(LICENSE_KINDS, value.trim()) ? { kind: value.trim() } : { spdx: value })
30
+ : value && typeof value === 'object' ? value : {};
31
+ const kind = Object.hasOwn(LICENSE_KINDS, v.kind) ? v.kind : DEFAULT_LICENSE;
32
+ const spdx = typeof v.spdx === 'string' && SPDX.test(v.spdx.trim()) ? v.spdx.trim() : null;
33
+ return { kind, spdx };
34
+ }
35
+
36
+ /** "Remix with credit (MIT)". */
37
+ export function licenseLabel(value) {
38
+ const l = licenseOf(value);
39
+ return `${LICENSE_KINDS[l.kind]}${l.spdx ? ` (${l.spdx})` : ''}`;
40
+ }
41
+
42
+ /** Whether the owner lets the game be remixed. */
43
+ export const remixAllowed = (value) => licenseOf(value).kind !== 'no-remix';
44
+
45
+ const line = (v, max) => (typeof v === 'string' && v.trim() ? v.replace(/[\x00-\x1f\x7f]+/g, ' ').trim().slice(0, max) : null);
46
+ /** An https address, else null. */
47
+ export function httpsPage(v) {
48
+ try { const u = new URL(String(v ?? '')); return u.protocol === 'https:' ? u.href : null; } catch { return null; }
49
+ }
50
+
51
+ /** A game's page from its source address: https://<site>/games/<id>/source.json is https://<site>/<id>/. */
52
+ export function pageOfSource(source) {
53
+ try {
54
+ const u = new URL(String(source ?? ''));
55
+ const m = /^\/games\/([a-z0-9][a-z0-9-]{0,39})\/source\.json$/.exec(u.pathname);
56
+ return u.protocol === 'https:' && m ? `${u.origin}/${m[1]}/` : null;
57
+ } catch { return null; }
58
+ }
59
+
60
+ /**
61
+ * What a game.json `remixOf` may show: the original's name, studio and page (an https link; a remix made before
62
+ * 0.14.4 has only its source address, which names the page too), plain text only.
63
+ */
64
+ export function remixRow(r) {
65
+ if (!r || typeof r !== 'object') return null;
66
+ const name = line(r.name, 80);
67
+ return name ? { name, studio: line(r.studio, 80), page: httpsPage(r.page) ?? pageOfSource(r.source) } : null;
68
+ }
69
+
70
+ /** "Remix of Gem Rush by Night Owl Games" (the studio left out when the source named none). */
71
+ export function remixCredit(r) {
72
+ const row = remixRow(r);
73
+ return row ? `Remix of ${row.name}${row.studio ? ` by ${row.studio}` : ''}` : null;
74
+ }
@@ -157,7 +157,7 @@ export const OFFICE_SCRIPT = String.raw`(function () {
157
157
  act('/_studio/api/game', { game: g.id, maxPlayers: n >= g.seats ? null : n }, 'Rooms of ' + g.name + ' now hold ' + Math.min(n, g.seats) + '.');
158
158
  });
159
159
  add(box, st,
160
- add(el('label'), remix, el('span', '', g.remixBuilt ? 'Remixable' : 'Remixable (not in the build)')),
160
+ add(el('label'), remix, el('span', '', !g.remixBuilt ? 'Remixable (not in the build)' : g.license === 'no-remix' ? 'Source shared (its licence: not for remixing)' : 'Remixable')),
161
161
  add(el('label'), el('span', 'dim', 'Players per room'), max, el('span', 'faint', 'of ' + g.seats), setMax),
162
162
  add(el('span', 'faint'), document.createTextNode(g.launchFrom === 'game.json' ? 'state from game.json' : '')));
163
163
  return box;
package/worker/office.mjs CHANGED
@@ -41,6 +41,7 @@ import { SEAT_MAX, seatsOf } from './seats.mjs';
41
41
  import { OWNER_COOKIE, cookieValues, ownerAllowed, ownerSession } from './stats.mjs';
42
42
  import { esc, layout, notFoundPage } from './site.mjs';
43
43
  import { confirmPage, lockedPage, officePage } from './office-page.mjs';
44
+ import { licenseOf } from './license.mjs';
44
45
 
45
46
  export { OFFICE_MIGRATION, OFFICE_MIGRATION_FILE } from './office-schema.mjs';
46
47
 
@@ -455,7 +456,7 @@ export async function officeView(env, cat, origin) {
455
456
  rooms.sort((a, b) => b.players - a.players || a.room.localeCompare(b.room));
456
457
  return {
457
458
  id: g.id, name: g.name, launch, launchFrom: row?.launch ? 'office' : g.launch ? 'game.json' : 'default',
458
- remix: remixOf(g, settings), remixBuilt: g.landing?.source !== false,
459
+ remix: remixOf(g, settings), remixBuilt: g.landing?.source !== false, license: licenseOf(g.license).kind,
459
460
  seats: seatsOf(g), maxPlayers: max, maxSet: Number(row?.max_players) >= 1 ? Number(row.max_players) : null,
460
461
  play: `${origin}/${g.id}/play`, page: `${origin}/${g.id}/`,
461
462
  invites: launch === 'invite' || (await hasInvites(env, g.id)) ? await invitesOf(env, g.id, origin) : [],