@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.
- package/README.md +16 -7
- package/bin/homie-studio.mjs +1 -1
- package/lib/build.mjs +15 -4
- package/lib/mcp-tools.mjs +3 -2
- package/lib/scaffold.mjs +15 -1
- package/lib/studio.mjs +23 -3
- package/lib/template-history.json +54 -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 +41 -5
- package/starters/ember-vale/src/main.ts +37 -5
- package/starters/gem-rush/src/main.ts +45 -15
- package/worker/index.mjs +83 -12
- package/worker/license.mjs +74 -0
- package/worker/office-page.mjs +1 -1
- package/worker/office.mjs +2 -1
- package/worker/pages.mjs +417 -4
- package/worker/room.mjs +65 -6
- package/worker/site.mjs +50 -12
- package/worker/stats.mjs +4 -3
- package/worker/version.mjs +1 -1
|
@@ -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
|
-
|
|
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 ===
|
|
305
|
-
const
|
|
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
|
-
|
|
329
|
-
|
|
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
|
-
|
|
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
|
-
|
|
540
|
-
const
|
|
541
|
-
const
|
|
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
|
|
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
|
|
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:
|
|
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 =
|
|
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 ===
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/worker/office-page.mjs
CHANGED
|
@@ -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' : '
|
|
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) : [],
|