@homie-rocks/studio 0.31.1 → 0.32.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.
@@ -0,0 +1,346 @@
1
+ /*
2
+ * THE STANDALONE SHELL (standalone/STANDALONE.md): the one page of a game's desktop or phone app. It frames the
3
+ * built game exactly as the site's play page does (worker/pages.mjs SHELL_JS, which this mirrors), because the
4
+ * netplay helper only talks to a page around it. Nothing here is about one game: what differs is config.js
5
+ * (window.__HOMIE_APP), which `homie-studio standalone build` writes.
6
+ *
7
+ * It finds a room on the studio's own site (the Lobby, then the room's socket), and when it cannot, the game plays
8
+ * offline with its bots. It never signs anybody in, keeps no cloud saves and opens no shop: the frame is this page's
9
+ * own origin, so the game's saves and settings stay on this device.
10
+ */
11
+ (function () {
12
+ 'use strict';
13
+ var app = window.__HOMIE_APP || {};
14
+ var game = String(app.game || 'game');
15
+ var name = String(app.name || game);
16
+ var site = String(app.site || '').replace(/\/+$/, '');
17
+ var ver = typeof app.ver === 'string' && /^[A-Za-z0-9._-]{1,32}$/.test(app.ver) ? app.ver : '';
18
+ var ROOM = /^[A-Za-z0-9_-]{1,32}$/;
19
+ var frame = document.querySelector('iframe.game');
20
+ var device = Math.min(innerWidth, innerHeight) <= 540 ? 'phone' : 'desk';
21
+ var state = { game: game, room: null, online: false, device: device, attached: false, seat: null, link: null, closed: null, stale: null, outdated: false, finding: false, notice: null, starts: 0, net: null };
22
+ window.__shell = state;
23
+ try { document.title = name; frame.title = name; } catch (e) {}
24
+ // The game's own palette (its style.json), as on the play page: the button wears its paper and text.
25
+ try {
26
+ var c = app.colours;
27
+ var hex = /^#[0-9a-f]{6}$/i;
28
+ if (c && hex.test(c.paper) && hex.test(c.text)) { document.documentElement.style.setProperty('--paper', c.paper + 'e6'); document.documentElement.style.setProperty('--text', c.text); }
29
+ if (c && hex.test(c.hot)) document.documentElement.style.setProperty('--hot', c.hot);
30
+ } catch (e) {}
31
+
32
+ // This device's room key: random, kept in this app's own storage, and sent only to this studio's rooms. It is
33
+ // what an owner's kick holds out of a room for a while; it says nothing about who the player is.
34
+ function rnd() {
35
+ 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(/=+$/, ''); }
36
+ catch (e) { var r = ''; while (r.length < 22) r += Math.random().toString(36).slice(2); return r.slice(0, 22); }
37
+ }
38
+ var roomKey = null;
39
+ 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(); }
40
+
41
+ // A room the player chose (a friend's code, a private room of their own) is remembered; a Quick play room is not.
42
+ var REMEMBER = 'homie-app.' + game + '.room';
43
+ function remembered() { try { var r = localStorage.getItem(REMEMBER); return ROOM.test(r || '') ? r : null; } catch (e) { return null; } }
44
+ function remember(room) { try { if (room) localStorage.setItem(REMEMBER, room); else localStorage.removeItem(REMEMBER); } catch (e) {} }
45
+
46
+ var ui = document.querySelector('[data-room-ui]');
47
+ var toggle = ui.querySelector('[data-share-toggle]');
48
+ var sheet = ui.querySelector('[data-share-sheet]');
49
+ var toast = document.querySelector('[data-toast]');
50
+ var noticeEl = document.querySelector('[data-notice]');
51
+ var dimTimer = null;
52
+ // Where the button sits on THIS device (game.json screen.share), so it never covers the game's own HUD.
53
+ function place() {
54
+ if (!app.share) return;
55
+ var key = device === 'phone' ? (innerWidth > innerHeight ? 'sideways' : 'phone') : 'desk';
56
+ var p = app.share[key] || app.share.desk;
57
+ if (!p) return;
58
+ ui.className = 'room at-' + p.at;
59
+ ui.style.setProperty('--dx', (p.x || 0) + 'px');
60
+ ui.style.setProperty('--dy', (p.y || 0) + 'px');
61
+ if (p.label === false) toggle.classList.add('icon'); else toggle.classList.remove('icon');
62
+ state.share = { device: key, at: p.at, x: p.x || 0, y: p.y || 0, label: p.label !== false };
63
+ }
64
+ place();
65
+ addEventListener('resize', place);
66
+ function labelOf(room) { var m = /^pub-(\d+)$/.exec(room); return m ? 'Room ' + m[1] : room; }
67
+ function flash(text, tap) {
68
+ toast.textContent = text; toast.hidden = false; toast.onclick = tap || null;
69
+ if (tap) toast.classList.add('tap'); else toast.classList.remove('tap');
70
+ clearTimeout(flash.t);
71
+ flash.t = setTimeout(function () { toast.hidden = true; toast.onclick = null; }, tap ? 12000 : 2600);
72
+ }
73
+ function wake() {
74
+ toggle.classList.remove('dim');
75
+ clearTimeout(dimTimer);
76
+ dimTimer = setTimeout(function () { if (sheet.hidden && state.online && (!state.link || state.link.state === 'online')) toggle.classList.add('dim'); }, 6000);
77
+ }
78
+ function open(on) { sheet.hidden = !on; toggle.setAttribute('aria-expanded', on ? 'true' : 'false'); wake(); tellRects(); if (!on) { try { frame.focus(); } catch (e) {} } }
79
+ function copy(text, done) {
80
+ var ok = function () { flash(done || 'Link copied'); };
81
+ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(ok, function () { flash(text); });
82
+ else flash(text);
83
+ }
84
+ /** The room's address on the studio's site: a friend opens it in any browser and lands in this room. */
85
+ function linkOf(room) { return site + '/' + game + '/play?room=' + encodeURIComponent(room); }
86
+
87
+ // What the button and its sheet say: the room, or plainly that this copy plays offline.
88
+ function paint() {
89
+ var alone = !state.online || (state.link && state.link.state === 'alone');
90
+ var word = !state.starts ? 'Finding a room…' : alone ? 'Playing offline · Try again' : state.link && state.link.state === 'reconnecting' ? 'Reconnecting…' : labelOf(state.room);
91
+ ui.querySelector('[data-room-code]').textContent = word;
92
+ ui.querySelector('[data-room-label]').textContent = state.online ? labelOf(state.room) : 'Offline';
93
+ ui.querySelector('[data-room-state]').textContent = !site ? 'This copy has no online address' : alone ? 'No room answered' : '';
94
+ // A copy the site has outgrown cannot invite anybody: a friend's browser would land on the newer version, which
95
+ // this copy cannot join. The line about updating stands where the invite was.
96
+ ui.querySelector('[data-room-link]').textContent = state.outdated ? STALE : state.online ? linkOf(state.room).replace(/^https?:\/\//, '') : 'Your game and its bots are on this device.';
97
+ ui.querySelector('[data-invite]').hidden = !state.online || state.outdated;
98
+ ui.querySelector('[data-quick]').textContent = alone ? 'Try again' : 'Quick play';
99
+ if (alone) ui.querySelector('[data-quick]').classList.add('primary'); else ui.querySelector('[data-quick]').classList.remove('primary');
100
+ ui.querySelector('[data-offline]').hidden = !state.online;
101
+ wake();
102
+ tellRects();
103
+ }
104
+
105
+ /** Which public room to join: the studio's Lobby, within this copy's own build of the game (`gv`). Any failure: null. */
106
+ var avoid = [];
107
+ function lobby() {
108
+ if (!site || typeof fetch !== 'function') return Promise.resolve(null);
109
+ var q = [];
110
+ if (ver) q.push('gv=' + encodeURIComponent(ver));
111
+ if (avoid.length) q.push('not=' + avoid.slice(-4).map(encodeURIComponent).join(','));
112
+ var opts = { method: 'POST' };
113
+ try { if (typeof AbortController === 'function') { var ac = new AbortController(); opts.signal = ac.signal; setTimeout(function () { try { ac.abort(); } catch (e) {} }, 6000); } } catch (e) {}
114
+ return fetch(site + '/' + game + '/api/lobby' + (q.length ? '?' + q.join('&') : ''), opts)
115
+ .then(function (r) { return r.json(); })
116
+ .then(function (j) { return j && typeof j.room === 'string' && ROOM.test(j.room) ? j.room : null; })
117
+ .catch(function () { return null; });
118
+ }
119
+
120
+ var KEY = null;
121
+ /*
122
+ * The seat's token is kept in this app's own storage, not for the visit only: an app that is closed and opened
123
+ * again comes back to its seat, where a fresh one would meet its own body still standing in the room.
124
+ */
125
+ function tokenOf(key) { try { var t = localStorage.getItem(key); return typeof t === 'string' && t.length > 0 && t.length <= 256 ? t : null; } catch (e) { return null; } }
126
+ /** Start the game in `room`, or offline (null): what the helper reads as HOMIE_NET, then the frame. */
127
+ function start(room) {
128
+ state.room = room; state.online = Boolean(room); state.attached = false; state.seat = null;
129
+ state.link = null; state.closed = null; state.stale = null; state.finding = false;
130
+ KEY = room ? 'homie-net.' + game + '.' + room + '.play' : null;
131
+ var token = KEY ? tokenOf(KEY) : null;
132
+ var cfg = {
133
+ v: 1,
134
+ url: room ? site.replace(/^http/, 'ws') + '/' + game + '/__net?room=' + encodeURIComponent(room) + '&b=' + roomKey + (ver ? '&gv=' + encodeURIComponent(ver) : '') : '',
135
+ params: app.params && typeof app.params === 'object' ? app.params : {},
136
+ prefs: true, device: device, want: 'play', debug: false, chatOff: true, bubbleOff: true, app: true,
137
+ };
138
+ if (room) cfg.room = room;
139
+ if (ver) cfg.ver = ver;
140
+ if (token) cfg.token = token;
141
+ if (app.movement) cfg.movement = app.movement;
142
+ state.net = cfg;
143
+ window.__HOMIE_APP_NET = cfg;
144
+ state.starts += 1;
145
+ // The same address again does not load a frame again: every start after the first names its turn.
146
+ turn = state.starts > 1 ? '?start=' + state.starts : '';
147
+ frame.src = 'game/index.html' + turn;
148
+ paint();
149
+ }
150
+ /** The turn the frame was last told to load: a message from the page before it is not about this room. */
151
+ var turn = '';
152
+ function current() { try { return frame.contentWindow.location.search === turn; } catch (e) { return false; } }
153
+ /*
154
+ * Ask the Lobby, and never keep the player at a blank screen for it: with no answer in a second and a half the
155
+ * game starts offline, and a room that arrives after that is offered ("Back online · Join"), never switched to
156
+ * under the player. One search at a time: a second press while one is out does nothing.
157
+ */
158
+ var WAIT = 1500;
159
+ function find() {
160
+ if (state.finding) return;
161
+ state.finding = true;
162
+ var asked = state.starts;
163
+ // Asked for by somebody already playing offline ("Try again"): their round goes on while the Lobby is asked.
164
+ if (asked > 0 && !state.online) {
165
+ lobby().then(function (room) {
166
+ state.finding = false;
167
+ if (state.starts !== asked) return;
168
+ if (room) start(room); else { flash('No room answered: still playing offline.'); paint(); }
169
+ });
170
+ return;
171
+ }
172
+ var late = false;
173
+ var timer = setTimeout(function () { if (state.finding && state.starts === asked) { late = true; start(null); state.finding = true; } }, WAIT);
174
+ lobby().then(function (room) {
175
+ clearTimeout(timer);
176
+ if (!late) { if (state.starts === asked) start(room); else state.finding = false; return; }
177
+ state.finding = false;
178
+ // The player may have chosen a room meanwhile: only somebody still offline is offered this one.
179
+ if (room && !state.online) flash('Back online · Join', function () { toast.hidden = true; hide(); start(room); });
180
+ });
181
+ }
182
+ function quick() { if (state.finding) return; remember(null); hide(); flash('Finding a room…'); find(); }
183
+ /** Back to the room the player chose, else the Lobby's. */
184
+ function go() { if (state.finding) return; var kept = remembered(); hide(); if (kept && site) start(kept); else find(); }
185
+ function join(code) {
186
+ if (!ROOM.test(code)) { flash('A room code is 1 to 32 letters, digits, - or _.'); return false; }
187
+ if (!site) { flash('This copy has no online address: it plays offline.'); return false; }
188
+ remember(code); hide(); open(false); start(code);
189
+ return true;
190
+ }
191
+ function privateCode() {
192
+ var abc = 'abcdefghjkmnpqrstuvwxyz23456789';
193
+ var out = '';
194
+ try { var a = new Uint8Array(6); crypto.getRandomValues(a); for (var i = 0; i < 6; i++) out += abc[a[i] % abc.length]; }
195
+ catch (e) { while (out.length < 6) out += abc[Math.floor(Math.random() * abc.length)]; }
196
+ return out;
197
+ }
198
+
199
+ // A room this copy cannot stay in, said plainly, with the two ways on: another room, or the game by itself.
200
+ function notice(title, text) {
201
+ state.notice = { title: title, text: text };
202
+ noticeEl.querySelector('[data-notice-title]').textContent = title;
203
+ noticeEl.querySelector('[data-notice-text]').textContent = text;
204
+ noticeEl.hidden = false;
205
+ tellRects();
206
+ }
207
+ function hide() { state.notice = null; noticeEl.hidden = true; }
208
+ var STALE = 'Update ' + name + ' to play online with everyone.';
209
+
210
+ /*
211
+ * NET.PREFS (NETPLAY.md section 24), kept by this page as the play page keeps them: one JSON object per game in
212
+ * this app's own storage, 16 KB, 32 keys and 64 characters a key at most; with no storage, for the visit.
213
+ */
214
+ var PREFS_KEY = 'homie-prefs.' + game;
215
+ var PREFS = { bytes: 16384, keys: 32, key: 64 };
216
+ var prefsMem = null;
217
+ function prefsRead() {
218
+ if (prefsMem) return prefsMem;
219
+ try { var o = JSON.parse(localStorage.getItem(PREFS_KEY) || '{}'); return o && typeof o === 'object' && !Array.isArray(o) ? o : {}; } catch (e) { prefsMem = {}; return prefsMem; }
220
+ }
221
+ function prefsWrite(o) {
222
+ var text = JSON.stringify(o);
223
+ if (typeof text !== 'string' || text.length > PREFS.bytes || Object.keys(o).length > PREFS.keys) return 'too-large';
224
+ if (prefsMem) { prefsMem = o; return 'memory'; }
225
+ try { localStorage.setItem(PREFS_KEY, text); return 'kept'; } catch (e) { prefsMem = o; return 'memory'; }
226
+ }
227
+ function answerPrefs(m) {
228
+ var out = { t: 'homie-prefs', n: m.n, ok: false };
229
+ try {
230
+ var all = prefsRead();
231
+ var k = m.k;
232
+ var okKey = typeof k === 'string' && k.length > 0 && k.length <= PREFS.key && k !== '__proto__';
233
+ if (m.op === 'all') { out.ok = true; out.all = all; }
234
+ else if (!okKey) out.why = 'key';
235
+ else if (m.op === 'set' || m.op === 'del') {
236
+ var next = {};
237
+ Object.keys(all).forEach(function (x) { if (x !== k) next[x] = all[x]; });
238
+ if (m.op === 'set' && m.v !== null && m.v !== undefined) next[k] = m.v;
239
+ // A NaN or an Infinity would be written as null and read back as a setting: refused.
240
+ var how = m.op === 'set' && typeof m.v === 'number' && !isFinite(m.v) ? 'value' : prefsWrite(next);
241
+ if (how === 'too-large' || how === 'value') out.why = how; else { out.ok = true; out.kept = how; }
242
+ } else out.why = 'op';
243
+ } catch (e) { out.why = 'error'; }
244
+ state.prefs = { op: m.op, ok: out.ok, why: out.why || null };
245
+ try { frame.contentWindow.postMessage(out, '*'); } catch (e2) {}
246
+ }
247
+
248
+ // Where this page's own controls sit over the game (section 24), in the game's own CSS pixels.
249
+ var RECTS = [['room', '[data-share-toggle]'], ['sheet', '[data-share-sheet]'], ['notice', '[data-notice]']];
250
+ var lastRects = '';
251
+ function shellRects() {
252
+ var out = [];
253
+ RECTS.forEach(function (r) {
254
+ var el = null;
255
+ try { el = document.querySelector(r[1]); } catch (e) { el = null; }
256
+ if (!el || el.hidden || typeof el.getBoundingClientRect !== 'function') return;
257
+ var b = el.getBoundingClientRect();
258
+ if (!b || !(b.width > 0) || !(b.height > 0)) return;
259
+ out.push({ id: r[0], x: Math.round(b.left), y: Math.round(b.top), w: Math.round(b.width), h: Math.round(b.height) });
260
+ });
261
+ return out;
262
+ }
263
+ function tellRects() {
264
+ if (!state.attached) return;
265
+ var w = typeof innerWidth === 'number' ? innerWidth : 0;
266
+ var h = typeof innerHeight === 'number' ? innerHeight : 0;
267
+ var msg = { t: 'homie-shell', device: device, orientation: w > h ? 'landscape' : 'portrait', width: w, height: h, rects: shellRects() };
268
+ var sig = JSON.stringify(msg);
269
+ if (sig === lastRects) return;
270
+ lastRects = sig;
271
+ state.rects = msg;
272
+ try { frame.contentWindow.postMessage(msg, '*'); } catch (e) {}
273
+ }
274
+
275
+ // What the game's helper says to the page around it.
276
+ window.addEventListener('message', function (ev) {
277
+ if (ev.source !== frame.contentWindow) return;
278
+ var m = ev.data;
279
+ if (!m || typeof m !== 'object' || m.t !== 'homie-net') return;
280
+ // The frame is one window through every start: a word from the page of the start BEFORE this one (still on its
281
+ // way out) is not about this room. The page of this start is the one whose address names this turn.
282
+ if (!current()) return;
283
+ // An offline helper never says `attached`: its first word of any kind is as good.
284
+ if (!state.attached) { state.attached = true; lastRects = ''; tellRects(); }
285
+ if (m.what === 'prefs') answerPrefs(m);
286
+ // A seat's token is this room's only when the helper says so: it is never kept under another room's name.
287
+ if (m.what === 'token' && typeof m.token === 'string' && m.room === state.room && KEY) { state.seat = m.seat; try { localStorage.setItem(KEY, m.token.slice(0, 256)); } catch (e) {} }
288
+ // The link: in the room, knocking again, or playing alone because the room never answered.
289
+ if (m.what === 'link' && typeof m.state === 'string') { state.link = { state: m.state, why: String(m.why || ''), at: Date.now() }; paint(); }
290
+ // A newer build of the game is live on the site. This copy cannot load it: only an update of the app can, so
291
+ // it is said, never reloaded. Kept out of a room for it (final): the notice, with the ways on.
292
+ if (m.what === 'stale') {
293
+ state.stale = { ver: typeof m.ver === 'string' ? m.ver : null, final: m.final === true };
294
+ state.outdated = true;
295
+ paint();
296
+ if (m.final === true) notice('A new version is out', STALE); else flash(STALE);
297
+ }
298
+ // Stopped for good in THIS room (a helper from before 0.32.0 does not name the room: it is taken at its word).
299
+ if (m.what === 'closed' && m.why !== 'stale' && (m.room === undefined || m.room === null || m.room === state.room)) {
300
+ state.closed = m.why;
301
+ if (state.room && avoid.indexOf(state.room) < 0) avoid.push(state.room);
302
+ if (m.why === 'kicked') notice('You were removed from this room', 'The studio removed this device from the room for a while.');
303
+ else if (m.why === 'room-closed') notice('This room is closed', 'The studio closed this room.');
304
+ else if (m.why === 'room-full' || m.why === 'too-many') notice('This room is full', 'Every seat in this room is taken.');
305
+ else if (m.why === 'replaced') notice('Playing somewhere else', 'This seat was opened again from another place.');
306
+ }
307
+ });
308
+
309
+ frame.addEventListener('load', function () {
310
+ try { frame.focus(); frame.contentWindow.focus(); } catch (e) {}
311
+ // The helper may start after the frame's load: the rectangles are said again once it has.
312
+ state.attached = true; lastRects = ''; tellRects();
313
+ setTimeout(function () { lastRects = ''; tellRects(); }, 1500);
314
+ });
315
+ window.addEventListener('pointerdown', function (e) { if (ui.contains(e.target) || noticeEl.contains(e.target)) return; try { frame.focus(); } catch (e2) {} }, { passive: true });
316
+ toggle.addEventListener('click', function (e) { e.stopPropagation(); open(sheet.hidden); });
317
+ document.addEventListener('pointerdown', function (e) { if (!sheet.hidden && !ui.contains(e.target)) open(false); }, true);
318
+ document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && !sheet.hidden) open(false); });
319
+ ui.querySelector('[data-invite]').addEventListener('click', function () {
320
+ if (!state.online) { flash('Join a room first: this game is playing offline.'); return; }
321
+ var link = linkOf(state.room);
322
+ var data = { title: name, text: 'Play ' + name + ' with me: join my room.', url: link };
323
+ if (navigator.share && (!navigator.canShare || navigator.canShare(data))) navigator.share(data).catch(function () {});
324
+ else copy(link, 'Invite link copied');
325
+ });
326
+ ui.querySelector('[data-quick]').addEventListener('click', function () { open(false); if (state.online) quick(); else go(); });
327
+ ui.querySelector('[data-private]').addEventListener('click', function () { join(privateCode()); });
328
+ ui.querySelector('[data-offline]').addEventListener('click', function () { open(false); hide(); start(null); });
329
+ ui.querySelector('[data-join-form]').addEventListener('submit', function (e) {
330
+ if (e && e.preventDefault) e.preventDefault();
331
+ var input = ui.querySelector('[data-join-code]');
332
+ if (join(String(input.value || '').trim())) input.value = '';
333
+ });
334
+ noticeEl.querySelector('[data-notice-quick]').addEventListener('click', quick);
335
+ noticeEl.querySelector('[data-notice-offline]').addEventListener('click', function () { hide(); start(null); });
336
+ // The device is online again while the game plays offline: offered, never done for the player mid-round.
337
+ addEventListener('online', function () {
338
+ if (!site || state.finding || (state.online && !(state.link && state.link.state === 'alone'))) return;
339
+ flash('Back online · Join', function () { toast.hidden = true; go(); });
340
+ });
341
+ addEventListener('resize', tellRects);
342
+ if (typeof setInterval === 'function') setInterval(tellRects, 1000);
343
+ state.join = join; state.quick = quick; state.go = go;
344
+
345
+ go();
346
+ })();
package/worker/index.mjs CHANGED
@@ -23,7 +23,8 @@
23
23
  * /<game>/__game/... the game's own files (index.html gets HOMIE_NET)
24
24
  * /<game>/__net?room= the room's netplay socket (Table Durable Object)
25
25
  * /<game>/__watch?room= the room's facts, for the shell
26
- * /<game>/api/lobby which public room to join (Lobby Durable Object); ?server=<id>: a room of that server
26
+ * /<game>/api/lobby which public room to join (Lobby Durable Object); ?server=<id>: a room of that server;
27
+ * the one answer a standalone copy's app may read (worker/standalone.mjs)
27
28
  * /<game>/api/watch which public room to watch: the busiest one now (nothing is reserved)
28
29
  * /<game>/servers/ the game's servers (worker/servers.mjs): named, lasting room pools with their own
29
30
  * policy (open, humans-only, hybrid, beginner) and door; /<game>/api/servers as JSON
@@ -83,6 +84,7 @@
83
84
  * it when a server uses it), or the owner's own key (secret HOMIE_BRAIN_KEY).
84
85
  */
85
86
  import { NetRoom, WATCH_POLICIES, departure, errorLine, versionOf } from './room.mjs';
87
+ import { appCors, isAppOrigin } from './standalone.mjs';
86
88
  import { ROOM_ID, badRoomPage, frameAncestors, noWatchPage, playPage, watchPage } from './pages.mjs';
87
89
  import {
88
90
  PUBLIC_SERVER, SERVER_ID, homeOf, memberCounts, memberOf, noteMember, policyOf, pooledRoom, roomCode, roomServer, serverAccess, serverPassOf, serverView, serversOf,
@@ -576,7 +578,16 @@ function finish(res, path) {
576
578
  export default {
577
579
  async fetch(request, env, ctx) {
578
580
  const url = new URL(request.url);
579
- const res = finish(await route(request, env, ctx), url.pathname);
581
+ // A standalone copy's Lobby call (worker/standalone.mjs): every answer to it can be read by the app's page, an
582
+ // unknown game's 404 and a failure included, so the app can say why it plays offline. No other address is opened.
583
+ const appLobby = isAppOrigin(request) && /^\/[^/]+\/api\/lobby\/?$/.test(url.pathname);
584
+ let res;
585
+ try { res = finish(await route(request, env, ctx), url.pathname); } catch (error) {
586
+ if (!appLobby) throw error;
587
+ try { console.error(JSON.stringify({ at: new Date().toISOString(), ev: 'lobby-route-failed', path: url.pathname, error: errorLine(error) })); } catch { /* no console */ }
588
+ res = json({ ok: false, error: 'failed' }, 500);
589
+ }
590
+ if (appLobby) return appCors(request, res);
580
591
  // A referral's arrival (worker/referrals.mjs): only a person's page load with ?via= of another site, on a studio
581
592
  // that pays referrals. The page is answered first; a cookie is added to it only then.
582
593
  if (url.searchParams.has('via') && res && res.status === 200 && /text\/html/i.test(res.headers.get('content-type') ?? '')) {
@@ -998,9 +1009,14 @@ async function route(request, env, ctx) {
998
1009
  return json({ ok: true, game, room: best?.room ?? null, players: best?.players ?? 0, max }, 200, { 'cache-control': 'no-store' });
999
1010
  }
1000
1011
  if (sub === 'api/lobby') {
1012
+ // A standalone copy's page reads this answer from another origin: the Worker's own `fetch` adds its header to
1013
+ // every answer of this address (worker/standalone.mjs), whichever line here made it.
1014
+ const app = isAppOrigin(request);
1001
1015
  if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
1002
1016
  // ?server=<id>: a room of that server's pool (its door first); none: public. Strangers never meet across servers.
1003
1017
  const sid = url.searchParams.get('server') ?? 'public';
1018
+ // An app plays on the public server only: it has no account and no invite to show a server's door.
1019
+ if (app && sid !== 'public') return json({ ok: false, error: 'app-public', message: 'A standalone copy plays on the public server.' }, 403);
1004
1020
  const srv = SERVER_ID.test(sid) || sid === 'public' ? serverById(sid) : null;
1005
1021
  if (!srv) return json({ ok: false, error: 'no-server', message: `${meta.name} has no server called ${String(sid).slice(0, 24)}.` }, 404);
1006
1022
  if (srv.id === 'public' && publicHidden() && !(await pageDoor(srv)).owner) return json({ ok: false, error: 'closed', message: 'Pick a server to play on.' }, 403);
@@ -1010,8 +1026,11 @@ async function route(request, env, ctx) {
1010
1026
  const not = String(url.searchParams.get('not') ?? '').split(',').filter((r) => ROOM_ID.test(r)).slice(0, 4).join(',');
1011
1027
  const pol = policyFor(srv);
1012
1028
  // Strangers on different builds of the game never meet (section 23): the Lobby matches within the live build.
1013
- const ver = versionOf(meta?.netplay?.version);
1014
- return lobby().fetch(`https://lobby/join?max=${humanSeats(pol)}&server=${srv.id}&rooms=${srv.roomsMax}${not ? `&not=${encodeURIComponent(not)}` : ''}${ver ? `&ver=${encodeURIComponent(ver)}` : ''}`, { method: 'POST' });
1029
+ // A standalone copy is the build it was made from, whatever is live now: it says its own (`gv`, or none for a
1030
+ // game that named none then) and is matched with copies of that build. What it says is its own word, so the
1031
+ // Lobby is told it came from an app (`app=1`) and keeps only a few such builds' rooms (LOBBY_APP_VERSIONS).
1032
+ const ver = app ? versionOf(url.searchParams.get('gv')) : versionOf(meta?.netplay?.version);
1033
+ return lobby().fetch(`https://lobby/join?max=${humanSeats(pol)}&server=${srv.id}&rooms=${srv.roomsMax}${not ? `&not=${encodeURIComponent(not)}` : ''}${ver ? `&ver=${encodeURIComponent(ver)}` : ''}${app ? '&app=1' : ''}`, { method: 'POST' });
1015
1034
  }
1016
1035
  if (sub === 'api/agent') {
1017
1036
  // An AI's seat (worker/agents.mjs): its pass, the server's policy, a room with people in it, a ticket.
@@ -1700,6 +1719,11 @@ export class Table {
1700
1719
  * room, where it waits as a spectator and is seated when a seat frees (`full: true`). An AI (`agent=1`) is only ever
1701
1720
  * sent to a room with people in it; it never opens one. Players are people: agents are reported apart.
1702
1721
  */
1722
+ /** How many builds of a game besides the live one may have Lobby rooms on one server at once (standalone copies name their own). */
1723
+ export const LOBBY_APP_VERSIONS = 8;
1724
+ /** How many rooms a game's Lobby keeps in all. Its list is one stored value: 512 rooms is about a third of what one may hold. */
1725
+ export const LOBBY_ROOMS_MAX = 512;
1726
+
1703
1727
  export class Lobby {
1704
1728
  constructor(ctx, env) {
1705
1729
  this.ctx = ctx;
@@ -1779,8 +1803,15 @@ export class Lobby {
1779
1803
  const not = new Set(String(url.searchParams.get('not') ?? '').split(',').filter(Boolean));
1780
1804
  for (const [name, until] of this.closed) if (until <= now) this.closed.delete(name);
1781
1805
  // Every room of the server counts toward its `rooms_max` and its numbering; only this build's rooms are matched.
1782
- const ofServer = [...this.rooms.values()].filter((r) => (r.server ?? 'public') === server && !not.has(r.name) && !this.closed.has(r.name));
1806
+ const everyRoom = [...this.rooms.values()].filter((r) => (r.server ?? 'public') === server);
1807
+ const ofServer = everyRoom.filter((r) => !not.has(r.name) && !this.closed.has(r.name));
1783
1808
  const pool = ofServer.filter((r) => (r.ver ?? '') === ver);
1809
+ // A standalone copy names its own build (the Worker says `app=1`), and anybody can claim to be one: only a few
1810
+ // builds besides the live one ever have rooms here, so a caller that invents a build a request makes no pool.
1811
+ const app = url.searchParams.get('app') === '1';
1812
+ if (app && !everyRoom.some((r) => (r.ver ?? '') === ver) && new Set(everyRoom.map((r) => r.ver ?? '')).size > LOBBY_APP_VERSIONS) {
1813
+ return json({ ok: false, room: null, error: 'versions', message: 'Too many versions of this game are being played at once: play offline for now.' }, 503);
1814
+ }
1784
1815
  if (url.searchParams.get('agent') === '1') {
1785
1816
  // An AI: a room of this pool with people in it and an AI seat free (the most people first). Never a new room.
1786
1817
  const reserve = Math.max(0, Math.floor(Number(url.searchParams.get('ai'))) || 0);
@@ -1802,11 +1833,27 @@ export class Lobby {
1802
1833
  }
1803
1834
  let room = best?.r;
1804
1835
  let full = false;
1805
- if (!room && server !== 'public' && pool.length >= roomsMax) {
1806
- // Every room this server may have is full: the fullest one, where the visitor waits for a seat.
1807
- room = pool.reduce((a, b) => (b.players + b.pending.length > a.players + a.pending.length ? b : a), pool[0]);
1836
+ const fullest = (list) => list.reduce((a, b) => (b.players + b.pending.length > a.players + a.pending.length ? b : a), list[0]);
1837
+ if (!room && server !== 'public' && everyRoom.length >= roomsMax) {
1838
+ // Every room this server may have exists, whatever build each runs: the fullest one of this build, where the
1839
+ // visitor waits for a seat; with none of this build, the fullest of the server (its players are told a newer
1840
+ // build is live, and the visitor is let in when they have it). Never a room past the server's own number.
1841
+ const from = pool.length ? pool : ofServer;
1842
+ if (!from.length) return json({ ok: false, room: null, error: 'full', message: 'Every room of this server is taken.' }, 503);
1843
+ room = fullest(from);
1808
1844
  full = true;
1809
1845
  }
1846
+ if (!room && this.rooms.size >= LOBBY_ROOMS_MAX) {
1847
+ // The Lobby keeps a bounded list (it is one stored value): rooms nobody is seated in go first, the oldest
1848
+ // first. When every room has people in it, nobody gets a new one: this build's fullest, or no room at all.
1849
+ const empty = [...this.rooms.values()].filter((r) => r.players === 0).sort((a, b) => a.at - b.at);
1850
+ for (const r of empty) { if (this.rooms.size < LOBBY_ROOMS_MAX) break; this.rooms.delete(r.name); }
1851
+ if (this.rooms.size >= LOBBY_ROOMS_MAX) {
1852
+ if (!pool.length) return json({ ok: false, room: null, error: 'busy', message: 'Every room is taken right now.' }, 503);
1853
+ room = fullest(pool);
1854
+ full = true;
1855
+ }
1856
+ }
1810
1857
  if (!room) {
1811
1858
  let n = this.next[server] ?? 1;
1812
1859
  while (not.has(roomCode(server, n)) || this.closed.has(roomCode(server, n)) || this.rooms.has(roomCode(server, n))) n += 1;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * STANDALONE COPIES OF A GAME (standalone/STANDALONE.md): the same built game in a desktop or phone app
3
+ * (`homie-studio standalone build`). The app's page is not served by this site, so it is another origin to this
4
+ * Worker: `app://game` in the desktop app (Electron), `capacitor://localhost` on iOS and `https://localhost` on
5
+ * Android (Capacitor). Those three are fixed by the shells, never by a game.
6
+ *
7
+ * What an app origin may do here is ONE thing: ask the Lobby which public room to join (`POST /<game>/api/lobby`),
8
+ * and read the answer. The room's socket (`/<game>/__net`) never asked where a page came from, so it needs nothing.
9
+ * Everything else a page of this site does stays this site's own: no credentials are allowed across (there is no
10
+ * `access-control-allow-credentials` anywhere here), the lobby is never opened to `*`, and the POSTs that check
11
+ * `sameOrigin` (players, saves, the shop, chat reports, the office) still refuse an app, as they refuse any other
12
+ * site. So an app has no account, no cloud saves and no shop, and says so.
13
+ */
14
+
15
+ /** The origins of the app shells' own page. */
16
+ export const APP_ORIGINS = ['app://game', 'capacitor://localhost', 'https://localhost'];
17
+
18
+ /** The app origin this request came from, or null (this site's own pages, another site, a tool with no Origin). */
19
+ export function appOriginOf(request) {
20
+ const origin = request.headers.get('origin');
21
+ return origin && APP_ORIGINS.includes(origin) ? origin : null;
22
+ }
23
+
24
+ export const isAppOrigin = (request) => appOriginOf(request) !== null;
25
+
26
+ /** The answer as an app's page may read it: its own origin named, and nothing else. Any other request: unchanged. */
27
+ export function appCors(request, response) {
28
+ const origin = appOriginOf(request);
29
+ if (!origin) return response;
30
+ const out = new Response(response.body, response);
31
+ out.headers.set('access-control-allow-origin', origin);
32
+ out.headers.append('vary', 'origin');
33
+ return out;
34
+ }
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.31.1';
2
+ export const STUDIO_VERSION_TAG = '0.32.0';