@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.
- package/README.md +12 -5
- package/lib/build.mjs +3 -0
- package/lib/scaffold.mjs +9 -1
- package/lib/template-history.json +27 -0
- package/netplay/NETPLAY.md +119 -13
- package/netplay/netplay.ts +291 -5
- package/package.json +1 -1
- package/port/hud.ts +10 -7
- package/port/room.ts +15 -0
- package/port/touch.ts +4 -1
- package/site/SITE.md +28 -0
- package/starters/ember-vale/src/main.ts +37 -5
- package/starters/gem-rush/src/main.ts +45 -15
- package/worker/index.mjs +60 -8
- package/worker/pages.mjs +417 -4
- package/worker/room.mjs +65 -6
- package/worker/site.mjs +29 -4
- package/worker/stats.mjs +4 -3
- package/worker/version.mjs +1 -1
package/worker/pages.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* worker/site.mjs. Every name a player typed is escaped.
|
|
4
4
|
*/
|
|
5
5
|
import { esc, layout } from './site.mjs';
|
|
6
|
+
import { NET_PALETTE } from './room.mjs';
|
|
6
7
|
import { SAVES_SHELL_CSS, SAVES_SHELL_JS } from './saves-shell.mjs';
|
|
7
8
|
|
|
8
9
|
export { homePage, mediaIndexPage, notFoundPage, songPage, videoPage } from './site.mjs';
|
|
@@ -21,14 +22,24 @@ export function frameAncestors(cat) {
|
|
|
21
22
|
return ["'self'", ...extra].join(' ');
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
/** What a play
|
|
25
|
-
export function badRoomPage(cat, g, raw, { screen = false } = {}) {
|
|
25
|
+
/** What a play, big-screen or watch page answers to a room code it cannot use: said on the page, never a silent public room. */
|
|
26
|
+
export function badRoomPage(cat, g, raw, { screen = false, watch = false } = {}) {
|
|
26
27
|
const shown = String(raw ?? '').slice(0, 64);
|
|
27
28
|
return layout(cat, {
|
|
28
29
|
title: `That room link does not work · ${g.name}`, page: 'bad-room', status: 400,
|
|
29
30
|
main: `<header class="head"><p class="kicker">${esc(g.name)}</p><h1>That room link does not work</h1>
|
|
30
|
-
<p class="lead">The room code in this link${shown ? ` (<span class="addr">${esc(shown)}${String(raw).length > 64 ? '…' : ''}</span>)` : ''} is not one a room can have: a code is 1 to 32 letters, digits, hyphens or underscores. Ask whoever sent it for the link again, or play in a public room.</p>
|
|
31
|
-
<div class="keys"><a class="btn" href="/${esc(g.id)}/${screen ? 'tv' : 'play'}" data-play
|
|
31
|
+
<p class="lead">The room code in this link${shown ? ` (<span class="addr">${esc(shown)}${String(raw).length > 64 ? '…' : ''}</span>)` : ''} is not one a room can have: a code is 1 to 32 letters, digits, hyphens or underscores. Ask whoever sent it for the link again, or ${watch ? 'watch' : 'play in'} a public room.</p>
|
|
32
|
+
<div class="keys"><a class="btn" href="/${esc(g.id)}/${watch ? 'watch' : screen ? 'tv' : 'play'}"${watch ? '' : ' data-play'}>${watch ? 'Watch a public room' : 'Join a public room'}</a><a class="ghost" href="/${esc(g.id)}/">Back to ${esc(g.name)}</a></div></header>`,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A game whose game.json says `"watch": false` has no watch door: the page says so and offers Play. */
|
|
37
|
+
export function noWatchPage(cat, g) {
|
|
38
|
+
return layout(cat, {
|
|
39
|
+
title: `${g.name} is not shown to watchers`, page: 'no-watch', status: 404,
|
|
40
|
+
main: `<header class="head"><p class="kicker">${esc(g.name)}</p><h1>${esc(g.name)} is played, not watched</h1>
|
|
41
|
+
<p class="lead">Its rooms are not shown to watchers: what each player sees stays theirs. Press Play and you are in a room, with bots in the empty seats.</p>
|
|
42
|
+
<div class="keys"><a class="btn" href="/${esc(g.id)}/play" data-play>Play ${esc(g.name)}</a><a class="ghost" href="/${esc(g.id)}/">Back to ${esc(g.name)}</a></div></header>`,
|
|
32
43
|
});
|
|
33
44
|
}
|
|
34
45
|
|
|
@@ -429,6 +440,7 @@ const SHELL_JS = String.raw`(function () {
|
|
|
429
440
|
var bits = [];
|
|
430
441
|
if (n !== null) bits.push(n + (n === 1 ? ' player' : ' players') + ' here');
|
|
431
442
|
if (f && f.counts && f.counts.bots) bits.push(f.counts.bots + ' bots');
|
|
443
|
+
if (f && f.counts && f.counts.watchers) bits.push(f.counts.watchers + ' watching');
|
|
432
444
|
if (state.closed) bits.push(state.closed === 'replaced' ? 'opened in another tab' : 'reconnecting');
|
|
433
445
|
say(bits.join(' · ') || 'joining…');
|
|
434
446
|
var count = ui && ui.querySelector('[data-room-count]');
|
|
@@ -598,3 +610,404 @@ const OWNER_JS = String.raw`(function () {
|
|
|
598
610
|
setInterval(render, 1500);
|
|
599
611
|
window.__owner = { open: open, render: render };
|
|
600
612
|
}());`;
|
|
613
|
+
|
|
614
|
+
/*
|
|
615
|
+
* WATCH ANY PLAYER (0.15.0, NETPLAY.md section 16). /<game>/watch?room=<room>[&follow=<seat>|auto|overview] is the
|
|
616
|
+
* game itself, rendered by this browser as a watcher: a screen that never takes a seat. A strip of the room's players
|
|
617
|
+
* switches whose view it renders (a tap, keys 1-9, A for Auto, O for the whole room); Auto follows the action. The
|
|
618
|
+
* game draws the followed player's camera and HUD (its netplay helper's `viewSeat`); a game that does not shows its
|
|
619
|
+
* overview, and the strip says so. A game with hidden hands or roles says `"watch": "overview"` in game.json: its
|
|
620
|
+
* watchers see the whole room only. A private or invite-only game is watched only by whoever may play it (the door
|
|
621
|
+
* is the play door's). Every name a player typed is set with textContent.
|
|
622
|
+
*/
|
|
623
|
+
export function watchPage(cat, g, { room = null, ticket = null, policy = 'follow' } = {}) {
|
|
624
|
+
const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
|
|
625
|
+
const hot = /^#[0-9a-f]{3,8}$/i.test(accent) ? accent : '#ffcf5a';
|
|
626
|
+
const boot = { game: g.id, name: g.name, policy, palette: NET_PALETTE, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}) };
|
|
627
|
+
const css = `:root{--hot:${hot}}${WATCH_CSS}`;
|
|
628
|
+
return layoutless(`Watch ${g.name}`, `
|
|
629
|
+
<header class="wtop" data-top>
|
|
630
|
+
<span class="live" data-live><i aria-hidden="true"></i><b>Live</b></span>
|
|
631
|
+
<span class="what"><b>${esc(g.name)}</b><span data-room-label>finding a room…</span></span>
|
|
632
|
+
<span class="clock" data-clock hidden></span>
|
|
633
|
+
<span class="grow"></span>
|
|
634
|
+
<span class="eyes" data-eyes hidden title="Watching this room"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.6-6.5 10-6.5S22 12 22 12s-3.6 6.5-10 6.5S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg><span data-eyes-n></span><span class="eyes-w"> watching</span></span>
|
|
635
|
+
<a class="playb" data-playb href="/${esc(g.id)}/play"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 4.8v14.4a1 1 0 0 0 1.5.86l12-7.2a1 1 0 0 0 0-1.72l-12-7.2A1 1 0 0 0 7 4.8Z" fill="currentColor"/></svg><span>Play</span></a>
|
|
636
|
+
</header>
|
|
637
|
+
<main class="stage"><iframe class="game" title="${esc(g.name)}, live" tabindex="-1" sandbox="allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups" allow="fullscreen *; autoplay *; gamepad *"></iframe></main>
|
|
638
|
+
<footer class="dock">
|
|
639
|
+
<div class="caption" data-caption role="status" aria-live="polite"><b data-cap-head>Joining the room…</b><span data-cap-sub></span></div>
|
|
640
|
+
<nav class="strip" data-strip role="toolbar" aria-label="Whose view to watch"></nav>
|
|
641
|
+
</footer>
|
|
642
|
+
<div class="wnote" data-note hidden></div>
|
|
643
|
+
<script>window.__HOMIE_WATCH=${JSON.stringify(boot).replace(/</g, '\\u003c')};</script>
|
|
644
|
+
<script>${WATCH_JS}</script>`, css, frameAncestors(cat));
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/*
|
|
648
|
+
* The watch page is a broadcast: a band above the game (live, the game and room, the clock, who is watching, Play),
|
|
649
|
+
* the game in between, and a dock below (whose view this is, and the players to switch between). Nothing covers the
|
|
650
|
+
* game's own picture or HUD: every game draws its clock and board where it likes.
|
|
651
|
+
*/
|
|
652
|
+
const WATCH_CSS = `
|
|
653
|
+
html, body { height: 100%; margin: 0; overflow: hidden; overscroll-behavior: none; background: #05070d; color: #eef1f8; -webkit-tap-highlight-color: transparent; }
|
|
654
|
+
body { --u: clamp(12px, calc(0.8vmin + 7px), 22px); --band: #080b14; --line: rgba(255,255,255,.08); display: flex; flex-direction: column; font: 600 var(--u)/1.25 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
655
|
+
body.big { --u: clamp(16px, calc(1vmin + 6px), 32px); }
|
|
656
|
+
.stage { position: relative; flex: 1; min-height: 0; background: #04060c; }
|
|
657
|
+
iframe.game { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #04060c; }
|
|
658
|
+
.wtop { flex: none; display: flex; align-items: center; gap: calc(var(--u) * .7); padding: max(calc(var(--u) * .5), env(safe-area-inset-top)) max(calc(var(--u) * .9), env(safe-area-inset-right)) calc(var(--u) * .5) max(calc(var(--u) * .9), env(safe-area-inset-left)); background: linear-gradient(#0b0f1a, var(--band)); border-bottom: 1px solid var(--line); min-width: 0; }
|
|
659
|
+
.grow { flex: 1; }
|
|
660
|
+
.live { flex: none; display: inline-flex; align-items: center; gap: .45em; padding: .3em .7em .3em .6em; border-radius: 999px; background: rgba(255,59,92,.16); border: 1px solid rgba(255,90,120,.5); font-weight: 800; font-size: .74em; letter-spacing: .14em; text-transform: uppercase; color: #ffd9e0; }
|
|
661
|
+
.live i { width: .62em; height: .62em; border-radius: 50%; background: #ff3b5c; box-shadow: 0 0 10px #ff3b5c; animation: pulse 1.6s ease-in-out infinite; }
|
|
662
|
+
.live.off { background: rgba(255,255,255,.06); border-color: rgba(255,255,255,.18); color: #aab3c7; }
|
|
663
|
+
.live.off i { background: #6c7489; box-shadow: none; animation: none; }
|
|
664
|
+
@keyframes pulse { 50% { opacity: .35; } }
|
|
665
|
+
@media (prefers-reduced-motion: reduce) { .live i { animation: none; } }
|
|
666
|
+
.what { display: flex; align-items: baseline; gap: .55em; min-width: 0; }
|
|
667
|
+
.what b { font-weight: 800; font-size: 1.05em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
668
|
+
.what span { color: #9aa3b7; font-size: .86em; font-weight: 600; white-space: nowrap; }
|
|
669
|
+
.clock { flex: none; padding: .26em .7em; border-radius: 9px; background: rgba(255,255,255,.05); border: 1px solid var(--line); font-variant-numeric: tabular-nums; font-weight: 800; white-space: nowrap; }
|
|
670
|
+
.eyes { flex: none; display: inline-flex; align-items: center; gap: .35em; color: #b9c1d3; font-variant-numeric: tabular-nums; font-size: .9em; }
|
|
671
|
+
.eyes svg { width: 1.2em; height: 1.2em; }
|
|
672
|
+
.playb { flex: none; display: inline-flex; align-items: center; gap: .45em; min-height: 2.4em; padding: 0 1em 0 .8em; border-radius: 11px; background: var(--hot); color: #0b0b10; font-weight: 800; text-decoration: none; }
|
|
673
|
+
.playb:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
|
|
674
|
+
.playb svg { width: 1em; height: 1em; }
|
|
675
|
+
.dock { flex: none; display: flex; align-items: center; gap: calc(var(--u) * .9); padding: calc(var(--u) * .55) max(calc(var(--u) * .9), env(safe-area-inset-right)) max(calc(var(--u) * .55), env(safe-area-inset-bottom)) max(calc(var(--u) * .9), env(safe-area-inset-left)); background: linear-gradient(var(--band), #0b0f1a); border-top: 1px solid var(--line); min-width: 0; }
|
|
676
|
+
.caption { flex: 0 1 auto; min-width: 0; max-width: 34%; display: flex; flex-direction: column; gap: .1em; }
|
|
677
|
+
.caption b { font-weight: 800; font-size: 1.08em; display: flex; align-items: center; gap: .5em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
678
|
+
.caption b .dot { width: .72em; height: .72em; border-radius: 50%; flex: none; }
|
|
679
|
+
.caption span { color: #9aa3b7; font-size: .82em; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
680
|
+
.strip { flex: 1; min-width: 0; display: flex; gap: calc(var(--u) * .45); overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; touch-action: pan-x; padding: 3px 2px; }
|
|
681
|
+
.strip::-webkit-scrollbar { display: none; }
|
|
682
|
+
.strip > :first-child { margin-left: auto; }
|
|
683
|
+
.strip > :last-child { margin-right: auto; }
|
|
684
|
+
.chip { flex: none; display: inline-flex; align-items: center; gap: .5em; min-height: 2.6em; max-width: 15em; padding: 0 .9em 0 .5em; border-radius: 999px; border: 1px solid rgba(255,255,255,.14); background: rgba(255,255,255,.035); color: #e8ecf5; font: inherit; font-weight: 700; cursor: pointer; touch-action: manipulation; transition: border-color .2s, background .2s; }
|
|
685
|
+
.chip:hover { border-color: rgba(255,255,255,.4); }
|
|
686
|
+
.chip:focus-visible { outline: 2px solid var(--hot); outline-offset: 2px; }
|
|
687
|
+
.chip[aria-pressed="true"] { border-color: var(--hot); background: color-mix(in srgb, var(--hot) 16%, transparent); box-shadow: inset 0 0 0 1px var(--hot); }
|
|
688
|
+
.chip.auto-on { border-color: color-mix(in srgb, var(--hot) 55%, rgba(255,255,255,.2)); }
|
|
689
|
+
.chip[disabled] { cursor: default; padding-left: .8em; }
|
|
690
|
+
.chip[disabled]:hover { border-color: rgba(255,255,255,.14); }
|
|
691
|
+
.chip .k { display: inline-grid; place-items: center; min-width: 1.45em; height: 1.45em; padding: 0 .2em; border-radius: 6px; background: rgba(255,255,255,.08); color: #9aa3b7; font-size: .76em; font-weight: 800; box-sizing: border-box; }
|
|
692
|
+
.chip .dot { width: .8em; height: .8em; border-radius: 50%; flex: none; }
|
|
693
|
+
.chip .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
694
|
+
.chip .sc { color: #9aa3b7; font-variant-numeric: tabular-nums; font-weight: 800; }
|
|
695
|
+
.chip .sc.lead { color: var(--hot); }
|
|
696
|
+
.chip svg { width: 1.1em; height: 1.1em; flex: none; }
|
|
697
|
+
.chip .tag { font-size: .68em; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--hot); }
|
|
698
|
+
.hint { flex: none; align-self: center; color: #7d8699; font-size: .76em; font-weight: 600; padding: 0 .2em; white-space: nowrap; }
|
|
699
|
+
@media (hover: none) { .chip .k, .hint { display: none; } }
|
|
700
|
+
/* A phone held upright: the caption over the strip, the strip scrolling sideways. */
|
|
701
|
+
@media (max-width: 640px) {
|
|
702
|
+
.dock { flex-direction: column; align-items: stretch; gap: calc(var(--u) * .45); }
|
|
703
|
+
.caption { max-width: none; align-items: center; text-align: center; }
|
|
704
|
+
.chip { max-width: 10.5em; min-height: 2.75em; }
|
|
705
|
+
.what span, .eyes-w { display: none; }
|
|
706
|
+
}
|
|
707
|
+
/* Not much height (a phone on its side): thinner bands. */
|
|
708
|
+
@media (max-height: 460px) {
|
|
709
|
+
.wtop, .dock { padding-top: 4px; padding-bottom: max(4px, env(safe-area-inset-bottom)); }
|
|
710
|
+
.caption span { display: none; }
|
|
711
|
+
.chip { min-height: 2.3em; }
|
|
712
|
+
}
|
|
713
|
+
body.idle { cursor: none; }
|
|
714
|
+
.wnote { position: fixed; inset: 0; z-index: 20; display: grid; place-items: center; padding: 20px; background: rgba(4,6,12,.8); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); }
|
|
715
|
+
.wnote .box { box-sizing: border-box; width: min(440px, 100%); padding: calc(var(--u) * 1.5); border-radius: 20px; background: #0d111c; border: 1px solid rgba(255,255,255,.14); box-shadow: 0 24px 70px rgba(0,0,0,.55); }
|
|
716
|
+
.wnote h1 { margin: 0 0 .4em; font-size: 1.5em; letter-spacing: -.01em; }
|
|
717
|
+
.wnote p { margin: 0 0 .7em; color: #c3cad9; font-weight: 500; line-height: 1.45; }
|
|
718
|
+
.wnote .acts { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 1em; }
|
|
719
|
+
.wnote .acts a { display: inline-flex; align-items: center; min-height: 2.9em; padding: 0 1.1em; border-radius: 12px; text-decoration: none; font-weight: 800; color: #eef1f8; border: 1px solid rgba(255,255,255,.18); }
|
|
720
|
+
.wnote .acts a.primary { background: var(--hot); color: #0b0b10; border-color: transparent; }
|
|
721
|
+
[hidden] { display: none !important; }`;
|
|
722
|
+
|
|
723
|
+
/* The watch page's script (the site's own origin; the game runs in the sandboxed frame as a watcher). */
|
|
724
|
+
const WATCH_JS = String.raw`(function () {
|
|
725
|
+
'use strict';
|
|
726
|
+
var boot = window.__HOMIE_WATCH;
|
|
727
|
+
var params = new URLSearchParams(location.search);
|
|
728
|
+
var hand = params.get('hand');
|
|
729
|
+
var device = hand === 'phone' || hand === 'desk' || hand === 'tv' ? hand : Math.min(innerWidth, innerHeight) <= 540 ? 'phone' : 'desk';
|
|
730
|
+
var frame = document.querySelector('iframe.game');
|
|
731
|
+
var q1 = function (s) { return document.querySelector(s); };
|
|
732
|
+
var strip = q1('[data-strip]'), note = q1('[data-note]');
|
|
733
|
+
var capHead = q1('[data-cap-head]'), capSub = q1('[data-cap-sub]'), clockEl = q1('[data-clock]'), liveEl = q1('[data-live]');
|
|
734
|
+
var palette = Array.isArray(boot.palette) ? boot.palette : [];
|
|
735
|
+
var asked = params.get('follow');
|
|
736
|
+
var first = /^\d{1,2}$/.test(asked || '') ? Number(asked) : asked === 'overview' ? null : 'auto';
|
|
737
|
+
// What the page knows: the room's facts (its watch socket), and what the game in the frame says it renders.
|
|
738
|
+
var state = { game: boot.game, room: null, facts: null, offset: 0, round: null, view: null, follows: null, canFollow: boot.policy !== 'overview', why: boot.policy === 'overview' ? 'overview' : null, scores: null, asked: first, attached: false, notice: null, emptySince: 0 };
|
|
739
|
+
window.__watch = state;
|
|
740
|
+
function rnd() {
|
|
741
|
+
try { var a = new Uint8Array(16); crypto.getRandomValues(a); var s = ''; for (var i = 0; i < a.length; i++) s += String.fromCharCode(a[i]); return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
|
|
742
|
+
catch (e) { var r = ''; while (r.length < 22) r += Math.random().toString(36).slice(2); return r.slice(0, 22); }
|
|
743
|
+
}
|
|
744
|
+
// The browser's room key, as the play page keeps it: a kick holds a browser out of watching too, and a browser
|
|
745
|
+
// that holds a seat in this room watches it in the overview only (section 16).
|
|
746
|
+
var roomKey = null;
|
|
747
|
+
try { roomKey = localStorage.getItem('homie-b'); if (!/^[A-Za-z0-9_-]{16,43}$/.test(roomKey || '')) { roomKey = rnd(); localStorage.setItem('homie-b', roomKey); } } catch (e) { roomKey = rnd(); }
|
|
748
|
+
|
|
749
|
+
function sizeUp() {
|
|
750
|
+
var big = hand === 'tv' || (Math.min(innerWidth, innerHeight) >= 900 && !(window.matchMedia && matchMedia('(pointer: coarse)').matches));
|
|
751
|
+
document.body.classList.toggle('big', big);
|
|
752
|
+
}
|
|
753
|
+
sizeUp();
|
|
754
|
+
addEventListener('resize', sizeUp);
|
|
755
|
+
function labelOf(room) { var m = /^pub-(\d+)$/.exec(room || ''); return m ? 'Room ' + m[1] : (room || ''); }
|
|
756
|
+
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined && text !== null) e.textContent = String(text); return e; }
|
|
757
|
+
function svg(path) { var s = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('aria-hidden', 'true'); s.setAttribute('fill', 'none'); s.setAttribute('stroke', 'currentColor'); s.setAttribute('stroke-width', '2'); s.setAttribute('stroke-linecap', 'round'); s.setAttribute('stroke-linejoin', 'round'); s.innerHTML = path; return s; }
|
|
758
|
+
var AUTO_ICON = '<path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5 18 18M6 18l2.5-2.5M15.5 8.5 18 6"/>';
|
|
759
|
+
var ROOM_ICON = '<rect x="3" y="3" width="7.5" height="7.5" rx="1.5"/><rect x="13.5" y="3" width="7.5" height="7.5" rx="1.5"/><rect x="3" y="13.5" width="7.5" height="7.5" rx="1.5"/><rect x="13.5" y="13.5" width="7.5" height="7.5" rx="1.5"/>';
|
|
760
|
+
|
|
761
|
+
// The players, in seat order: the strip's order, and what keys 1-9 pick (the n-th player).
|
|
762
|
+
function players() {
|
|
763
|
+
var f = state.facts;
|
|
764
|
+
var list = f && Array.isArray(f.clients) ? f.clients.filter(function (c) { return c && typeof c.seat === 'number' && !c.watch; }) : [];
|
|
765
|
+
return list.sort(function (a, b) { return a.seat - b.seat; });
|
|
766
|
+
}
|
|
767
|
+
function nameOf(seat) { var p = players().filter(function (c) { return c.seat === seat; })[0]; return p ? String(p.name || 'A player') : null; }
|
|
768
|
+
function colourOf(p) { return palette[(Number(p.colour) || 0) % (palette.length || 1)] || '#e8ecf5'; }
|
|
769
|
+
// Choosing whose view: the game decides it can (its helper said so), and the room lets this browser.
|
|
770
|
+
function canChoose() { return state.follows === true && state.canFollow; }
|
|
771
|
+
|
|
772
|
+
function follow(target) {
|
|
773
|
+
if (!canChoose() || !frame.contentWindow) return;
|
|
774
|
+
state.asked = target;
|
|
775
|
+
try { frame.contentWindow.postMessage({ t: 'homie-watch', follow: target }, '*'); } catch (e) {}
|
|
776
|
+
paint();
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// The address follows the view, so a copied link watches the same player.
|
|
780
|
+
function syncUrl() {
|
|
781
|
+
try {
|
|
782
|
+
var v = state.view;
|
|
783
|
+
var f = !v ? null : v.following === 'auto' ? 'auto' : v.following === null ? 'overview' : String(v.following);
|
|
784
|
+
if (params.get('room') !== state.room || (f && params.get('follow') !== f)) {
|
|
785
|
+
params.set('room', state.room);
|
|
786
|
+
if (f) params.set('follow', f); else params.delete('follow');
|
|
787
|
+
params.delete('not');
|
|
788
|
+
history.replaceState(history.state, '', location.pathname + '?' + params.toString() + location.hash);
|
|
789
|
+
}
|
|
790
|
+
} catch (e) {}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function start(room) {
|
|
794
|
+
state.room = room;
|
|
795
|
+
syncUrl();
|
|
796
|
+
q1('[data-room-label]').textContent = labelOf(room);
|
|
797
|
+
q1('[data-playb]').href = '/' + boot.game + '/play?room=' + encodeURIComponent(room);
|
|
798
|
+
document.title = 'Watch ' + boot.name + ' · ' + labelOf(room);
|
|
799
|
+
var q = new URLSearchParams({ room: room, watch: '1', device: device, b: roomKey });
|
|
800
|
+
q.set('follow', first === 'auto' ? 'auto' : first === null ? 'overview' : String(first));
|
|
801
|
+
if (boot.t) q.set('t', boot.t);
|
|
802
|
+
if (params.get('debug') === '1') q.set('debug', '1');
|
|
803
|
+
['cam', 'view'].forEach(function (k) { var v = params.get(k); if (v && /^[A-Za-z0-9_-]{1,16}$/.test(v)) q.set(k, v); });
|
|
804
|
+
frame.src = '/' + boot.game + '/__game/?' + q.toString();
|
|
805
|
+
addEventListener('message', function (ev) {
|
|
806
|
+
if (ev.source !== frame.contentWindow) return;
|
|
807
|
+
var m = ev.data;
|
|
808
|
+
if (!m || typeof m !== 'object' || m.t !== 'homie-net') return;
|
|
809
|
+
if (m.what === 'attached') {
|
|
810
|
+
state.attached = true;
|
|
811
|
+
// A helper from before revision 5 never says what it renders: after a moment, the room's overview it is.
|
|
812
|
+
setTimeout(function () { if (state.follows === null) { state.follows = false; paint(); } }, 4000);
|
|
813
|
+
}
|
|
814
|
+
if (m.what === 'view') {
|
|
815
|
+
state.view = { seat: typeof m.seat === 'number' ? m.seat : null, following: m.following === 'auto' || typeof m.following === 'number' ? m.following : null, why: String(m.why || '') };
|
|
816
|
+
state.follows = m.follows === true;
|
|
817
|
+
state.canFollow = m.canFollow !== false;
|
|
818
|
+
state.why = typeof m.whyNot === 'string' ? m.whyNot : null;
|
|
819
|
+
syncUrl();
|
|
820
|
+
}
|
|
821
|
+
if (m.what === 'scores' && Array.isArray(m.scores)) {
|
|
822
|
+
var sc = {}; m.scores.forEach(function (r) { if (r && typeof r.seat === 'number' && isFinite(r.score)) sc[r.seat] = Number(r.score); });
|
|
823
|
+
state.scores = sc;
|
|
824
|
+
}
|
|
825
|
+
if (m.what === 'round' && m.round) state.round = m.round;
|
|
826
|
+
if (m.what === 'closed') {
|
|
827
|
+
if (m.why === 'kicked' || m.why === 'room-closed') shut(m.why === 'kicked' ? 'kicked' : 'closed', m);
|
|
828
|
+
else if (m.why === 'watch-off') shut('off', m);
|
|
829
|
+
else if (m.why === 'room-full') shut('full', m);
|
|
830
|
+
}
|
|
831
|
+
paint();
|
|
832
|
+
});
|
|
833
|
+
watch(room);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function watch(room) {
|
|
837
|
+
if (state.notice && state.notice.final) return;
|
|
838
|
+
var ws;
|
|
839
|
+
var extra = '&b=' + encodeURIComponent(roomKey) + (boot.t ? '&t=' + encodeURIComponent(boot.t) : '');
|
|
840
|
+
try { ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/' + boot.game + '/__watch?room=' + encodeURIComponent(room) + extra); }
|
|
841
|
+
catch (e) { setTimeout(function () { watch(room); }, 2000); return; }
|
|
842
|
+
state.socket = ws;
|
|
843
|
+
ws.onmessage = function (ev) {
|
|
844
|
+
var m; try { m = JSON.parse(ev.data); } catch (e) { return; }
|
|
845
|
+
if (!m || typeof m !== 'object') return;
|
|
846
|
+
if (m.t === 'kicked') { shut('kicked', m); return; }
|
|
847
|
+
if (m.t === 'closed') { shut('closed', m); return; }
|
|
848
|
+
if (m.t !== 'net') return;
|
|
849
|
+
state.facts = m;
|
|
850
|
+
if (typeof m.st === 'number') state.offset = m.st - Date.now();
|
|
851
|
+
if (m.round) state.round = m.round;
|
|
852
|
+
paint();
|
|
853
|
+
};
|
|
854
|
+
ws.onclose = function () { if (!(state.notice && state.notice.final)) setTimeout(function () { watch(room); }, 1500); };
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// A room that ended, a game that is not watched, a kick: the page says so plainly and offers the way on.
|
|
858
|
+
function shut(kind, m) {
|
|
859
|
+
if (state.notice && state.notice.kind === kind) return;
|
|
860
|
+
var final = kind !== 'empty';
|
|
861
|
+
state.notice = { kind: kind, final: final };
|
|
862
|
+
if (final) { try { frame.src = 'about:blank'; } catch (e) {} if (state.socket) { try { state.socket.close(); } catch (e) {} } }
|
|
863
|
+
note.textContent = '';
|
|
864
|
+
var box = el('div', 'box');
|
|
865
|
+
var label = labelOf(state.room);
|
|
866
|
+
var heads = { empty: 'Everyone has left ' + label, kicked: 'You were removed from this room', closed: 'This room is closed', off: boot.name + ' is played, not watched', full: label + ' is full of watchers', none: 'Nobody is playing ' + boot.name + ' right now' };
|
|
867
|
+
var lines = {
|
|
868
|
+
empty: 'The players went on their way. Watch another room, or press Play: you start at once, with bots in the empty seats.',
|
|
869
|
+
kicked: (m && m.message) || 'The studio removed you from this room.',
|
|
870
|
+
closed: (m && m.message) || 'The studio closed this room.',
|
|
871
|
+
off: 'Its rooms are not shown to watchers. Press Play and you are in one.',
|
|
872
|
+
full: 'Every place to watch it from is taken. Try the busiest other room.',
|
|
873
|
+
none: 'This page finds a room the moment somebody starts one. Or press Play, and people who come to watch will see you.',
|
|
874
|
+
};
|
|
875
|
+
box.appendChild(el('h1', '', heads[kind] || heads.closed));
|
|
876
|
+
box.appendChild(el('p', '', lines[kind] || lines.closed));
|
|
877
|
+
var acts = el('div', 'acts');
|
|
878
|
+
var other = el('a', 'primary', kind === 'none' || kind === 'off' ? 'Play ' + boot.name : 'Watch another room');
|
|
879
|
+
other.href = kind === 'none' || kind === 'off' ? '/' + boot.game + '/play' : '/' + boot.game + '/watch?not=' + encodeURIComponent(state.room || '');
|
|
880
|
+
acts.appendChild(other);
|
|
881
|
+
if (kind !== 'none' && kind !== 'off') { var play = el('a', '', 'Play'); play.href = '/' + boot.game + '/play'; acts.appendChild(play); }
|
|
882
|
+
var back = el('a', '', 'Back to ' + boot.name); back.href = '/' + boot.game + '/'; acts.appendChild(back);
|
|
883
|
+
box.appendChild(acts);
|
|
884
|
+
note.appendChild(box);
|
|
885
|
+
note.hidden = false;
|
|
886
|
+
liveEl.classList.add('off');
|
|
887
|
+
}
|
|
888
|
+
function unshut() { if (state.notice && !state.notice.final) { state.notice = null; note.hidden = true; note.textContent = ''; liveEl.classList.remove('off'); } }
|
|
889
|
+
|
|
890
|
+
var drawn = '';
|
|
891
|
+
function paintStrip() {
|
|
892
|
+
var list = players();
|
|
893
|
+
var choose = canChoose();
|
|
894
|
+
var v = state.view || { seat: null, following: state.asked, why: '' };
|
|
895
|
+
var scores = state.scores || {};
|
|
896
|
+
var lead = null; var best = -Infinity;
|
|
897
|
+
list.forEach(function (p) { var s = scores[p.seat]; if (typeof s === 'number' && s > best) { best = s; lead = p.seat; } });
|
|
898
|
+
var sig = JSON.stringify([choose, v.seat, v.following, list.map(function (p) { return [p.seat, p.name, p.colour, scores[p.seat]]; }), lead, state.follows]);
|
|
899
|
+
if (sig === drawn) return;
|
|
900
|
+
drawn = sig;
|
|
901
|
+
strip.textContent = '';
|
|
902
|
+
function chip(target, key, build, pressed, extraCls) {
|
|
903
|
+
var b = el('button', 'chip' + (extraCls ? ' ' + extraCls : ''));
|
|
904
|
+
b.type = 'button';
|
|
905
|
+
b.setAttribute('aria-pressed', pressed ? 'true' : 'false');
|
|
906
|
+
if (!choose) { b.disabled = true; b.setAttribute('aria-disabled', 'true'); }
|
|
907
|
+
if (key) b.appendChild(el('span', 'k', key));
|
|
908
|
+
build(b);
|
|
909
|
+
b.addEventListener('click', function () { follow(target); });
|
|
910
|
+
strip.appendChild(b);
|
|
911
|
+
return b;
|
|
912
|
+
}
|
|
913
|
+
if (choose) chip('auto', 'A', function (b) { b.appendChild(svg(AUTO_ICON)); b.appendChild(el('span', 'nm', 'Auto')); }, v.following === 'auto');
|
|
914
|
+
list.forEach(function (p, i) {
|
|
915
|
+
var on = choose && v.following === p.seat;
|
|
916
|
+
var auto = choose && v.following === 'auto' && v.seat === p.seat;
|
|
917
|
+
chip(p.seat, i < 9 ? String(i + 1) : '', function (b) {
|
|
918
|
+
var dot = el('i', 'dot'); dot.style.background = colourOf(p); b.appendChild(dot);
|
|
919
|
+
b.appendChild(el('span', 'nm', p.name || 'A player'));
|
|
920
|
+
if (auto) b.appendChild(el('span', 'tag', 'auto'));
|
|
921
|
+
if (typeof scores[p.seat] === 'number') b.appendChild(el('span', 'sc' + (p.seat === lead && list.length > 1 ? ' lead' : ''), scores[p.seat]));
|
|
922
|
+
b.setAttribute('aria-label', (on || auto ? 'Watching ' : 'Watch ') + (p.name || 'a player'));
|
|
923
|
+
}, on, auto ? 'auto-on' : '');
|
|
924
|
+
});
|
|
925
|
+
if (choose) chip(null, 'O', function (b) { b.appendChild(svg(ROOM_ICON)); b.appendChild(el('span', 'nm', 'Whole room')); }, v.following === null);
|
|
926
|
+
if (choose && device !== 'phone') strip.appendChild(el('span', 'hint', '1–9 · A · O'));
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function paint() {
|
|
930
|
+
var f = state.facts;
|
|
931
|
+
var list = players();
|
|
932
|
+
// The caption: whose view this is, and why it is the whole room when it is.
|
|
933
|
+
var v = state.view;
|
|
934
|
+
var head = 'Watching the whole room';
|
|
935
|
+
var sub = '';
|
|
936
|
+
var dotColour = null;
|
|
937
|
+
if (!state.attached || state.follows === null) { head = 'Joining ' + labelOf(state.room) + '…'; }
|
|
938
|
+
else if (!state.follows) sub = boot.name + ' shows watchers the whole room';
|
|
939
|
+
else if (!state.canFollow && state.why === 'seated-here') sub = 'You are playing in this room in another tab, so this one shows the whole room';
|
|
940
|
+
else if (!state.canFollow) sub = boot.name + ' keeps each player’s view to themselves';
|
|
941
|
+
else if (v && v.seat !== null) {
|
|
942
|
+
var who = list.filter(function (p) { return p.seat === v.seat; })[0];
|
|
943
|
+
head = 'Watching ' + (who ? who.name || 'a player' : 'a player');
|
|
944
|
+
dotColour = who ? colourOf(who) : null;
|
|
945
|
+
sub = v.following === 'auto' ? 'Auto · following the action' : '';
|
|
946
|
+
} else if (v && v.following === 'auto') sub = list.length ? 'Auto · waiting for the action' : 'Auto · nobody is playing yet';
|
|
947
|
+
capHead.textContent = '';
|
|
948
|
+
if (dotColour) { var d = el('span', 'dot'); d.style.background = dotColour; capHead.appendChild(d); }
|
|
949
|
+
capHead.appendChild(document.createTextNode(head));
|
|
950
|
+
capSub.textContent = sub;
|
|
951
|
+
capSub.hidden = !sub;
|
|
952
|
+
// The round's clock, in the relay's time.
|
|
953
|
+
var r = state.round;
|
|
954
|
+
if (r && isFinite(r.endsAt)) {
|
|
955
|
+
var left = Math.max(0, Math.ceil((r.endsAt - (Date.now() + state.offset)) / 1000));
|
|
956
|
+
clockEl.textContent = r.phase === 'over' ? 'Next round in ' + left : 'Round ' + r.n + ' · ' + Math.floor(left / 60) + ':' + String(left % 60).padStart(2, '0');
|
|
957
|
+
clockEl.hidden = false;
|
|
958
|
+
} else clockEl.hidden = true;
|
|
959
|
+
var eyes = f && f.counts ? Number(f.counts.watchers) || 0 : 0;
|
|
960
|
+
q1('[data-eyes]').hidden = eyes < 1;
|
|
961
|
+
q1('[data-eyes-n]').textContent = String(eyes);
|
|
962
|
+
paintStrip();
|
|
963
|
+
// Everyone left: say so after a moment (a reload is not a departure), and take it back if they return.
|
|
964
|
+
var people = f && f.counts ? Number(f.counts.players) || 0 : null;
|
|
965
|
+
if (people === 0 && state.attached) {
|
|
966
|
+
if (!state.emptySince) state.emptySince = Date.now();
|
|
967
|
+
if (Date.now() - state.emptySince > 6000 && !state.notice) shut('empty');
|
|
968
|
+
} else { state.emptySince = 0; if (people) unshut(); }
|
|
969
|
+
}
|
|
970
|
+
setInterval(paint, 500);
|
|
971
|
+
|
|
972
|
+
// Keys: 1-9 the n-th player, A Auto, O (or 0) the whole room, arrows the next or previous player, F full screen.
|
|
973
|
+
addEventListener('keydown', function (e) {
|
|
974
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
975
|
+
var k = e.key;
|
|
976
|
+
var list = players();
|
|
977
|
+
wake();
|
|
978
|
+
if (/^[1-9]$/.test(k)) { var p = list[Number(k) - 1]; if (p) follow(p.seat); e.preventDefault(); return; }
|
|
979
|
+
if (k === 'a' || k === 'A') { follow('auto'); return; }
|
|
980
|
+
if (k === 'o' || k === 'O' || k === '0') { follow(null); return; }
|
|
981
|
+
if (k === 'ArrowRight' || k === 'ArrowLeft') {
|
|
982
|
+
if (!list.length) return;
|
|
983
|
+
var cur = state.view && typeof state.view.seat === 'number' ? state.view.seat : null;
|
|
984
|
+
var i = list.map(function (p) { return p.seat; }).indexOf(cur);
|
|
985
|
+
var n = k === 'ArrowRight' ? (i + 1) % list.length : (i <= 0 ? list.length - 1 : i - 1);
|
|
986
|
+
follow(list[n].seat); e.preventDefault(); return;
|
|
987
|
+
}
|
|
988
|
+
if (k === 'f' || k === 'F') { try { if (document.fullscreenElement) document.exitFullscreen(); else document.documentElement.requestFullscreen(); } catch (e2) {} }
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
// The bars get out of the way while nobody touches anything; any touch, move or key brings them back.
|
|
992
|
+
var idleTimer = null;
|
|
993
|
+
function wake() { document.body.classList.remove('idle'); clearTimeout(idleTimer); idleTimer = setTimeout(function () { document.body.classList.add('idle'); }, 5000); }
|
|
994
|
+
['pointermove', 'pointerdown', 'touchstart', 'wheel'].forEach(function (t) { addEventListener(t, wake, { passive: true }); });
|
|
995
|
+
wake();
|
|
996
|
+
// The frame never keeps the keys: a click in the game hands them back to the page.
|
|
997
|
+
addEventListener('blur', function () { setTimeout(function () { if (document.activeElement === frame) { try { frame.blur(); window.focus(); } catch (e) {} } }, 0); });
|
|
998
|
+
|
|
999
|
+
// A named room watches that room; no room: the busiest public room now (nothing is reserved), or a wait for one.
|
|
1000
|
+
var askedRoom = params.get('room');
|
|
1001
|
+
if (boot.room) start(boot.room);
|
|
1002
|
+
else if (askedRoom !== null && /^[A-Za-z0-9_-]{1,32}$/.test(askedRoom)) start(askedRoom);
|
|
1003
|
+
else {
|
|
1004
|
+
var not = /^[A-Za-z0-9_,-]{1,140}$/.test(params.get('not') || '') ? params.get('not') : '';
|
|
1005
|
+
var look = function () {
|
|
1006
|
+
fetch('/' + boot.game + '/api/watch' + (not ? '?not=' + encodeURIComponent(not) : ''), { cache: 'no-store' })
|
|
1007
|
+
.then(function (r) { return r.json(); })
|
|
1008
|
+
.then(function (j) { if (j && j.room) { unshut(); state.notice = null; note.hidden = true; start(j.room); } else { if (!state.notice) shut('none'); state.notice.final = false; setTimeout(look, 10000); } })
|
|
1009
|
+
.catch(function () { setTimeout(look, 10000); });
|
|
1010
|
+
};
|
|
1011
|
+
look();
|
|
1012
|
+
}
|
|
1013
|
+
}());`;
|
package/worker/room.mjs
CHANGED
|
@@ -16,6 +16,11 @@
|
|
|
16
16
|
* allocated or the host changes, plus the latest checkpoint/round/
|
|
17
17
|
* roster/state at most every 30 s. That is what lets a Worker deploy
|
|
18
18
|
* (which evicts every Durable Object) keep seats, bodies and round.
|
|
19
|
+
*
|
|
20
|
+
* WATCHERS (revision 5, NETPLAY.md section 16): a screen that came to watch
|
|
21
|
+
* (`hello.watch`, or a socket the Worker opened through a game's watch door,
|
|
22
|
+
* `conn.watch`) never takes a seat, hosts only when no player can, and is told
|
|
23
|
+
* whether it may follow one player's view (`welcome.watch`, then `watch` frames).
|
|
19
24
|
* =============================================================================
|
|
20
25
|
*/
|
|
21
26
|
|
|
@@ -51,6 +56,16 @@ export const STATE_CAPS = Object.freeze({ keys: 64, bytes: 65536 });
|
|
|
51
56
|
export const CONTROL_LIMITS = Object.freeze({ minutes: 24 * 60, announce: 280, announceSeconds: 3600, bans: 200, mutes: 200, message: 200 });
|
|
52
57
|
/** What a muted player cannot say: an `ev` whose kind starts with one of these (a game's chat, quick lines, emotes). */
|
|
53
58
|
export const SPEECH = /^(?:say|chat|emote)/i;
|
|
59
|
+
/**
|
|
60
|
+
* The contract's 12 player colours: a peer's `colour` (its seat % 12) is an index into this list. The watch page's
|
|
61
|
+
* player strip draws them; a game that colours its players the same way matches it (Gem Rush does).
|
|
62
|
+
*/
|
|
63
|
+
export const NET_PALETTE = Object.freeze(['#8fe36a', '#ffd166', '#ef6f6c', '#6cb4ee', '#c792ea', '#f4a261', '#2ec4b6', '#ff8fab', '#a7c957', '#e9c46a', '#90e0ef', '#f28482']);
|
|
64
|
+
/**
|
|
65
|
+
* What a game lets its watchers see (game.json "watch", passed by the Worker as `conn.watchPolicy`): `follow` (any
|
|
66
|
+
* player's view, the default), `overview` (the whole room only: a game with hidden hands or hidden roles), `off`.
|
|
67
|
+
*/
|
|
68
|
+
export const WATCH_POLICIES = Object.freeze(['follow', 'overview', 'off']);
|
|
54
69
|
const oneLine = (v, max) => String(v ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
|
|
55
70
|
/** A short tag for a browser's room key: enough for the owner to see "the same browser", never the key itself. */
|
|
56
71
|
export function browserTag(key) {
|
|
@@ -193,6 +208,8 @@ export class NetRoom {
|
|
|
193
208
|
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,
|
|
194
209
|
device: 'desk', want: 'play', canHost: true, hidden: false, waiting: false, joinedAt: now, lastSeen: now, lastSnapAt: 0,
|
|
195
210
|
rates: new Map(), drops: [], errAt: 0,
|
|
211
|
+
// A watcher (section 16): the Worker's word for a socket opened through a watch door, else the hello's own.
|
|
212
|
+
watch: conn.watch === true, policy: WATCH_POLICIES.includes(conn.watchPolicy) ? conn.watchPolicy : 'follow', follow: null, followWhy: null,
|
|
196
213
|
};
|
|
197
214
|
this.clients.set(c.id, c);
|
|
198
215
|
return {
|
|
@@ -223,7 +240,7 @@ export class NetRoom {
|
|
|
223
240
|
host() { return this.hostId ? this.clients.get(this.hostId) ?? null : null; }
|
|
224
241
|
hostRef() { const h = this.host(); return h ? { id: h.id, seat: h.seat } : null; }
|
|
225
242
|
roleOf(c) { return c.id === this.hostId ? 'host' : c.seat === null ? 'screen' : 'replica'; }
|
|
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 } : {}) }; }
|
|
243
|
+
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), ...(c.watch ? { watch: true } : {}), ...(this.muteOf(c) ? { muted: true } : {}) }; }
|
|
227
244
|
peers() { return this.live().map((c) => this.peer(c)); }
|
|
228
245
|
stateObject() { const o = {}; for (const [k, v] of this.state) o[k] = v.d; return o; }
|
|
229
246
|
|
|
@@ -417,11 +434,16 @@ export class NetRoom {
|
|
|
417
434
|
if (this.closedUntil > this.now()) return refuse('room-closed', this.closedWhy || 'The studio closed this room.', { until: this.closedUntil });
|
|
418
435
|
const ban = this.banOf(c, typeof m.token === 'string' ? m.token : '');
|
|
419
436
|
if (ban) return refuse('kicked', ban.message, { until: ban.until });
|
|
437
|
+
// A watcher of a game that cannot be watched (game.json "watch": false) is refused for good (section 16).
|
|
438
|
+
const watching = c.watch || m.watch === true;
|
|
439
|
+
if (watching && c.policy === 'off') return refuse('watch-off', 'this game cannot be watched; play it instead');
|
|
420
440
|
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`);
|
|
421
441
|
if (live.length >= this.maxPlayers + this.maxScreens) return refuse('room-full', 'this room is full; try another');
|
|
422
442
|
const now = this.now();
|
|
423
443
|
c.device = m.device === 'phone' || m.device === 'tv' ? m.device : 'desk';
|
|
424
|
-
|
|
444
|
+
// A watcher is a screen that never takes a seat, whatever else its hello says.
|
|
445
|
+
c.watch = watching;
|
|
446
|
+
c.want = watching || m.want === 'screen' ? 'screen' : 'play';
|
|
425
447
|
c.canHost = m.canHost !== false;
|
|
426
448
|
c.game = typeof m.game === 'string' ? m.game.slice(0, 64) : '';
|
|
427
449
|
c.typed = typeof m.name === 'string' ? m.name.replace(/\s+/g, ' ').trim().slice(0, 24) : '';
|
|
@@ -430,7 +452,7 @@ export class NetRoom {
|
|
|
430
452
|
this.reapSeats(now);
|
|
431
453
|
let full = false;
|
|
432
454
|
if (c.want === 'play' && !this.seatClient(c, typeof m.token === 'string' ? m.token : '')) { full = true; c.waiting = true; }
|
|
433
|
-
if (c.seat === null) c.name = c.typed || (c.want === 'screen' ? 'Screen' : 'Watcher');
|
|
455
|
+
if (c.seat === null) c.name = c.typed || (c.want === 'screen' && !c.watch ? 'Screen' : 'Watcher');
|
|
434
456
|
c.helloed = true;
|
|
435
457
|
c.joinedAt = now;
|
|
436
458
|
if (!this.openedAt) this.openedAt = now;
|
|
@@ -442,12 +464,14 @@ export class NetRoom {
|
|
|
442
464
|
let deposed = null;
|
|
443
465
|
const h = this.host();
|
|
444
466
|
const preferred = this.preferHost && now < this.preferHost.until ? this.preferHost.seat : undefined;
|
|
445
|
-
|
|
467
|
+
// A watcher hosts only a room nobody else can host (a player who can is elected first), and never deposes one.
|
|
468
|
+
const lastResort = c.watch && live.some((o) => o.canHost && !o.watch && !o.hidden);
|
|
469
|
+
if (c.canHost && !lastResort && (preferred === undefined || preferred === c.seat)) {
|
|
446
470
|
if (!h) {
|
|
447
471
|
role = 'host';
|
|
448
472
|
why = this.lastSnap || this.lastCkpt ? 'resumed' : 'first';
|
|
449
473
|
if (preferred !== undefined) this.preferHost = null;
|
|
450
|
-
} else if (h.hidden || now - h.lastSeen > this.silentMs) {
|
|
474
|
+
} else if (!c.watch && (h.hidden || now - h.lastSeen > this.silentMs)) {
|
|
451
475
|
// The host is a frozen phone (socket open, nothing sent): the newcomer runs the round now, not after a stall.
|
|
452
476
|
deposed = h;
|
|
453
477
|
role = 'host';
|
|
@@ -469,13 +493,16 @@ export class NetRoom {
|
|
|
469
493
|
...(role === 'host' ? { ckpt: this.lastCkpt } : {}),
|
|
470
494
|
...(full ? { full: true } : {}),
|
|
471
495
|
...(this.liveAnnouncement() ? { announce: this.liveAnnouncement() } : {}),
|
|
496
|
+
...(c.watch ? { watch: this.followFacts(c) } : {}),
|
|
472
497
|
});
|
|
473
498
|
for (const o of this.others(c)) this.send(o, { t: 'join', peer: this.peer(c) });
|
|
499
|
+
// A seat taken by a browser that also watches this room: its watching tab sees the overview from now on.
|
|
500
|
+
this.refreshWatchers(c);
|
|
474
501
|
if (deposed) {
|
|
475
502
|
this.send(deposed, { t: 'role', role: deposed.seat === null ? 'screen' : 'replica', why, host: this.hostRef(), peers: this.peers() });
|
|
476
503
|
for (const o of this.others(c)) if (o !== deposed) this.send(o, { t: 'host', host: this.hostRef(), why });
|
|
477
504
|
}
|
|
478
|
-
this.log({ ev: 'hello', room: this.code, id: c.id, seat: c.seat, device: c.device, want: c.want, role, why, full, resumed: typeof m.token === 'string' && m.token === c.token });
|
|
505
|
+
this.log({ ev: 'hello', room: this.code, id: c.id, seat: c.seat, device: c.device, want: c.want, role, why, full, resumed: typeof m.token === 'string' && m.token === c.token, ...(c.watch ? { watch: true } : {}) });
|
|
479
506
|
this.persist(now); // a new seat (or a new host) is written now, not on the next tick: a deploy can come any moment
|
|
480
507
|
this.tellWatchers();
|
|
481
508
|
}
|
|
@@ -539,10 +566,38 @@ export class NetRoom {
|
|
|
539
566
|
for (const o of this.others(c)) this.send(o, { t: 'join', peer: this.peer(c) });
|
|
540
567
|
this.log({ ev: 'seated', room: this.code, id: c.id, seat: c.seat });
|
|
541
568
|
this.persist();
|
|
569
|
+
this.refreshWatchers();
|
|
542
570
|
this.tellWatchers();
|
|
543
571
|
}
|
|
544
572
|
}
|
|
545
573
|
|
|
574
|
+
/* ------------------------------------------------------------ watchers (section 16) */
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Whether this watcher may follow one player's view, and if not, why: `overview` (the game shows watchers the
|
|
578
|
+
* whole room only) or `seated-here` (the same browser holds a seat in this room: a second tab is not a way to
|
|
579
|
+
* look over an opponent's shoulder). The stream is the same either way; this decides what the watcher's page
|
|
580
|
+
* offers and its helper renders.
|
|
581
|
+
*/
|
|
582
|
+
followFacts(c) {
|
|
583
|
+
if (c.policy === 'overview') return { follow: false, why: 'overview' };
|
|
584
|
+
if (c.browser && this.live().some((o) => o !== c && o.seat !== null && o.browser === c.browser)) return { follow: false, why: 'seated-here' };
|
|
585
|
+
return { follow: true };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** Tell every watcher whose right to follow changed (a seat taken or left by its own browser). */
|
|
589
|
+
refreshWatchers(except = null) {
|
|
590
|
+
for (const c of this.live()) {
|
|
591
|
+
if (!c.watch) continue;
|
|
592
|
+
const f = this.followFacts(c);
|
|
593
|
+
const why = f.why ?? null;
|
|
594
|
+
if (c === except || (c.follow === f.follow && c.followWhy === why)) { c.follow = f.follow; c.followWhy = why; continue; }
|
|
595
|
+
c.follow = f.follow;
|
|
596
|
+
c.followWhy = why;
|
|
597
|
+
this.send(c, { t: 'watch', ...f });
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
546
601
|
onClose(c, why) {
|
|
547
602
|
if (this.clients.get(c.id) !== c) return;
|
|
548
603
|
this.clients.delete(c.id);
|
|
@@ -560,6 +615,7 @@ export class NetRoom {
|
|
|
560
615
|
}
|
|
561
616
|
if (!this.live().length) this.emptySince = now;
|
|
562
617
|
this.seatWaiting();
|
|
618
|
+
if (c.seat !== null) this.refreshWatchers();
|
|
563
619
|
this.tellWatchers();
|
|
564
620
|
}
|
|
565
621
|
|
|
@@ -576,6 +632,8 @@ export class NetRoom {
|
|
|
576
632
|
.filter((c) => c.canHost && c.id !== excludeId)
|
|
577
633
|
.sort((a, b) => Number(a.hidden) - Number(b.hidden)
|
|
578
634
|
|| silent(a) - silent(b)
|
|
635
|
+
// A watcher is the host of last resort: any player who can host comes first (section 16).
|
|
636
|
+
|| Number(a.watch) - Number(b.watch)
|
|
579
637
|
|| tenure(b) - tenure(a)
|
|
580
638
|
|| (DEVICE_RANK[a.device] ?? 3) - (DEVICE_RANK[b.device] ?? 3)
|
|
581
639
|
|| a.joinedAt - b.joinedAt);
|
|
@@ -963,6 +1021,7 @@ export class NetRoom {
|
|
|
963
1021
|
players: live.filter((c) => c.seat !== null).length,
|
|
964
1022
|
screens: live.filter((c) => c.seat === null).length,
|
|
965
1023
|
waiting: live.filter((c) => c.waiting).length,
|
|
1024
|
+
watchers: live.filter((c) => c.watch).length,
|
|
966
1025
|
humans: slots.filter((s) => s && !s.bot).length,
|
|
967
1026
|
bots: slots.filter((s) => s && s.bot).length,
|
|
968
1027
|
maxPlayers: this.maxPlayers,
|