@homie-rocks/studio 0.30.2 → 0.31.1
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/CHANGELOG.md +108 -1
- package/README.md +349 -23
- package/assets/animate.ts +16 -6
- package/assets/assets.ts +14 -3
- package/assets/render-page.ts +10 -2
- package/assets/safety.mjs +7 -1
- package/bin/homie-studio.mjs +204 -124
- package/lib/art-cli.mjs +61 -18
- package/lib/art-tools.mjs +53 -10
- package/lib/asset-check.mjs +249 -46
- package/lib/asset-import.mjs +11 -5
- package/lib/asset-manifest.mjs +128 -51
- package/lib/build.mjs +239 -72
- package/lib/characters.mjs +119 -20
- package/lib/check.mjs +110 -7
- package/lib/chrome.mjs +11 -0
- package/lib/clips.mjs +54 -0
- package/lib/cloudflare.mjs +203 -10
- package/lib/codex.mjs +6 -0
- package/lib/collision.mjs +245 -0
- package/lib/decisions.mjs +2 -2
- package/lib/deploy-state.mjs +166 -0
- package/lib/dev.mjs +430 -0
- package/lib/directory.mjs +172 -9
- package/lib/doctor.mjs +12 -11
- package/lib/handoff.mjs +2 -6
- package/lib/library.mjs +1 -1
- package/lib/mcp-tools.mjs +42 -37
- package/lib/mcp.mjs +1 -1
- package/lib/net.mjs +81 -2
- package/lib/node-version.mjs +40 -0
- package/lib/office.mjs +6 -6
- package/lib/optimise.mjs +39 -4
- package/lib/parts-build.mjs +169 -0
- package/lib/parts-cli.mjs +104 -0
- package/lib/parts-store.mjs +342 -0
- package/lib/parts-tools.mjs +92 -0
- package/lib/parts.mjs +720 -0
- package/lib/perf.mjs +131 -31
- package/lib/port-check.mjs +40 -6
- package/lib/preview.mjs +77 -0
- package/lib/routes.mjs +240 -0
- package/lib/scaffold.mjs +83 -22
- package/lib/shoot.mjs +307 -0
- package/lib/site.mjs +1 -2
- package/lib/stage.mjs +118 -0
- package/lib/studio.mjs +0 -66
- package/lib/style-board.mjs +49 -6
- package/lib/template-history.json +101 -0
- package/lib/trailer.mjs +47 -0
- package/lib/typecheck.mjs +107 -0
- package/mcp/ui/lineup.js +3 -3
- package/mcp/ui/rights.js +1 -3
- package/netplay/NETPLAY.md +552 -16
- package/netplay/netplay.ts +950 -43
- package/package.json +10 -1
- package/parts/PARTS.md +169 -0
- package/port/index.ts +8 -2
- package/port/personas.ts +275 -0
- package/port/powerups.ts +357 -0
- package/port/probe.ts +38 -2
- package/port/room.ts +329 -40
- package/site/SITE.md +102 -20
- package/starters/ember-vale/index.html +1 -1
- package/starters/ember-vale/src/main.ts +7 -1
- package/starters/gem-rush/index.html +1 -1
- package/starters/gem-rush/src/main.ts +41 -12
- package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
- package/starters/gem-rush-3d/assets/manifest.json +0 -24
- package/starters/gem-rush-3d/index.html +1 -1
- package/starters/gem-rush-3d/src/main.ts +50 -11
- package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
- package/starters/hero-rush-3d/assets/manifest.json +0 -19
- package/starters/hero-rush-3d/index.html +1 -1
- package/starters/hero-rush-3d/src/main.ts +50 -11
- package/worker/arrival.mjs +4 -1
- package/worker/discover.mjs +11 -25
- package/worker/index.mjs +142 -62
- package/worker/license.mjs +45 -41
- package/worker/office-page.mjs +0 -4
- package/worker/office-schema.mjs +6 -0
- package/worker/office.mjs +19 -23
- package/worker/pages.mjs +156 -29
- package/worker/parts.mjs +275 -0
- package/worker/room.mjs +157 -14
- package/worker/schema.mjs +6 -8
- package/worker/seats.mjs +71 -0
- package/worker/site.mjs +51 -53
- package/worker/version.mjs +1 -1
- package/lib/remix-assets.mjs +0 -84
package/worker/office-schema.mjs
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
/** The back office's D1 tables (worker/office.mjs), as the template's migration 0003 and `homie-studio upgrade` write them. */
|
|
2
2
|
export const OFFICE_MIGRATION_FILE = '0005_studio_office.sql';
|
|
3
|
+
/*
|
|
4
|
+
* This text is a migration that has run in studios in the field: it is never edited, not even its first comment.
|
|
5
|
+
* `office_games.remix` (the owner's switch for handing a game over whole) is still made here and still in every
|
|
6
|
+
* studio's table, though remix was retired: worker/office.mjs neither reads nor writes it, and no later migration
|
|
7
|
+
* drops it.
|
|
8
|
+
*/
|
|
3
9
|
export const OFFICE_MIGRATION = `-- The studio's back office (@homie-rocks/studio 0.13.0): each game's launch state, remix switch and room size,
|
|
4
10
|
-- its invites and the passes they gave, and the controls the owner's AI asked the owner to confirm.
|
|
5
11
|
CREATE TABLE IF NOT EXISTS office_games (
|
package/worker/office.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* /_studio/office the owner's live view: every live room of every game (players, bots, round,
|
|
6
6
|
* uptime) and who is in it, refreshing by itself; kick, mute, announce, close a room;
|
|
7
|
-
* each game's launch state,
|
|
7
|
+
* each game's launch state, room size and invites
|
|
8
8
|
* /_studio/confirm/<ask> a control the owner's AI asked for, waiting for the owner's one tap
|
|
9
9
|
* /_studio/api/... the same, as JSON, for the page, the in-game owner overlay, the CLI and the MCP
|
|
10
10
|
* /<game>/invite an invite code, spent for this browser's pass to an invite-only game
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
* office key (`homie-studio office key`, also minted with that login; D1 keeps only its SHA-256). A player can
|
|
15
15
|
* never be the owner: the session is an HttpOnly cookie no page or game can read, and the games run in a sandboxed,
|
|
16
16
|
* opaque-origin frame that can send no request as this site. With an office key, the AI can look, announce and
|
|
17
|
-
* make invites; a control that takes something away (kick, mute, closing a room, a game's launch state
|
|
18
|
-
*
|
|
17
|
+
* make invites; a control that takes something away (kick, mute, closing a room, a game's launch state
|
|
18
|
+
* or room size) only becomes an ASK, which the owner confirms with one tap in their signed-in browser.
|
|
19
19
|
* No key can confirm an ask.
|
|
20
20
|
*
|
|
21
21
|
* CONTROLS ARE SIGNED. The Worker hands a room a control as a `ctl` message signed with the studio's own office
|
|
@@ -28,8 +28,12 @@
|
|
|
28
28
|
* is left out of the studio's pages, /api/games, /api/rooms and the directory manifest, so the directory drops it
|
|
29
29
|
* the next time it reads the studio; its own pages, rooms and play frame refuse anyone without access (the play
|
|
30
30
|
* frame and its sockets carry a signed ticket the play page mints). A Preview (no D1, an unlisted address of a
|
|
31
|
-
* branch under review) enforces no launch state.
|
|
32
|
-
*
|
|
31
|
+
* branch under review) enforces no launch state.
|
|
32
|
+
*
|
|
33
|
+
* `office_games` also has a `remix` column: the owner's switch for handing a game over whole, from when that existed
|
|
34
|
+
* (remix was retired). A migration that ran in the field is never edited and the column is never dropped, so it
|
|
35
|
+
* stays in the table with whatever it held; nothing here reads it and nothing writes it (settingsOf, the `game`
|
|
36
|
+
* action below).
|
|
33
37
|
*
|
|
34
38
|
* PLAYER ACCOUNTS (worker/players.mjs, handed over in worker/index.mjs with `usePlayers()`): `of(request, env)` names
|
|
35
39
|
* the signed-in player in their play page's ticket, so their seat carries their account and a kick holds it on every
|
|
@@ -61,7 +65,6 @@ import { DEFAULT_MODEL, NEURONS_PER_M, OWNER_MODEL, vocabularyOf } from './brain
|
|
|
61
65
|
import { OWNER_COOKIE, cookieValues, counter, ownerAllowed, ownerSession, today } from './stats.mjs';
|
|
62
66
|
import { esc, layout, notFoundPage } from './site.mjs';
|
|
63
67
|
import { confirmPage, lockedPage, officePage } from './office-page.mjs';
|
|
64
|
-
import { licenseOf } from './license.mjs';
|
|
65
68
|
import { CHAT_MODES, CHAT_WHO, checkChatRules, publicChat } from './chat.mjs';
|
|
66
69
|
import { chatDay, chatOf, chatRowsOf, clearChatRules, dismissReport, reportsOf, writeChatRules } from './chat-store.mjs';
|
|
67
70
|
import { checkRefund, checkSettle, describeRefund, officeShopPage, ordersCsv, performRefund, settleReferrer, shopOffice, statementsOut, statementsSend } from './shop.mjs';
|
|
@@ -178,7 +181,7 @@ export async function settingsOf(env, { fresh = false } = {}) {
|
|
|
178
181
|
if (!fresh && hit && Date.now() - hit.at < 5000) return hit.map;
|
|
179
182
|
let map = new Map();
|
|
180
183
|
try {
|
|
181
|
-
const { results } = await env.DB.prepare('SELECT game, launch,
|
|
184
|
+
const { results } = await env.DB.prepare('SELECT game, launch, max_players, updated_at FROM office_games LIMIT 500').all();
|
|
182
185
|
map = new Map((results ?? []).map((r) => [r.game, r]));
|
|
183
186
|
} catch { map = new Map(); }
|
|
184
187
|
settingsCache.set(env.DB, { at: Date.now(), map });
|
|
@@ -192,11 +195,6 @@ export function launchOf(meta, settings, env) {
|
|
|
192
195
|
const v = settings?.get(meta?.id)?.launch ?? meta?.launch ?? 'public';
|
|
193
196
|
return LAUNCH_STATES.includes(v) ? v : 'public';
|
|
194
197
|
}
|
|
195
|
-
/** Whether the game's source is offered for remix: built (game.json share.source) and not withdrawn by the owner. */
|
|
196
|
-
export function remixOf(meta, settings) {
|
|
197
|
-
if (meta?.landing?.source === false) return false;
|
|
198
|
-
return settings?.get(meta?.id)?.remix !== 0;
|
|
199
|
-
}
|
|
200
198
|
/** A room's seats: the game's own (its netplay manifest), lowered by the owner's room size. */
|
|
201
199
|
export function seatsFor(meta, settings) {
|
|
202
200
|
const own = seatsOf(meta);
|
|
@@ -616,7 +614,6 @@ export async function officeView(env, cat, origin) {
|
|
|
616
614
|
// 0.17.0: the game's AI guides have words of their own (agents.json): without it they play but never talk.
|
|
617
615
|
vocab: g.vocab === true,
|
|
618
616
|
passes: (await passList(env, { game: g.id })).filter((x) => x.live || Date.now() - x.createdAt < 7 * 86_400_000),
|
|
619
|
-
remix: remixOf(g, settings), remixBuilt: g.landing?.source !== false, license: licenseOf(g.license).kind,
|
|
620
617
|
seats: seatsOf(g), maxPlayers: max, maxSet: Number(row?.max_players) >= 1 ? Number(row.max_players) : null,
|
|
621
618
|
play: `${origin}/${g.id}/play`, page: `${origin}/${g.id}/`,
|
|
622
619
|
invites: launch === 'invite' || (await hasInvites(env, g.id)) ? await invitesOf(env, g.id, origin) : [],
|
|
@@ -691,7 +688,6 @@ function checkAction(cat, op, body) {
|
|
|
691
688
|
if (!meta) return bad('game is one of this studio\'s game ids');
|
|
692
689
|
const launch = body.launch === undefined || body.launch === null ? undefined : String(body.launch);
|
|
693
690
|
if (launch !== undefined && !LAUNCH_STATES.includes(launch)) return bad('launch is private, invite or public');
|
|
694
|
-
const remix = body.remix === undefined || body.remix === null ? undefined : body.remix === true || body.remix === 'on';
|
|
695
691
|
let maxPlayers;
|
|
696
692
|
if (body.maxPlayers === null || body.maxPlayers === 'game') maxPlayers = null;
|
|
697
693
|
else if (body.maxPlayers !== undefined) {
|
|
@@ -699,9 +695,10 @@ function checkAction(cat, op, body) {
|
|
|
699
695
|
if (!(maxPlayers >= 1)) return bad('maxPlayers is a number from 1 to the game\'s own seats, or null for the game\'s own');
|
|
700
696
|
maxPlayers = Math.min(maxPlayers, seatsOf(meta));
|
|
701
697
|
}
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
698
|
+
// An older plugin or CLI may still send `remix` (the switch that offered a game whole). It is not a setting
|
|
699
|
+
// any more: alone it is refused in a sentence, and beside a launch state or a room size it is left out.
|
|
700
|
+
if (launch === undefined && maxPlayers === undefined) return bad(body.remix !== undefined && body.remix !== null ? 'remix was retired: a game is no longer handed over whole, so there is no switch to set. A studio shares pieces of its games as parts (/parts/)' : 'say what to change: launch or maxPlayers');
|
|
701
|
+
return { ok: true, action: { op, game: meta.id, ...(launch !== undefined ? { launch } : {}), ...(maxPlayers !== undefined ? { maxPlayers } : {}) } };
|
|
705
702
|
}
|
|
706
703
|
case 'server-create': {
|
|
707
704
|
if (!meta) return bad('game is one of this studio\'s game ids');
|
|
@@ -822,7 +819,6 @@ export function describe(cat, a) {
|
|
|
822
819
|
case 'game': {
|
|
823
820
|
const bits = [];
|
|
824
821
|
if (a.launch) bits.push({ private: `make ${gname} private (only you can open it; after the current round everyone else leaves its rooms with a thank-you, and it leaves the directory on the next read)`, invite: `make ${gname} an invite-only beta (invited players only; after the current round everyone else leaves its rooms with a thank-you, and it leaves the directory on the next read)`, public: `make ${gname} public (anyone can play; listed in the directory on the next read)` }[a.launch]);
|
|
825
|
-
if (a.remix !== undefined) bits.push(a.remix ? `publish ${gname}'s source for remixing` : `withdraw ${gname}'s source from remixing`);
|
|
826
822
|
if (a.maxPlayers !== undefined) bits.push(a.maxPlayers === null ? `give ${gname}'s rooms the game's own number of seats` : `set ${gname}'s rooms to at most ${a.maxPlayers} players`);
|
|
827
823
|
const s = bits.join('; ');
|
|
828
824
|
return `${s.charAt(0).toUpperCase()}${s.slice(1)}.`;
|
|
@@ -950,16 +946,16 @@ export async function perform(env, cat, a) {
|
|
|
950
946
|
const row = settings.get(meta.id) ?? {};
|
|
951
947
|
const next = {
|
|
952
948
|
launch: a.launch ?? row.launch ?? null,
|
|
953
|
-
remix: a.remix === undefined ? (row.remix ?? null) : (a.remix ? 1 : 0),
|
|
954
949
|
max: a.maxPlayers === undefined ? (row.max_players ?? null) : a.maxPlayers,
|
|
955
950
|
};
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
951
|
+
// The table's `remix` column is not named: a new row leaves it NULL and an old row keeps what it had (above).
|
|
952
|
+
await env.DB.prepare(`INSERT INTO office_games (game, launch, max_players, updated_at) VALUES (?1, ?2, ?3, ?4)
|
|
953
|
+
ON CONFLICT(game) DO UPDATE SET launch = excluded.launch, max_players = excluded.max_players, updated_at = excluded.updated_at`)
|
|
954
|
+
.bind(meta.id, next.launch, next.max, Date.now()).run();
|
|
959
955
|
forgetSettings(env);
|
|
960
956
|
const fresh = await settingsOf(env, { fresh: true });
|
|
961
957
|
const launch = launchOf(meta, fresh, env);
|
|
962
|
-
const done = { ok: true, op: 'game', game: meta.id, launch,
|
|
958
|
+
const done = { ok: true, op: 'game', game: meta.id, launch, maxPlayers: seatsFor(meta, fresh), regating: 0 };
|
|
963
959
|
const rooms = await liveRooms(env, meta.id);
|
|
964
960
|
// Who may play changed: every live room finishes its current round, with a notice, and then whoever the new
|
|
965
961
|
// state leaves out is sent out of it (the owner, and in an invite-only beta the invited, play on). Back to a
|
package/worker/pages.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { esc, layout } from './site.mjs';
|
|
6
6
|
import { NET_PALETTE } from './room.mjs';
|
|
7
|
+
import { PARAM_VALUE, PREFS_LIMITS, playParams } from './seats.mjs';
|
|
7
8
|
import { SKILLS } from './agents.mjs';
|
|
8
9
|
import { KIDS_LINE, POLICY_WORDS } from './servers.mjs';
|
|
9
10
|
import { SAVES_SHELL_CSS, SAVES_SHELL_JS } from './saves-shell.mjs';
|
|
@@ -101,7 +102,7 @@ export function playPage(cat, g, { screen = false, joinUrl = null, qr = null, lo
|
|
|
101
102
|
const arrive = arrivalCard(cat, g, { screen });
|
|
102
103
|
const css = `:root{--hot:${/^#[0-9a-f]{3,8}$/i.test(accent) ? accent : '#ffcf5a'}}
|
|
103
104
|
html, body { height: 100%; margin: 0; overflow: hidden; overscroll-behavior: none; background: #04060c; touch-action: none; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent; }
|
|
104
|
-
iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #04060c; touch-action: none; }
|
|
105
|
+
iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #04060c; touch-action: none; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
|
|
105
106
|
.chip { position: fixed; left: max(10px, env(safe-area-inset-left)); bottom: max(10px, env(safe-area-inset-bottom)); z-index: 5; padding: 6px 10px; border-radius: 999px; background: rgba(0,0,0,.55); color: #dfe6f5; font: 12px/1.2 ui-sans-serif, system-ui, sans-serif; pointer-events: none; backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); transition: opacity .6s; }
|
|
106
107
|
.chip.quiet { opacity: 0; }
|
|
107
108
|
.card { position: fixed; right: 12px; bottom: 12px; z-index: 4; max-width: 300px; padding: 10px 12px; border-radius: 12px; background: rgba(8,12,22,.82); border: 1px solid rgba(255,255,255,.14); color: #e8ecf5; font: 13px/1.35 ui-sans-serif, system-ui, sans-serif; }
|
|
@@ -171,7 +172,8 @@ ${arrive.css}${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''
|
|
|
171
172
|
const srv = server ? { id: server.id, name: server.name, badge: server.badge, line: server.line, policy: server.policy, kids: Boolean(server.kids), mentor: Boolean(server.mentor), levelMax: server.levelMax } : null;
|
|
172
173
|
// game.json "agents": { "vote": "game" } draws its own vote card; false turns the vote off (section 17).
|
|
173
174
|
const vote = g.agents?.vote === 'game' || g.agents?.vote === false ? g.agents.vote : 'shell';
|
|
174
|
-
|
|
175
|
+
// The address's switches the game's frame is handed (NETPLAY.md section 24): the defaults and the game's own names.
|
|
176
|
+
const boot = { game: g.id, name: g.name, screen: Boolean(screen), share: places, params: playParams(g), paramValue: PARAM_VALUE, prefs: PREFS_LIMITS, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}), ...(owner ? { owner: true, launch } : {}), ...(srv ? { server: srv } : {}), vote, skills: SKILLS.map((k) => ({ level: k.level, name: k.name, card: k.card })), words: POLICY_WORDS, kidsLine: KIDS_LINE, ...(shop ? { shop } : {}) };
|
|
175
177
|
// game.json "screen": { "join": "top-left" | "top-right" | "bottom-left" | "bottom-right" } keeps the card off the game's own HUD.
|
|
176
178
|
const joinCorner = corner('join', 'bottom-right');
|
|
177
179
|
const first = places.desk;
|
|
@@ -401,25 +403,47 @@ const SHELL_JS = String.raw`(function () {
|
|
|
401
403
|
}
|
|
402
404
|
} catch (e) {}
|
|
403
405
|
var KEY = 'homie-net.' + boot.game + '.' + room + '.' + want;
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
406
|
+
// The frame's address, made again for a reload of the game (a newer build, section 23): the seat's token as it is now.
|
|
407
|
+
function frameUrl() {
|
|
408
|
+
var token = null;
|
|
409
|
+
try { token = sessionStorage.getItem(KEY); } catch (e) {}
|
|
410
|
+
var q = new URLSearchParams({ room: room, device: device, want: want });
|
|
411
|
+
if (token) q.set('k', token);
|
|
412
|
+
q.set('b', roomKey);
|
|
413
|
+
if (boot.t) q.set('t', boot.t);
|
|
414
|
+
if (params.get('name')) q.set('name', params.get('name'));
|
|
415
|
+
else {
|
|
416
|
+
// A player with an account (or a named guest) on this studio plays under their own name in every room.
|
|
417
|
+
try { var who = JSON.parse(localStorage.getItem('homie.player') || 'null'); if (who && typeof who.name === 'string' && who.name) q.set('name', who.name.slice(0, 24)); } catch (e) {}
|
|
418
|
+
}
|
|
419
|
+
// Quiet AI (this browser hides AI speech): the helper starts hushed, and hears a change by message.
|
|
420
|
+
if (state.quietAi) q.set('hush', '1');
|
|
421
|
+
// Room chat (section 19): this browser's "Show chat" and "Show my messages over my character", for the game's bubbles.
|
|
422
|
+
try { if (localStorage.getItem('homie-chat-show') === '0') q.set('chat', '0'); if (localStorage.getItem('homie-chat-bubble') === '0') q.set('bub', '0'); } catch (e) {}
|
|
423
|
+
// The frame cannot read this page's address (it is an opaque origin): hand it the game's own switches (section 24).
|
|
424
|
+
// An allow-list: ?debug and ?q always, the port kit's three, and the names game.json "netplay.params" declares;
|
|
425
|
+
// never one this page uses itself (the room, the token, the ticket). The game reads them as net.params.
|
|
426
|
+
var okValue = new RegExp(boot.paramValue || '^[A-Za-z0-9_.~-]{0,48}$');
|
|
427
|
+
(boot.params || ['debug', 'q', 'touchdebug', 'cam', 'view']).forEach(function (k) { var v = params.get(k); if (v !== null && okValue.test(v)) q.set(k, v); });
|
|
428
|
+
// This page keeps net.prefs for the frame (section 24).
|
|
429
|
+
q.set('pf', '1');
|
|
430
|
+
return '/' + boot.game + '/__game/?' + q.toString();
|
|
414
431
|
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
//
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
432
|
+
state.params = {};
|
|
433
|
+
(boot.params || []).forEach(function (k) { var v = params.get(k); if (v !== null && new RegExp(boot.paramValue || '^$').test(v)) state.params[k] = v; });
|
|
434
|
+
// A newer build of the game is live (section 23): load the frame again, at most twice a minute (a build whose
|
|
435
|
+
// files are still the old ones at the edge would otherwise reload for ever).
|
|
436
|
+
var reloads = [];
|
|
437
|
+
function reloadGame() {
|
|
438
|
+
var t = Date.now();
|
|
439
|
+
reloads = reloads.filter(function (x) { return t - x < 60000; });
|
|
440
|
+
if (reloads.length >= 2) { flash('This game was updated. Reload the page to play the new version.'); return false; }
|
|
441
|
+
reloads.push(t);
|
|
442
|
+
state.stale = null; state.closed = null; state.reloaded = (state.reloaded || 0) + 1;
|
|
443
|
+
try { frame.src = frameUrl(); } catch (e) {}
|
|
444
|
+
return true;
|
|
445
|
+
}
|
|
446
|
+
frame.src = frameUrl();
|
|
423
447
|
A.room(labelOf(room));
|
|
424
448
|
frame.addEventListener('load', function () { A.frameLoaded(); try { frame.focus(); frame.contentWindow.focus(); } catch (e) {} });
|
|
425
449
|
window.addEventListener('pointerdown', function (e) { if ((ui && ui.contains(e.target)) || (e.target.closest && e.target.closest('[data-keep-focus]'))) return; try { frame.focus(); } catch (e2) {} }, { passive: true });
|
|
@@ -428,18 +452,41 @@ const SHELL_JS = String.raw`(function () {
|
|
|
428
452
|
var m = ev.data;
|
|
429
453
|
if (!m || typeof m !== 'object' || m.t !== 'homie-net') return;
|
|
430
454
|
A.message(m);
|
|
431
|
-
if (m.what === 'attached') state.attached = true;
|
|
432
|
-
|
|
455
|
+
if (m.what === 'attached') { state.attached = true; lastRects = ''; tellRects(); }
|
|
456
|
+
// The link (section 22): in the room, knocking again, or playing alone because the room never answered.
|
|
457
|
+
if (m.what === 'link' && typeof m.state === 'string') state.link = { state: m.state, why: String(m.why || ''), hosting: m.hosting === true, at: Date.now() };
|
|
458
|
+
// Every seat is taken and this browser asked to play (the room's own word, in its welcome): it watches until one
|
|
459
|
+
// frees up, and the chip says so. Not full any more once it has been seated, or plays by itself.
|
|
460
|
+
if (m.what === 'full') state.full = m.full === true;
|
|
461
|
+
// The game's own word that it was playable (section 21), beside who lifted the card: a late one is a game whose
|
|
462
|
+
// arrival should be 'game'. A performance probe reads both here.
|
|
463
|
+
if (m.what === 'ready') {
|
|
464
|
+
state.ready = { mode: m.mode === 'game' ? 'game' : 'auto', ms: Number(m.ms) || 0, lateMs: typeof m.lateMs === 'number' ? m.lateMs : null, atMs: Math.round(typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now()) };
|
|
465
|
+
if (A.state) { A.state.explicitMs = state.ready.atMs; A.state.lateMs = state.ready.lateMs; }
|
|
466
|
+
}
|
|
467
|
+
if (m.what === 'prefs') answerPrefs(m);
|
|
468
|
+
// A newer build of the game is live. Kept out of a room for it (final): the game loads again now. Still
|
|
469
|
+
// playing in its own room: it loads again at the round's break, or at once on a big screen (nothing to lose).
|
|
470
|
+
if (m.what === 'stale') {
|
|
471
|
+
state.stale = { ver: typeof m.ver === 'string' ? m.ver : null, final: m.final === true };
|
|
472
|
+
if (m.final === true || screenMode) reloadGame();
|
|
473
|
+
else flash('A new version of ' + boot.name + ' is ready: it loads after this round.');
|
|
474
|
+
}
|
|
475
|
+
if (m.what === 'token' && typeof m.token === 'string') { state.seat = m.seat; if (typeof m.seat === 'number') state.full = false; try { sessionStorage.setItem(KEY, m.token); } catch (e) {} if (window.__homieChat) window.__homieChat.seat(); }
|
|
433
476
|
if (m.what === 'stats') state.stats = m.stats;
|
|
434
|
-
if (m.what === 'round') onRound(m.round);
|
|
477
|
+
if (m.what === 'round') { onRound(m.round); if (state.stale && m.round && m.round.phase === 'over') reloadGame(); }
|
|
435
478
|
if (m.what === 'roster') state.roster = m.slots;
|
|
436
|
-
|
|
479
|
+
// Stopped for good. Removed, or the room closed: the notice. A room with no place left at all: the same notice
|
|
480
|
+
// with its own words, because "try another room" is the only thing a player can do about it.
|
|
481
|
+
if (m.what === 'closed') { state.closed = m.why; if (m.why === 'kicked' || m.why === 'room-closed' || m.why === 'agents-off') notice(m.why === 'kicked' ? 'kicked' : 'closed', m); else if (m.why === 'room-full' || m.why === 'too-many') notice('full', m); }
|
|
437
482
|
// A game's net.pickPlayer(seat): only an owner's page listens (its overlay opens that player's card).
|
|
438
483
|
if (m.what === 'pick' && (m.seat === null || typeof m.seat === 'number')) { try { window.dispatchEvent(new CustomEvent('homie-pick', { detail: { seat: m.seat } })); } catch (e) {} }
|
|
439
484
|
paint();
|
|
440
485
|
});
|
|
441
486
|
watch(room);
|
|
442
487
|
shareReady(room);
|
|
488
|
+
addEventListener('resize', tellRects);
|
|
489
|
+
if (typeof setInterval === 'function') setInterval(tellRects, 1000);
|
|
443
490
|
if (screenMode && !document.querySelector('[data-join]') && !LOCAL) {
|
|
444
491
|
var h2 = document.createElement('h2'); h2.textContent = 'Join on your phone';
|
|
445
492
|
var div = document.createElement('div'); div.textContent = location.origin + '/' + boot.game + '/play?room=' + encodeURIComponent(room);
|
|
@@ -447,6 +494,81 @@ const SHELL_JS = String.raw`(function () {
|
|
|
447
494
|
}
|
|
448
495
|
}
|
|
449
496
|
|
|
497
|
+
/*
|
|
498
|
+
* NET.PREFS (NETPLAY.md section 24). The game's frame is an opaque origin (no allow-same-origin: a stranger's game
|
|
499
|
+
* must never read this site's storage, cookies or the owner's session), so its own localStorage throws. This page
|
|
500
|
+
* keeps a few settings for it instead: one JSON object per game under this site's storage, 16 KB and 32 keys at
|
|
501
|
+
* most, read and written only for this page's own game. With no storage (a private window): for the visit.
|
|
502
|
+
*/
|
|
503
|
+
var PREFS_KEY = 'homie-prefs.' + boot.game;
|
|
504
|
+
var PREFS = boot.prefs || { bytes: 16384, keys: 32, key: 64 };
|
|
505
|
+
var prefsMem = null;
|
|
506
|
+
function prefsRead() {
|
|
507
|
+
if (prefsMem) return prefsMem;
|
|
508
|
+
try { var o = JSON.parse(localStorage.getItem(PREFS_KEY) || '{}'); return o && typeof o === 'object' && !Array.isArray(o) ? o : {}; } catch (e) { prefsMem = {}; return prefsMem; }
|
|
509
|
+
}
|
|
510
|
+
function prefsWrite(o) {
|
|
511
|
+
var text = JSON.stringify(o);
|
|
512
|
+
if (typeof text !== 'string' || text.length > PREFS.bytes || Object.keys(o).length > PREFS.keys) return 'too-large';
|
|
513
|
+
if (prefsMem) { prefsMem = o; return 'memory'; }
|
|
514
|
+
try { localStorage.setItem(PREFS_KEY, text); return 'kept'; } catch (e) { prefsMem = o; return 'memory'; }
|
|
515
|
+
}
|
|
516
|
+
function answerPrefs(m) {
|
|
517
|
+
var out = { t: 'homie-prefs', n: m.n, ok: false };
|
|
518
|
+
try {
|
|
519
|
+
var all = prefsRead();
|
|
520
|
+
var k = m.k;
|
|
521
|
+
var okKey = typeof k === 'string' && k.length > 0 && k.length <= PREFS.key && k !== '__proto__';
|
|
522
|
+
if (m.op === 'all') { out.ok = true; out.all = all; }
|
|
523
|
+
else if (!okKey) out.why = 'key';
|
|
524
|
+
else if (m.op === 'set' || m.op === 'del') {
|
|
525
|
+
var next = {};
|
|
526
|
+
Object.keys(all).forEach(function (x) { if (x !== k) next[x] = all[x]; });
|
|
527
|
+
if (m.op === 'set' && m.v !== null && m.v !== undefined) next[k] = m.v;
|
|
528
|
+
// A NaN or an Infinity would be written as null and read back as a setting (a volume of null plays as 0):
|
|
529
|
+
// refused, whatever helper sent it.
|
|
530
|
+
var how = m.op === 'set' && typeof m.v === 'number' && !isFinite(m.v) ? 'value' : prefsWrite(next);
|
|
531
|
+
if (how === 'too-large' || how === 'value') out.why = how; else { out.ok = true; out.kept = how; }
|
|
532
|
+
} else out.why = 'op';
|
|
533
|
+
} catch (e) { out.why = 'error'; }
|
|
534
|
+
state.prefs = { op: m.op, ok: out.ok, why: out.why || null };
|
|
535
|
+
try { frame.contentWindow.postMessage(out, '*'); } catch (e2) {}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/*
|
|
539
|
+
* WHERE THIS PAGE'S OWN CONTROLS SIT OVER THE GAME (section 24). The frame fills the page, so these rectangles are
|
|
540
|
+
* in the game's own CSS pixels. A game's HUD check that only measures inside the frame cannot see them: the room
|
|
541
|
+
* button, the server pill, the chat pill, the "3 playing" chip (it fades, and comes back), the big screen's join
|
|
542
|
+
* card, the studio's banner. Said to the helper when it attaches, on a resize or a turn, and when one moves.
|
|
543
|
+
*/
|
|
544
|
+
var RECTS = [['room', '[data-share-toggle]'], ['server', '[data-server-toggle]'], ['chat', '[data-chat-toggle]'], ['chip', '[data-chip]', true], ['join', '[data-join]'], ['banner', '[data-banner]'], ['ticker', '.ctick', true], ['results', '[data-results]'], ['screen', '[data-screen]'], ['sheet', '[data-share-sheet]'], ['server-sheet', '[data-server-sheet]'], ['vote', '[data-vote]']];
|
|
545
|
+
var lastRects = '';
|
|
546
|
+
function shellRects() {
|
|
547
|
+
var out = [];
|
|
548
|
+
RECTS.forEach(function (r) {
|
|
549
|
+
var el = null;
|
|
550
|
+
try { el = document.querySelector(r[1]); } catch (e) { el = null; }
|
|
551
|
+
if (!el || el.hidden || typeof el.getBoundingClientRect !== 'function') return;
|
|
552
|
+
var b = el.getBoundingClientRect();
|
|
553
|
+
if (!b || !(b.width > 0) || !(b.height > 0)) return;
|
|
554
|
+
var o = { id: r[0], x: Math.round(b.left), y: Math.round(b.top), w: Math.round(b.width), h: Math.round(b.height) };
|
|
555
|
+
if (r[2]) o.fades = true;
|
|
556
|
+
out.push(o);
|
|
557
|
+
});
|
|
558
|
+
return out;
|
|
559
|
+
}
|
|
560
|
+
function tellRects() {
|
|
561
|
+
if (!state.attached) return;
|
|
562
|
+
var w = typeof innerWidth === 'number' ? innerWidth : 0;
|
|
563
|
+
var h = typeof innerHeight === 'number' ? innerHeight : 0;
|
|
564
|
+
var msg = { t: 'homie-shell', device: device, orientation: w > h ? 'landscape' : 'portrait', width: w, height: h, rects: shellRects() };
|
|
565
|
+
var sig = JSON.stringify(msg);
|
|
566
|
+
if (sig === lastRects) return;
|
|
567
|
+
lastRects = sig;
|
|
568
|
+
state.rects = msg;
|
|
569
|
+
try { frame.contentWindow.postMessage(msg, '*'); } catch (e) {}
|
|
570
|
+
}
|
|
571
|
+
|
|
450
572
|
function onRound(r) {
|
|
451
573
|
if (!r) return;
|
|
452
574
|
state.round = r;
|
|
@@ -541,8 +663,8 @@ const SHELL_JS = String.raw`(function () {
|
|
|
541
663
|
var label = labelOf(state.room || '');
|
|
542
664
|
var box = document.createElement('div'); box.className = 'notice'; box.setAttribute('data-notice', kind); box.setAttribute('role', 'alertdialog'); box.setAttribute('data-keep-focus', '');
|
|
543
665
|
var inner = document.createElement('div'); inner.className = 'box';
|
|
544
|
-
var h = document.createElement('h1'); h.textContent = kind === 'kicked' ? 'You were removed from this room' : 'This room is closed';
|
|
545
|
-
var p = document.createElement('p'); p.textContent = state.notice.message || (kind === 'kicked' ? 'The studio removed you from this room.' : 'The studio closed this room. Thanks for playing!');
|
|
666
|
+
var h = document.createElement('h1'); h.textContent = kind === 'kicked' ? 'You were removed from this room' : kind === 'full' ? 'This room is full' : 'This room is closed';
|
|
667
|
+
var p = document.createElement('p'); p.textContent = kind === 'full' ? label + ' has no place left, to play or to watch. Another room is one tap away.' : state.notice.message || (kind === 'kicked' ? 'The studio removed you from this room.' : 'The studio closed this room. Thanks for playing!');
|
|
546
668
|
var w = document.createElement('p'); w.className = 'when';
|
|
547
669
|
w.textContent = mins ? (kind === 'kicked' ? 'You can come back to ' + label + ' in ' + mins + ' min.' : label + ' opens again in ' + mins + ' min.') : '';
|
|
548
670
|
var acts = document.createElement('div'); acts.className = 'acts';
|
|
@@ -551,7 +673,7 @@ const SHELL_JS = String.raw`(function () {
|
|
|
551
673
|
acts.append(other, back);
|
|
552
674
|
inner.append(h, p, w, acts); box.appendChild(inner);
|
|
553
675
|
document.body.appendChild(box);
|
|
554
|
-
say(kind === 'kicked' ? 'removed from this room' : 'room closed');
|
|
676
|
+
say(kind === 'kicked' ? 'removed from this room' : kind === 'full' ? 'room full' : 'room closed');
|
|
555
677
|
}
|
|
556
678
|
|
|
557
679
|
function paint() {
|
|
@@ -565,7 +687,12 @@ const SHELL_JS = String.raw`(function () {
|
|
|
565
687
|
if (aiN) bits.push(aiN + ' AI');
|
|
566
688
|
if (f && f.counts && f.counts.watchers) bits.push(f.counts.watchers + ' watching');
|
|
567
689
|
if (state.full) bits.push('waiting for a seat');
|
|
568
|
-
|
|
690
|
+
// Stopped for good: nothing is knocking any more, so it never says "reconnecting". What is true, in a word.
|
|
691
|
+
if (state.closed) bits.push({ replaced: 'opened in another tab', 'room-full': 'room full', 'too-many': 'room full', stale: 'updating…', kicked: 'removed from this room', 'room-closed': 'room closed', 'agents-off': 'room closed' }[state.closed] || 'disconnected: reload to play');
|
|
692
|
+
// The link (section 22): cut off from a room it was in and knocking again ("reconnecting" is true only then), or
|
|
693
|
+
// playing by itself because the room never answered, or did not come back in time.
|
|
694
|
+
else if (state.link && state.link.state === 'reconnecting') bits.push('reconnecting');
|
|
695
|
+
else if (state.link && state.link.state === 'alone') bits.push(state.link.why === 'reconnect-timeout' ? 'playing on your own: the room dropped' : 'playing on your own: looking for the room');
|
|
569
696
|
// The arrival card's line: the room and who is in it ("Room 2 · 3 playing · 2 AI").
|
|
570
697
|
if (!A.done && state.room) A.facts([labelOf(state.room), n ? n + ' playing' : '', aiN ? aiN + ' AI' : ''].filter(Boolean).join(' · '));
|
|
571
698
|
say(bits.join(' · ') || 'joining…');
|
|
@@ -899,7 +1026,7 @@ html, body { height: 100%; margin: 0; overflow: hidden; overscroll-behavior: non
|
|
|
899
1026
|
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; }
|
|
900
1027
|
body.big { --u: clamp(16px, calc(1vmin + 6px), 32px); }
|
|
901
1028
|
.stage { position: relative; flex: 1; min-height: 0; background: #04060c; }
|
|
902
|
-
iframe.game { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #04060c; }
|
|
1029
|
+
iframe.game { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #04060c; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
|
|
903
1030
|
.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; }
|
|
904
1031
|
.grow { flex: 1; }
|
|
905
1032
|
.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; }
|