@homie-rocks/studio 0.32.1 → 0.33.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 +67 -1
- package/README.md +24 -6
- package/agents/agents.ts +57 -24
- package/bin/homie-studio.mjs +3 -3
- package/lib/build.mjs +64 -9
- package/lib/cf-usage.mjs +76 -0
- package/lib/cloudflare.mjs +25 -2
- package/lib/dev.mjs +45 -4
- package/lib/lab-check.mjs +3 -2
- package/lib/mcp-tools.mjs +1 -1
- package/lib/mcp.mjs +1 -1
- package/lib/office.mjs +7 -0
- package/lib/perf.mjs +8 -3
- package/lib/player-image.mjs +40 -0
- package/lib/rules-build.mjs +184 -0
- package/lib/rules-guard.mjs +770 -0
- package/lib/scaffold.mjs +43 -16
- package/lib/shop-catalog.mjs +10 -9
- package/lib/shop.mjs +32 -15
- package/lib/studio.mjs +22 -1
- package/lib/template-history.json +90 -0
- package/lib/upgrade.mjs +19 -15
- package/lib/version.mjs +2 -7
- package/netplay/NETPLAY.md +249 -4
- package/netplay/netplay.ts +55 -13
- package/package.json +23 -1
- package/rules/core.ts +1250 -0
- package/rules/guard.ts +419 -0
- package/rules/host.ts +707 -0
- package/rules/math.ts +266 -0
- package/rules/pack.ts +292 -0
- package/rules/rules.ts +544 -0
- package/rules/view.ts +447 -0
- package/shop/SELLING.md +14 -16
- package/shop/SHOP.md +119 -30
- package/shop/shop.ts +6 -8
- package/site/SITE.md +119 -2
- package/starters/coin-dash/game.json +11 -0
- package/starters/coin-dash/index.html +17 -0
- package/starters/coin-dash/map/main.json +16 -0
- package/starters/coin-dash/src/move.ts +27 -0
- package/starters/coin-dash/src/rules.ts +86 -0
- package/starters/coin-dash/src/view.ts +106 -0
- package/starters/coin-dash/tunables.json +5 -0
- package/starters/ember-vale/index.html +11 -0
- package/worker/brain.mjs +8 -7
- package/worker/chat-page.mjs +1 -1
- package/worker/embed.mjs +135 -0
- package/worker/hosted.mjs +56 -0
- package/worker/index.mjs +413 -22
- package/worker/office-page.mjs +1 -0
- package/worker/office.mjs +24 -7
- package/worker/pages.mjs +189 -43
- package/worker/players.mjs +10 -6
- package/worker/referrals.mjs +192 -57
- package/worker/room-store.mjs +54 -0
- package/worker/room.mjs +294 -119
- package/worker/saves-shell.mjs +7 -1
- package/worker/schema.mjs +4 -3
- package/worker/shop-page.mjs +103 -37
- package/worker/shop-rules.mjs +156 -117
- package/worker/shop-store.mjs +70 -9
- package/worker/shop.mjs +275 -143
- package/worker/site.mjs +26 -5
- package/worker/stripe.mjs +9 -6
- package/worker/version.mjs +1 -1
package/worker/office-page.mjs
CHANGED
|
@@ -744,6 +744,7 @@ export const OFFICE_SCRIPT = String.raw`(function () {
|
|
|
744
744
|
add(el('div', 'stat'), el('span', '', 'up'), document.createTextNode(r.closedUntil ? 'closed ' + dur(r.closedUntil - now) : dur(now - r.openedAt))),
|
|
745
745
|
acts);
|
|
746
746
|
box.appendChild(head);
|
|
747
|
+
if (r.durability && !r.durability.ok) box.appendChild(el('div', 'held', 'Room saves are unavailable. Play continues; saves retry automatically. A restart may lose recent progress.' + (r.durability.message ? ' ' + r.durability.message : '')));
|
|
747
748
|
if (r.announce && r.announce.text) box.appendChild(el('div', 'held', '📣 "' + r.announce.text + '" (' + dur(r.announce.until - now) + ' left)'));
|
|
748
749
|
if (r.regate) box.appendChild(el('div', 'held', 'After this round (at the latest in ' + dur(r.regate.until - now) + '), players the new launch state leaves out are sent out with a thank-you.'));
|
|
749
750
|
if (S.open[key] !== false && (S.open[key] || r.players + r.screens <= 12)) {
|
package/worker/office.mjs
CHANGED
|
@@ -67,7 +67,7 @@ import { esc, layout, notFoundPage } from './site.mjs';
|
|
|
67
67
|
import { confirmPage, lockedPage, officePage } from './office-page.mjs';
|
|
68
68
|
import { CHAT_MODES, CHAT_WHO, checkChatRules, publicChat } from './chat.mjs';
|
|
69
69
|
import { chatDay, chatOf, chatRowsOf, clearChatRules, dismissReport, reportsOf, writeChatRules } from './chat-store.mjs';
|
|
70
|
-
import { checkRefund, checkSettle, describeRefund, officeShopPage, ordersCsv, performRefund, settleReferrer, shopOffice, statementsOut, statementsSend } from './shop.mjs';
|
|
70
|
+
import { linesOf, checkRelease, performRelease, checkRefund, checkSettle, describeRefund, officeShopPage, ordersCsv, performRefund, receivedPages, settleReferrer, shopOffice, statementsOut, statementsSend } from './shop.mjs';
|
|
71
71
|
import { orderById } from './shop-store.mjs';
|
|
72
72
|
import { checkLoungeAction, describeLounge, loungeNeedsAsk, loungeOffice, performLounge } from './lounge.mjs';
|
|
73
73
|
|
|
@@ -456,6 +456,7 @@ async function roomRow(env, meta, room, max) {
|
|
|
456
456
|
}));
|
|
457
457
|
const slots = Array.isArray(f.roster) ? f.roster : [];
|
|
458
458
|
return {
|
|
459
|
+
...(f.durability ? { durability: { ok: f.durability.ok === true, message: oneLine(f.durability.message ?? '', 160), since: Number(f.durability.since) || null } } : {}),
|
|
459
460
|
room, label: roomLabel(room), public: /^pub-\d+$/.test(room), server: roomServer(room),
|
|
460
461
|
players: f.counts?.players ?? 0, screens: f.counts?.screens ?? 0, waiting: f.counts?.waiting ?? 0,
|
|
461
462
|
humans: f.counts?.humans ?? 0, bots: f.counts?.bots ?? 0, max: f.counts?.maxPlayers ?? max,
|
|
@@ -740,6 +741,7 @@ function checkAction(cat, op, body) {
|
|
|
740
741
|
if (!(level >= 1 && level <= 5)) return bad('level is 1 to 5 (Rookie, Steady, Fair, Strong, Maxed)');
|
|
741
742
|
return { ok: true, action: { op, game: meta.id, room, level } };
|
|
742
743
|
}
|
|
744
|
+
case 'shop-release': return checkRelease(body);
|
|
743
745
|
case 'refund': return checkRefund(body);
|
|
744
746
|
case 'shop-settle': return checkSettle(body);
|
|
745
747
|
case 'agents-brain': {
|
|
@@ -841,8 +843,9 @@ export function describe(cat, a) {
|
|
|
841
843
|
case 'member': return a.remove ? `Remove ${a.name ?? 'that player'} from ${gname}'s server ${a.server} (they can join again unless its door keeps them out).` : `Make ${a.name ?? 'that player'} a ${a.role} of ${gname}'s server ${a.server}.`;
|
|
842
844
|
case 'pass': return a.action === 'revoke' ? `Revoke the agent pass ${a.id}: that AI leaves every room and cannot sit again.` : `Issue an agent pass for an AI called "${a.label} · AI".`;
|
|
843
845
|
case 'room-level': return `Set the AI in ${where} to ${levelName(a.level)} (level ${a.level}).`;
|
|
846
|
+
case 'shop-release': return `Release reservation ${a.order}: it stops counting toward the cap. We ask Stripe to expire an open Checkout Session. This does not refund a payment. A payment that still arrives grants the item, counts toward spending and can be refunded from the office.`;
|
|
844
847
|
case 'refund': return describeRefund(cat, a, a.item ? { item: a.item, amount: a.amount, currency: a.currency } : null);
|
|
845
|
-
case 'shop-settle': return `Mark what this studio owes ${a.via} for referrals as paid${a.ref ? ` (${a.ref})` : ''}: its owed lines are settled in the books.`;
|
|
848
|
+
case 'shop-settle': return `Mark what this studio owes ${a.via} for referrals in ${a.currency.toUpperCase()} as paid${a.ref ? ` (${a.ref})` : ''}: its owed lines are settled in the books.`;
|
|
846
849
|
case 'agents-brain': return ['workers-ai', 'owner-key'].includes(a.mode)
|
|
847
850
|
? `Let the AI guides on ${gname}'s server ${a.server} talk: they speak only the lines the game's own agents.json gives them (never free text), at most one line every 8 seconds, never about a person, and a player can quiet them. Their brain runs on ${a.mode === 'workers-ai' ? `this studio's own Workers AI (free allowance${a.budget !== undefined ? `; at most ${Math.round(a.budget).toLocaleString('en-US')} neurons a day` : ''})` : `your own AI provider key (claude-haiku-4-5, your money${a.budget !== undefined ? `, at most $${Number(a.budget).toFixed(2)} a day` : ', capped daily'})`}.`
|
|
848
851
|
: `Set the AI guides' brain on ${gname}'s server ${a.server} to ${a.mode}.`;
|
|
@@ -875,7 +878,7 @@ export async function needsAsk(env, cat, a) {
|
|
|
875
878
|
if (String(a.op).startsWith('lounge-')) return loungeNeedsAsk(env, cat, a, chatOpensUp);
|
|
876
879
|
if (DESTRUCTIVE.has(a.op)) return true;
|
|
877
880
|
// Money: the owner's AI may only propose a refund or a settlement; the owner says yes.
|
|
878
|
-
if (a.op === 'refund' || a.op === 'shop-settle') return true;
|
|
881
|
+
if (a.op === 'refund' || a.op === 'shop-settle' || a.op === 'shop-release') return true;
|
|
879
882
|
if (a.op === 'server-close') return !a.reopen;
|
|
880
883
|
if (a.op === 'member') return a.remove === true;
|
|
881
884
|
if (a.op === 'server-set') {
|
|
@@ -914,6 +917,15 @@ async function pushPolicy(env, meta, settings, server) {
|
|
|
914
917
|
return { rooms: rooms.length, leaving: res.reduce((n, r) => n + (r.leaving ?? 0), 0) };
|
|
915
918
|
}
|
|
916
919
|
|
|
920
|
+
/** The authenticated owner who actually confirms the release. */
|
|
921
|
+
async function releaseIdentity(request, env) {
|
|
922
|
+
const player = await players?.of?.(request, env);
|
|
923
|
+
if (player?.owner) return `account ${player.id}`;
|
|
924
|
+
const session = await ownerSession(request, env);
|
|
925
|
+
// Legacy owner sessions have no account or label. Record a fingerprint, never the credential.
|
|
926
|
+
return `owner session ${await sha256(session)}`;
|
|
927
|
+
}
|
|
928
|
+
|
|
917
929
|
/** Do it: the owner's own session did, or the owner confirmed what the AI asked. */
|
|
918
930
|
export async function perform(env, cat, a) {
|
|
919
931
|
if (String(a.op).startsWith('lounge-')) return performLounge(env, cat, a, a.origin ?? '');
|
|
@@ -1097,6 +1109,7 @@ export async function perform(env, cat, a) {
|
|
|
1097
1109
|
if (a.mode === 'off') notes.push('The guides are the game\'s plain bots.');
|
|
1098
1110
|
return { ok: true, op: a.op, game: meta.id, server: sv.id, brain: sv.brain, budget: day.budget, note: `Saved. ${notes.join(' ')}`.trim() };
|
|
1099
1111
|
}
|
|
1112
|
+
case 'shop-release': return performRelease(env, a);
|
|
1100
1113
|
case 'refund': return performRefund(env, cat, a);
|
|
1101
1114
|
case 'shop-settle': return settleReferrer(env, a);
|
|
1102
1115
|
default: return { ok: false, error: 'op' };
|
|
@@ -1242,20 +1255,22 @@ async function api(request, env, url, cat) {
|
|
|
1242
1255
|
return lounge ? json({ ok: true, lounge }) : json({ ok: false, error: 'no-lounge', message: 'This studio has no Lounge: add "lounge": true to studio.json and deploy.' }, 404);
|
|
1243
1256
|
}
|
|
1244
1257
|
// The shop (0.24.0): the owner's view, the accountant's CSV, the referral statements.
|
|
1245
|
-
if (path === '/_studio/api/shop' && request.method === 'GET') return json(await shopOffice(env, cat, url.origin));
|
|
1258
|
+
if (path === '/_studio/api/shop' && request.method === 'GET') return json(await shopOffice(env, cat, url.origin, url.searchParams.get('cursor') ?? '', /^\d+$/.test(url.searchParams.get('itemCursor') ?? '') ? Number(url.searchParams.get('itemCursor')) : 0, /^\d+$/.test(url.searchParams.get('orderCursor') ?? '') ? Math.min(Number(url.searchParams.get('orderCursor')), Number.MAX_SAFE_INTEGER) : 0));
|
|
1246
1259
|
if (path === '/_studio/api/shop/orders.csv' && request.method === 'GET') {
|
|
1247
1260
|
try {
|
|
1248
1261
|
return new Response(await ordersCsv(env), { headers: { 'content-type': 'text/csv; charset=utf-8', 'cache-control': 'no-store, private', 'content-disposition': `attachment; filename="orders-${today()}.csv"` } });
|
|
1249
1262
|
} catch { return json({ ok: false, error: 'not-migrated', message: 'The shop needs migration 0008_studio_shop.sql (npm run deploy).' }, 503); }
|
|
1250
1263
|
}
|
|
1251
|
-
if (path === '/_studio/api/shop/
|
|
1252
|
-
if (path === '/_studio/api/shop/
|
|
1264
|
+
if (path === '/_studio/api/shop/lines' && request.method === 'GET') return json({ ok: true, ...(await linesOf(env, { via: url.searchParams.get('via'), currency: url.searchParams.get('currency'), cursor: url.searchParams.get('cursor') ?? '' })) });
|
|
1265
|
+
if (path === '/_studio/api/shop/received' && request.method === 'GET') return json(await receivedPages(env, url.searchParams.get('seller') ?? '', url.searchParams.get('period') ?? '', url.searchParams.get('cursor') ?? ''));
|
|
1266
|
+
if (path === '/_studio/api/shop/statements' && request.method === 'GET') return json(await statementsOut(env, cat, url.origin, url.searchParams.get('period'), url.searchParams.get('cursor') ?? ''));
|
|
1267
|
+
if (path === '/_studio/api/shop/statements/send' && request.method === 'POST') return json(await statementsSend(env, cat, url.origin, body.period, body.cursor ?? ''));
|
|
1253
1268
|
const OPS = {
|
|
1254
1269
|
'/_studio/api/chat/rules': 'chat-rules', '/_studio/api/chat/remove': 'chat-remove', '/_studio/api/chat/report': 'chat-report', '/_studio/api/chat/budget': 'chat-budget',
|
|
1255
1270
|
'/_studio/api/kick': 'kick', '/_studio/api/mute': 'mute', '/_studio/api/close': 'close', '/_studio/api/announce': 'announce', '/_studio/api/game': 'game',
|
|
1256
1271
|
'/_studio/api/servers': 'server-create', '/_studio/api/servers/set': 'server-set', '/_studio/api/servers/close': 'server-close', '/_studio/api/servers/member': 'member',
|
|
1257
1272
|
'/_studio/api/agents/pass': 'pass', '/_studio/api/room-level': 'room-level', '/_studio/api/agents/brain': 'agents-brain',
|
|
1258
|
-
'/_studio/api/shop/refund': 'refund', '/_studio/api/shop/settle': 'shop-settle',
|
|
1273
|
+
'/_studio/api/shop/release': 'shop-release', '/_studio/api/shop/refund': 'refund', '/_studio/api/shop/settle': 'shop-settle',
|
|
1259
1274
|
'/_studio/api/lounge/rules': 'lounge-rules', '/_studio/api/lounge/night': 'lounge-night', '/_studio/api/lounge/mod': 'lounge-mod',
|
|
1260
1275
|
'/_studio/api/lounge/remove': 'lounge-remove', '/_studio/api/lounge/hold': 'lounge-hold',
|
|
1261
1276
|
};
|
|
@@ -1264,6 +1279,7 @@ async function api(request, env, url, cat) {
|
|
|
1264
1279
|
const checked = checkAction(cat, op, body);
|
|
1265
1280
|
if (!checked.ok) return json(checked, 400);
|
|
1266
1281
|
const action = checked.action;
|
|
1282
|
+
if (op === 'shop-release' && who === 'session') action.by = await releaseIdentity(request, env);
|
|
1267
1283
|
if (op === 'server-create' || op === 'pass' || op === 'lounge-night') action.origin = url.origin;
|
|
1268
1284
|
if (op === 'refund') {
|
|
1269
1285
|
// The ask and the confirm page say what the order is (its item and price), read from the books now.
|
|
@@ -1330,6 +1346,7 @@ export async function officeRoutes(request, env, url, { catalogueOf }) {
|
|
|
1330
1346
|
await env.DB.prepare("UPDATE office_asks SET state = 'working' WHERE id = ?1 AND state = 'pending'").bind(ask.id).run();
|
|
1331
1347
|
const claimed = await readAsk(env, ask.id);
|
|
1332
1348
|
if (claimed?.state !== 'working') return confirmPage(cat, askView(claimed ?? row, cat, url.origin));
|
|
1349
|
+
if (ask.action.op === 'shop-release') ask.action.by = await releaseIdentity(request, env);
|
|
1333
1350
|
const result = await perform(env, cat, ask.action);
|
|
1334
1351
|
await env.DB.prepare('UPDATE office_asks SET state = ?2, result = ?3 WHERE id = ?1').bind(ask.id, result.ok ? 'done' : 'failed', JSON.stringify(result).slice(0, 4000)).run();
|
|
1335
1352
|
return confirmPage(cat, { ...ask, state: result.ok ? 'done' : 'failed', result });
|
package/worker/pages.mjs
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* The play shell: the page around a game's sandboxed frame at /<game>/play and /<game>/tv. Every other page is
|
|
3
3
|
* worker/site.mjs. Every name a player typed is escaped.
|
|
4
4
|
*/
|
|
5
|
-
import {
|
|
5
|
+
import { playerAncestors, PLAYER_SANDBOX, PLAYER_SIZE } from './embed.mjs';
|
|
6
|
+
import { esc, layout, gameSocialTags } from './site.mjs';
|
|
6
7
|
import { NET_PALETTE } from './room.mjs';
|
|
7
8
|
import { PARAM_VALUE, PREFS_LIMITS, playParams } from './seats.mjs';
|
|
8
9
|
import { SKILLS } from './agents.mjs';
|
|
@@ -88,7 +89,7 @@ export function sharePlaces(value) {
|
|
|
88
89
|
* the edge (game.json `screen.share`, per device: sharePlaces) opens Invite, Big screen and the room code; nothing
|
|
89
90
|
* covers the middle of the screen or a thumb.
|
|
90
91
|
*/
|
|
91
|
-
export function playPage(cat, g, { screen = false, joinUrl = null, qr = null, local = false, room = null, ticket = null, owner = false, launch = 'public', server = null, acct = false, member = false, shop = null } = {}) {
|
|
92
|
+
export function playPage(cat, g, { embed = false, origin = '', preview = false, screen = false, joinUrl = null, qr = null, local = false, room = null, ticket = null, owner = false, launch = 'public', server = null, acct = false, member = false, shop = null } = {}) {
|
|
92
93
|
const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
|
|
93
94
|
const corner = (name, fallback) => (['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(g.screen?.[name]) ? g.screen[name] : fallback);
|
|
94
95
|
const places = sharePlaces(g.screen?.share);
|
|
@@ -165,7 +166,9 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
|
|
|
165
166
|
.notice .acts a { display: inline-flex; align-items: center; min-height: 44px; padding: 0 16px; border-radius: 12px; text-decoration: none; font-weight: 700; color: #eef1f8; border: 1px solid rgba(255,255,255,.18); }
|
|
166
167
|
.notice .acts a.primary { background: var(--hot); color: #0b0b10; border-color: transparent; }
|
|
167
168
|
[hidden] { display: none !important; }
|
|
168
|
-
${arrive.css}
|
|
169
|
+
${arrive.css}
|
|
170
|
+
${embed ? `html,body{touch-action:auto}.room.at-top-center{transform:none;left:auto;right:max(8px,env(safe-area-inset-right))}.embed-open{display:flex;align-items:center;min-height:44px;color:inherit}.embed-note{font:12px/1.4 system-ui;overflow-wrap:anywhere}.sheet{position:fixed;inset:calc(max(8px,env(safe-area-inset-top)) + 48px) max(8px,env(safe-area-inset-right)) auto auto;max-width:calc(100vw - max(8px,env(safe-area-inset-left)) - max(8px,env(safe-area-inset-right)));max-height:calc(100dvh - max(8px,env(safe-area-inset-top)) - 48px - max(8px,env(safe-area-inset-bottom)));overflow:auto;overscroll-behavior:contain;touch-action:pan-y}.sheet.more::after{content:'▾ more below';position:sticky;bottom:-12px;display:block;margin:0 -12px -12px;padding:16px 0 5px;text-align:center;font:600 11px/1 system-ui;color:#cfd6e6;background:linear-gradient(transparent,rgba(8,12,22,.98) 60%);pointer-events:none}.sheet .embed-help{margin:8px 2px;font:500 12px/1.4 system-ui;color:#aab3c7}html:not(.player-frame) .frame-only{display:none}.sheet a,.sheet button{min-height:44px}.sheet .foot a,.sheet .foot button{display:inline-flex;align-items:center}.embed-open .frame-only{margin-left:4px}.player-top iframe.game{inset:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);width:calc(100% - env(safe-area-inset-left) - env(safe-area-inset-right));height:calc(100% - env(safe-area-inset-top) - env(safe-area-inset-bottom))}.arrive-in{padding:12px}.arrive-title{font-size:clamp(24px,7vw,52px)}` : ''}
|
|
171
|
+
${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${shop ? SHOP_SHELL_CSS : ''}${pillUi}`;
|
|
169
172
|
// The room button's place on each device (sharePlaces); the shell moves it to this browser's once it knows the device.
|
|
170
173
|
// A ticket (a game that is not public) and the owner's overlay ride along only for the browser they are for.
|
|
171
174
|
// The server this page plays on (0.16.0): its pool (the Lobby), its badge and line for the chip, its ceiling.
|
|
@@ -173,7 +176,7 @@ ${arrive.css}${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''
|
|
|
173
176
|
// game.json "agents": { "vote": "game" } draws its own vote card; false turns the vote off (section 17).
|
|
174
177
|
const vote = g.agents?.vote === 'game' || g.agents?.vote === false ? g.agents.vote : 'shell';
|
|
175
178
|
// 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 } : {}) };
|
|
179
|
+
const boot = { embed, 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 } : {}) };
|
|
177
180
|
// game.json "screen": { "join": "top-left" | "top-right" | "bottom-left" | "bottom-right" } keeps the card off the game's own HUD.
|
|
178
181
|
const joinCorner = corner('join', 'bottom-right');
|
|
179
182
|
const first = places.desk;
|
|
@@ -181,7 +184,7 @@ ${arrive.css}${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''
|
|
|
181
184
|
// button's sheet says to deploy to share, for whoever looks for a link.
|
|
182
185
|
const joinCard = screen && joinUrl && !local ? `<div class="join join-${joinCorner}" data-join>${qr ? `<div class="qr">${qr}</div>` : ''}<div><b>Scan to play</b><span>${esc(joinUrl.replace(/^https?:\/\//, ''))}</span></div></div>` : '';
|
|
183
186
|
const share = screen ? '' : `<div class="room at-${first.at}" style="--dx:${first.x}px;--dy:${first.y}px" data-room-ui>
|
|
184
|
-
<div class="pills"><button class="pill${first.label ? '' : ' icon'}" type="button" data-share-toggle aria-expanded="false" aria-controls="share-sheet" aria-label="Room, invite and big screen"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5.5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="18.5" r="2.5"/><path d="m8.2 10.8 7.6-4.1M8.2 13.2l7.6 4.1"/></svg><span data-room-code>Room</span></button>
|
|
187
|
+
<div class="pills"><button class="pill${first.label ? '' : ' icon'}" type="button" data-share-toggle aria-expanded="false" aria-controls="share-sheet" aria-label="${embed ? 'Room, invite and the studio’s site' : 'Room, invite and big screen'}"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5.5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="18.5" r="2.5"/><path d="m8.2 10.8 7.6-4.1M8.2 13.2l7.6 4.1"/></svg><span data-room-code>Room</span></button>
|
|
185
188
|
<button class="pill spill" type="button" data-server-toggle aria-expanded="false" aria-controls="server-sheet" hidden><span class="sdot" aria-hidden="true"></span><span data-server-label></span></button></div>
|
|
186
189
|
<div class="sheet ssheet" id="server-sheet" role="dialog" aria-label="This server" data-server-sheet hidden>
|
|
187
190
|
<div class="code"><b data-server-name></b><span data-server-badge></span></div>
|
|
@@ -190,13 +193,15 @@ ${arrive.css}${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''
|
|
|
190
193
|
<label class="srow quiet-ai"><span>Quiet AI <small>hides AI chat on this screen</small></span><input type="checkbox" data-quiet-ai></label>
|
|
191
194
|
</div>
|
|
192
195
|
<div class="sheet" id="share-sheet" role="dialog" aria-label="This room" data-share-sheet hidden>
|
|
193
|
-
<div class="code"><b data-room-label>This room</b><span data-room-count></span></div
|
|
196
|
+
<div class="code"><b data-room-label>This room</b><span data-room-count></span></div>${embed ? `
|
|
197
|
+
<a class="embed-open" data-studio-open href="/${esc(g.id)}/play" target="_blank" rel="noopener noreferrer">Open on the studio's site<span class="frame-only"> ↗</span></a><div class="embed-note" data-embed-note role="status" hidden></div>` : ''}
|
|
194
198
|
<div class="acts">
|
|
195
199
|
<button type="button" class="primary" data-invite><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3M7.5 7.5 12 3l4.5 4.5M5 12v7a1.5 1.5 0 0 0 1.5 1.5h11A1.5 1.5 0 0 0 19 19v-7"/></svg><span data-invite-word>Invite</span></button>
|
|
196
200
|
<a data-bigscreen target="_blank" rel="noopener" href="/${esc(g.id)}/tv"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="4" width="19" height="13" rx="2"/><path d="M8 20.5h8"/></svg><span>Big screen</span></a>
|
|
197
201
|
</div>
|
|
202
|
+
${embed ? ` <p class="embed-help">Sound starts after a press. If pointer lock, fullscreen or a gamepad is unavailable, use keys or touch, or open on the studio’s site.<span class="frame-only"> Big screen and phone controllers open in a new tab.</span></p>` : ''}
|
|
198
203
|
<span class="link" data-room-link></span>
|
|
199
|
-
<div class="foot"><a href="/${esc(g.id)}/">← ${esc(g.name)}</a>${shop ? '<button type="button" data-shop-open>Shop</button>' : ''}<button type="button" data-copy-link>Copy link</button></div>${g.saves ? `
|
|
204
|
+
<div class="foot"><a href="/${esc(g.id)}/"${embed ? ' target="_blank" rel="noopener noreferrer"' : ''}>← ${esc(g.name)}</a>${shop ? '<button type="button" data-shop-open>Shop</button>' : ''}<button type="button" data-copy-link>Copy link</button></div>${g.saves ? `
|
|
200
205
|
<div class="who-row" data-who hidden><span data-who-name></span><button type="button" data-who-act></button></div>` : ''}
|
|
201
206
|
</div>
|
|
202
207
|
</div>
|
|
@@ -215,7 +220,7 @@ ${joinCard}${share}
|
|
|
215
220
|
<script>${ARRIVAL_JS}</script>
|
|
216
221
|
<script>${SHELL_JS}</script>${g.saves && !screen ? `
|
|
217
222
|
<script>${SAVES_SHELL_JS}</script>` : ''}${shop ? `
|
|
218
|
-
<script>${SHOP_SHELL_JS}</script>` : ''}${owner ? `<style>${OWNER_CSS}</style><script>${OWNER_JS}</script><script>${CHAT_OWNER_JS}</script>` : ''}`, css, frameAncestors(cat));
|
|
223
|
+
<script>${SHOP_SHELL_JS}</script>` : ''}${owner ? `<style>${OWNER_CSS}</style><script>${OWNER_JS}</script><script>${CHAT_OWNER_JS}</script>` : ''}`, css, embed ? embedAncestors(cat, origin, preview) : frameAncestors(cat), { embed, origin, head: gameSocialTags(cat, g, origin, launch === 'public') });
|
|
219
224
|
}
|
|
220
225
|
|
|
221
226
|
/*
|
|
@@ -262,16 +267,16 @@ const SERVER_CSS = `.spill .sdot { width: 8px; height: 8px; border-radius: 50%;
|
|
|
262
267
|
}`;
|
|
263
268
|
|
|
264
269
|
/** The play page's own document (no site chrome: the game owns the whole screen). */
|
|
265
|
-
function layoutless(title, body, css, ancestors = "'self'") {
|
|
270
|
+
function layoutless(title, body, css, ancestors = "'self'", { embed = false, origin = '', head = '' } = {}) {
|
|
266
271
|
return new Response(`<!doctype html>
|
|
267
272
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
|
268
|
-
<title>${esc(title)}</title
|
|
273
|
+
<title>${esc(title)}</title>${head}<link rel="icon" href="data:,"><style>${css}</style></head>
|
|
269
274
|
<body>${body}</body></html>`, {
|
|
270
275
|
headers: {
|
|
271
276
|
'content-type': 'text/html; charset=utf-8',
|
|
272
277
|
'cache-control': 'no-store, no-transform',
|
|
273
278
|
// Framed only by this site and the origins studio.json names (a browser that reads CSP ignores the older header).
|
|
274
|
-
'x-frame-options': 'SAMEORIGIN',
|
|
279
|
+
...(embed ? {} : { 'x-frame-options': 'SAMEORIGIN' }),
|
|
275
280
|
'content-security-policy': `frame-ancestors ${ancestors}`,
|
|
276
281
|
'x-content-type-options': 'nosniff',
|
|
277
282
|
'referrer-policy': 'strict-origin-when-cross-origin',
|
|
@@ -280,15 +285,65 @@ function layoutless(title, body, css, ancestors = "'self'") {
|
|
|
280
285
|
}
|
|
281
286
|
|
|
282
287
|
/* The shell script (runs on the site's own origin; the game runs in the sandboxed frame). */
|
|
283
|
-
|
|
288
|
+
// Included separately in each page: a successful welcome acknowledges the actual frame build.
|
|
289
|
+
const BUILD_RETRY_JS = String.raw`
|
|
290
|
+
function buildRetry(load, note) {
|
|
291
|
+
var active = false, loading = false, reported = false, failed = false, rules = false;
|
|
292
|
+
var attempt = 0, timer = null, probe = null, target = null, exhausted = false, legacy = [];
|
|
293
|
+
function cancel() { clearTimeout(timer); clearTimeout(probe); timer = probe = null; }
|
|
294
|
+
function done() { cancel(); active = false; failed = false; }
|
|
295
|
+
function giveUp() { exhausted = true; done(); note('Reload the page to play the new version.'); }
|
|
296
|
+
function run() {
|
|
297
|
+
cancel();
|
|
298
|
+
legacy = legacy.filter(function (at) { return Date.now() - at < 60000; });
|
|
299
|
+
if (rules ? attempt >= 6 : legacy.length >= 2) { giveUp(); return; }
|
|
300
|
+
if (!rules) legacy.push(Date.now());
|
|
301
|
+
active = true; loading = true; reported = false; failed = false; attempt++;
|
|
302
|
+
note('Updating the game. Loading…'); load();
|
|
303
|
+
}
|
|
304
|
+
function wait() {
|
|
305
|
+
if (!active || loading || !failed || timer !== null) return;
|
|
306
|
+
clearTimeout(probe); probe = null;
|
|
307
|
+
if (rules ? attempt >= 6 : legacy.length >= 2) { giveUp(); return; }
|
|
308
|
+
var delay = [2000, 5000, 15000][Math.min(attempt - 1, 2)];
|
|
309
|
+
note('Updating the game. Retrying in ' + delay / 1000 + ' s.');
|
|
310
|
+
timer = setTimeout(run, delay);
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
request: function (ver, immediate) {
|
|
314
|
+
if (exhausted && ver === target) return;
|
|
315
|
+
if (!active) { target = ver; rules = immediate === true; attempt = 0; exhausted = false; run(); }
|
|
316
|
+
else if (!rules) run();
|
|
317
|
+
else { failed = true; wait(); }
|
|
318
|
+
},
|
|
319
|
+
loaded: function () {
|
|
320
|
+
loading = false;
|
|
321
|
+
if (!active) return;
|
|
322
|
+
if (failed) { wait(); return; }
|
|
323
|
+
// A browser error document emits load too. Only the helper can confirm this document.
|
|
324
|
+
if (!reported && probe === null) probe = setTimeout(function () { probe = null; failed = true; wait(); }, 5000);
|
|
325
|
+
},
|
|
326
|
+
reported: function () { reported = true; clearTimeout(probe); probe = null; },
|
|
327
|
+
failed: function () { loading = false; failed = true; wait(); },
|
|
328
|
+
// Told to wait for its room, the page says so. A newer build that loads after this round is not a wait: the
|
|
329
|
+
// page is still in its room, live, and says nothing here.
|
|
330
|
+
stop: function (quiet) { done(); note(quiet === true ? '' : 'Waiting for the room.'); },
|
|
331
|
+
acknowledge: function () { var was = active; exhausted = false; done(); loading = false; note(''); return was; }
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
`;
|
|
335
|
+
|
|
336
|
+
const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
284
337
|
'use strict';
|
|
285
338
|
var boot = window.__HOMIE_PLAY;
|
|
339
|
+
var framed = window.top !== window;
|
|
340
|
+
if (boot.embed) document.documentElement.classList.add(framed ? 'player-frame' : 'player-top');
|
|
286
341
|
var params = new URLSearchParams(location.search);
|
|
287
|
-
var screenMode = params.get('screen') === '1' || boot.screen === true;
|
|
342
|
+
var screenMode = !boot.embed && (params.get('screen') === '1' || boot.screen === true);
|
|
288
343
|
// Local development (this computer's own address): never hand a phone a link it cannot open.
|
|
289
344
|
var LOCAL = /^(localhost|.*\.localhost|127\..*|\[::1\]|0\.0\.0\.0)$/.test(location.hostname);
|
|
290
|
-
var asked = params.get('hand');
|
|
291
|
-
var device = asked === 'phone' || asked === 'desk' || asked === 'tv' ? asked : Math.min(innerWidth, innerHeight) <= 540 ? 'phone' : 'desk';
|
|
345
|
+
var asked = boot.embed ? null : params.get('hand');
|
|
346
|
+
var device = asked === 'phone' || asked === 'desk' || asked === 'tv' ? asked : (boot.embed ? matchMedia('(pointer:coarse)').matches : Math.min(innerWidth, innerHeight) <= 540) ? 'phone' : 'desk';
|
|
292
347
|
var want = screenMode ? 'screen' : 'play';
|
|
293
348
|
var frame = document.querySelector('iframe.game');
|
|
294
349
|
var statusEl = document.querySelector('[data-status]');
|
|
@@ -296,6 +351,8 @@ const SHELL_JS = String.raw`(function () {
|
|
|
296
351
|
var screenCard = document.querySelector('[data-screen]');
|
|
297
352
|
var state = { game: boot.game, room: null, device: device, want: want, attached: false, stats: null, round: null, roster: null, facts: null, seat: null, results: [], closed: null, link: null, notice: null, banner: null, muted: null, server: boot.server || null, vote: null, myVote: {}, quietAi: false, full: false };
|
|
298
353
|
window.__shell = state;
|
|
354
|
+
// Where signing in comes back to. From the player address that is Play in the same room: the player itself is guests only.
|
|
355
|
+
state.returnPath = function () { return boot.embed ? '/' + boot.game + '/play' + (state.room ? '?room=' + encodeURIComponent(state.room) : '') : location.pathname + location.search; };
|
|
299
356
|
// The arrival card (worker/arrival.mjs): the game's look while the room connects and the game loads.
|
|
300
357
|
var A = window.__homieArrival || { done: true, room: function () {}, facts: function () {}, full: function () {}, message: function () {}, lift: function () {}, frameLoaded: function () {} };
|
|
301
358
|
state.arrival = A.state || null;
|
|
@@ -305,6 +362,16 @@ const SHELL_JS = String.raw`(function () {
|
|
|
305
362
|
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(/=+$/, ''); }
|
|
306
363
|
catch (e) { var r = ''; while (r.length < 22) r += Math.random().toString(36).slice(2); return r.slice(0, 22); }
|
|
307
364
|
}
|
|
365
|
+
// The player address (a post's player card): a guest name and room of its own, never the address's. Opened as a
|
|
366
|
+
// page of its own, and while this tab's storage works, they are kept for the visit: a reload, Back or Forward comes
|
|
367
|
+
// back to the same room, seat and name. In a frame nothing is kept: two posts of one game on a page are two frames
|
|
368
|
+
// sharing one sessionStorage, and they must stay two players.
|
|
369
|
+
var keeps = !boot.embed || !framed;
|
|
370
|
+
var EMBED_ROOM = /^(?:pub-[1-9][0-9]*|main)$/;
|
|
371
|
+
var EMBED_KEY = 'homie-player.' + boot.game;
|
|
372
|
+
var kept = null;
|
|
373
|
+
if (boot.embed && keeps) try { kept = JSON.parse(sessionStorage.getItem(EMBED_KEY) || 'null'); } catch (e) { kept = null; }
|
|
374
|
+
var guestName = kept && /^Guest [A-Za-z0-9_-]{6}$/.test(kept.name || '') ? kept.name : 'Guest ' + rnd().slice(0, 6);
|
|
308
375
|
var roomKey = null;
|
|
309
376
|
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(); }
|
|
310
377
|
|
|
@@ -366,25 +433,34 @@ const SHELL_JS = String.raw`(function () {
|
|
|
366
433
|
if (sp && (!ss || ss.hidden)) sp.classList.add('dim');
|
|
367
434
|
}, 6000);
|
|
368
435
|
}
|
|
436
|
+
// The player address in a short frame: a sheet that scrolls says there is more below (the hint is 30 px of its own).
|
|
437
|
+
function more(el) { if (!el.hidden) el.classList.toggle('more', el.scrollTop + el.clientHeight < el.scrollHeight - (el.classList.contains('more') ? 34 : 4)); }
|
|
438
|
+
if (boot.embed) {
|
|
439
|
+
[].forEach.call(document.querySelectorAll('.sheet'), function (el) {
|
|
440
|
+
el.addEventListener('scroll', function () { more(el); });
|
|
441
|
+
try { new MutationObserver(function () { more(el); }).observe(el, { attributes: true, attributeFilter: ['hidden'] }); } catch (e) {}
|
|
442
|
+
});
|
|
443
|
+
addEventListener('resize', function () { [].forEach.call(document.querySelectorAll('.sheet'), more); });
|
|
444
|
+
}
|
|
369
445
|
function open(on) { if (!sheet) return; sheet.hidden = !on; toggle.setAttribute('aria-expanded', on ? 'true' : 'false'); if (on) wake(); else { wake(); try { frame.focus(); } catch (e) {} } }
|
|
370
446
|
function copy(text, done) {
|
|
371
447
|
var ok = function () { flash(done || 'Link copied'); };
|
|
372
|
-
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(ok, function () { flash(text); });
|
|
373
|
-
else flash(text);
|
|
448
|
+
try { if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(ok, function () { flash(text); });
|
|
449
|
+
else flash(text); } catch (e) { flash(text); }
|
|
374
450
|
}
|
|
375
451
|
function shareReady(room) {
|
|
376
452
|
if (!ui) return;
|
|
377
453
|
var link = location.origin + '/' + boot.game + '/play?room=' + encodeURIComponent(room);
|
|
378
454
|
state.link = link;
|
|
379
|
-
ui.querySelector('[data-room-code]').textContent = labelOf(room);
|
|
455
|
+
ui.querySelector('[data-room-code]').textContent = labelOf(room) + (boot.embed ? ' \u00b7 Site' : '');
|
|
380
456
|
ui.querySelector('[data-room-label]').textContent = labelOf(room);
|
|
381
457
|
ui.querySelector('[data-room-link]').textContent = LOCAL ? 'Deploy to share: this preview address works on this computer only.' : link.replace(/^https?:\/\//, '');
|
|
382
458
|
ui.querySelector('[data-bigscreen]').href = '/' + boot.game + '/tv?room=' + encodeURIComponent(room);
|
|
383
459
|
toggle.addEventListener('click', function (e) { e.stopPropagation(); open(sheet.hidden); });
|
|
384
460
|
ui.querySelector('[data-invite]').addEventListener('click', function () {
|
|
385
461
|
var data = { title: boot.name, text: 'Play ' + boot.name + ' with me: join my room.', url: link };
|
|
386
|
-
if (navigator.share && (!navigator.canShare || navigator.canShare(data))) navigator.share(data).catch(function () {});
|
|
387
|
-
else copy(link, 'Invite link copied');
|
|
462
|
+
try { if (navigator.share && (!navigator.canShare || navigator.canShare(data))) navigator.share(data).catch(function () { copy(link, 'Invite link copied'); });
|
|
463
|
+
else copy(link, 'Invite link copied'); } catch (e) { copy(link, 'Invite link copied'); }
|
|
388
464
|
});
|
|
389
465
|
ui.querySelector('[data-copy-link]').addEventListener('click', function () { copy(link); });
|
|
390
466
|
document.addEventListener('pointerdown', function (e) { if (!sheet.hidden && !ui.contains(e.target)) open(false); }, true);
|
|
@@ -394,6 +470,9 @@ const SHELL_JS = String.raw`(function () {
|
|
|
394
470
|
|
|
395
471
|
function start(room) {
|
|
396
472
|
state.room = room;
|
|
473
|
+
if (boot.embed && keeps) try { sessionStorage.setItem(EMBED_KEY, JSON.stringify({ room: room, name: guestName })); } catch (e) {}
|
|
474
|
+
var studioOpen = document.querySelector('[data-studio-open]');
|
|
475
|
+
if (studioOpen) studioOpen.href = '/' + boot.game + '/play?room=' + encodeURIComponent(room);
|
|
397
476
|
// The room goes into the address: a reload comes back to it, and a copied address brings a friend into it.
|
|
398
477
|
try {
|
|
399
478
|
if (params.get('room') !== room) {
|
|
@@ -402,17 +481,20 @@ const SHELL_JS = String.raw`(function () {
|
|
|
402
481
|
history.replaceState(history.state, '', location.pathname + '?' + params.toString() + location.hash);
|
|
403
482
|
}
|
|
404
483
|
} catch (e) {}
|
|
484
|
+
var memoryToken = null;
|
|
405
485
|
var KEY = 'homie-net.' + boot.game + '.' + room + '.' + want;
|
|
406
486
|
// 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
487
|
function frameUrl() {
|
|
408
|
-
var token =
|
|
409
|
-
try { token = sessionStorage.getItem(KEY); } catch (e) {}
|
|
488
|
+
var token = memoryToken;
|
|
489
|
+
try { if (keeps) token = sessionStorage.getItem(KEY) || memoryToken; } catch (e) {}
|
|
410
490
|
var q = new URLSearchParams({ room: room, device: device, want: want });
|
|
491
|
+
if (boot.embed) q.set('embed', '1');
|
|
411
492
|
if (token) q.set('k', token);
|
|
412
493
|
q.set('b', roomKey);
|
|
494
|
+
if (boot.embed) q.set('name', guestName);
|
|
413
495
|
if (boot.t) q.set('t', boot.t);
|
|
414
|
-
if (params.get('name')) q.set('name', params.get('name'));
|
|
415
|
-
else {
|
|
496
|
+
if (!boot.embed && params.get('name')) q.set('name', params.get('name'));
|
|
497
|
+
else if (!boot.embed) {
|
|
416
498
|
// A player with an account (or a named guest) on this studio plays under their own name in every room.
|
|
417
499
|
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
500
|
}
|
|
@@ -431,27 +513,24 @@ const SHELL_JS = String.raw`(function () {
|
|
|
431
513
|
}
|
|
432
514
|
state.params = {};
|
|
433
515
|
(boot.params || []).forEach(function (k) { var v = params.get(k); if (v !== null && new RegExp(boot.paramValue || '^$').test(v)) state.params[k] = v; });
|
|
434
|
-
|
|
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);
|
|
516
|
+
var updating = buildRetry(function () {
|
|
442
517
|
state.stale = null; state.closed = null; state.reloaded = (state.reloaded || 0) + 1;
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
}
|
|
518
|
+
frame.src = frameUrl();
|
|
519
|
+
}, function (text) { clearTimeout(flash.t); if (toast) { toast.textContent = text; toast.hidden = !text; } });
|
|
520
|
+
function reloadGame() { updating.request(state.stale && state.stale.ver, state.stale && state.stale.immediate); }
|
|
446
521
|
frame.src = frameUrl();
|
|
447
522
|
A.room(labelOf(room));
|
|
448
|
-
frame.addEventListener('
|
|
523
|
+
frame.addEventListener('error', updating.failed);
|
|
524
|
+
frame.addEventListener('load', function () { updating.loaded(); A.frameLoaded(); try { frame.focus(); frame.contentWindow.focus(); } catch (e) {} });
|
|
449
525
|
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 });
|
|
450
526
|
window.addEventListener('message', function (ev) {
|
|
451
527
|
if (ev.source !== frame.contentWindow) return;
|
|
452
528
|
var m = ev.data;
|
|
453
529
|
if (!m || typeof m !== 'object' || m.t !== 'homie-net') return;
|
|
454
530
|
A.message(m);
|
|
531
|
+
if (m.what === 'wait') updating.stop();
|
|
532
|
+
if (m.what === 'build' && updating.acknowledge(m.ver) && toast) toast.hidden = true;
|
|
533
|
+
if (m.what === 'attached' || m.what === 'ready') updating.reported();
|
|
455
534
|
if (m.what === 'attached') { state.attached = true; lastRects = ''; tellRects(); }
|
|
456
535
|
// The link (section 22): in the room, knocking again, or playing alone because the room never answered.
|
|
457
536
|
if (m.what === 'link' && typeof m.state === 'string') state.link = { state: m.state, why: String(m.why || ''), hosting: m.hosting === true, at: Date.now() };
|
|
@@ -467,12 +546,20 @@ const SHELL_JS = String.raw`(function () {
|
|
|
467
546
|
if (m.what === 'prefs') answerPrefs(m);
|
|
468
547
|
// A newer build of the game is live. Kept out of a room for it (final): the game loads again now. Still
|
|
469
548
|
// playing in its own room: it loads again at the round's break, or at once on a big screen (nothing to lose).
|
|
549
|
+
if (m.what === 'rematch' && m.room === room) {
|
|
550
|
+
var fresh = new URL(location.href);
|
|
551
|
+
fresh.searchParams.delete('room'); fresh.searchParams.set('not', room);
|
|
552
|
+
location.replace(fresh.href);
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
470
555
|
if (m.what === 'stale') {
|
|
471
|
-
state.stale = { ver: typeof m.ver === 'string' ? m.ver : null, final: m.final === true };
|
|
472
|
-
if (m.final
|
|
556
|
+
state.stale = { ver: typeof m.ver === 'string' ? m.ver : null, final: m.final === true, immediate: m.immediate === true };
|
|
557
|
+
if (m.final !== true && m.immediate !== true && !screenMode) updating.stop(true);
|
|
558
|
+
if (m.final === true || m.immediate === true || screenMode) reloadGame();
|
|
473
559
|
else flash('A new version of ' + boot.name + ' is ready: it loads after this round.');
|
|
474
560
|
}
|
|
475
|
-
if (
|
|
561
|
+
if (boot.embed && m.what === 'embed-unavailable') { var note = document.querySelector('[data-embed-note]'); if (note) { note.hidden = false; note.textContent = framed ? 'This frame cannot use ' + String(m.feature).slice(0, 30) + '. Open on the studio’s site for full controls.' : String(m.feature).slice(0, 30) + ' is unavailable here: use keys or touch.'; } }
|
|
562
|
+
if (m.what === 'token' && typeof m.token === 'string') { memoryToken = m.token; state.seat = m.seat; if (typeof m.seat === 'number') state.full = false; try { if (keeps) sessionStorage.setItem(KEY, m.token); } catch (e) {} if (window.__homieChat) window.__homieChat.seat(); }
|
|
476
563
|
if (m.what === 'stats') state.stats = m.stats;
|
|
477
564
|
if (m.what === 'round') { onRound(m.round); if (state.stale && m.round && m.round.phase === 'over') reloadGame(); }
|
|
478
565
|
if (m.what === 'roster') state.roster = m.slots;
|
|
@@ -806,9 +893,43 @@ const SHELL_JS = String.raw`(function () {
|
|
|
806
893
|
if (state.vote.options.indexOf(n) >= 0) { e.preventDefault(); cast(n); }
|
|
807
894
|
});
|
|
808
895
|
|
|
896
|
+
if (boot.embed) {
|
|
897
|
+
state.openStudio = function (href) {
|
|
898
|
+
if (!framed) { location.assign(href); return; }
|
|
899
|
+
var opened = null;
|
|
900
|
+
try { opened = window.open(href, '_blank'); if (opened) opened.opener = null; } catch (e) {}
|
|
901
|
+
if (!opened) {
|
|
902
|
+
open(true);
|
|
903
|
+
var note = document.querySelector('[data-embed-note]');
|
|
904
|
+
note.hidden = false; note.textContent = 'This host blocked opening a tab. Copy this address into your browser: ' + new URL(href, location.href).href;
|
|
905
|
+
}
|
|
906
|
+
};
|
|
907
|
+
document.addEventListener('click', function (e) {
|
|
908
|
+
var a = e.target.closest && e.target.closest('a[href]');
|
|
909
|
+
if (!a) return;
|
|
910
|
+
var u = new URL(a.href, location.href);
|
|
911
|
+
if (u.origin === location.origin && u.pathname === '/' + boot.game + '/play/embed') return;
|
|
912
|
+
e.preventDefault(); state.openStudio(a.href);
|
|
913
|
+
}, true);
|
|
914
|
+
if (self.origin === 'null') {
|
|
915
|
+
A.lift(); open(true);
|
|
916
|
+
var note = document.querySelector('[data-embed-note]');
|
|
917
|
+
note.hidden = false; note.textContent = 'This host prevents the game from connecting. Open on the studio’s site to play.';
|
|
918
|
+
say('Open on the studio’s site to play');
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
809
923
|
// A named room (?room=, a friend's link, a test, the big screen's QR) skips the lobby; everyone else meets strangers.
|
|
810
924
|
// The page refuses a code it cannot use before it gets here, so an asked room is never swapped for a public one.
|
|
811
925
|
var askedRoom = params.get('room');
|
|
926
|
+
if (boot.embed) {
|
|
927
|
+
// The player address never takes a room from its address. A reload, Back or Forward goes back to the room this
|
|
928
|
+
// visit was in (as Play's own address does); a fresh arrival asks the lobby where people are.
|
|
929
|
+
var arrived = '';
|
|
930
|
+
try { arrived = performance.getEntriesByType('navigation')[0].type; } catch (e) {}
|
|
931
|
+
askedRoom = kept && (arrived === 'reload' || arrived === 'back_forward') && EMBED_ROOM.test(kept.room || '') ? kept.room : null;
|
|
932
|
+
}
|
|
812
933
|
var lobbyQ = [];
|
|
813
934
|
if (state.server) lobbyQ.push('server=' + encodeURIComponent(state.server.id));
|
|
814
935
|
if (/^[A-Za-z0-9_,-]{1,140}$/.test(params.get('not') || '')) lobbyQ.push('not=' + encodeURIComponent(params.get('not')));
|
|
@@ -990,7 +1111,7 @@ const OWNER_JS = String.raw`(function () {
|
|
|
990
1111
|
* watchers see the whole room only. A private or invite-only game is watched only by whoever may play it (the door
|
|
991
1112
|
* is the play door's). Every name a player typed is set with textContent.
|
|
992
1113
|
*/
|
|
993
|
-
export function watchPage(cat, g, { room = null, ticket = null, policy = 'follow', owner = false, acct = false, member = false } = {}) {
|
|
1114
|
+
export function watchPage(cat, g, { origin = '', room = null, ticket = null, policy = 'follow', owner = false, acct = false, member = false } = {}) {
|
|
994
1115
|
const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
|
|
995
1116
|
const hot = /^#[0-9a-f]{3,8}$/i.test(accent) ? accent : '#ffcf5a';
|
|
996
1117
|
const boot = { game: g.id, name: g.name, policy, palette: NET_PALETTE, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}) };
|
|
@@ -998,6 +1119,7 @@ export function watchPage(cat, g, { room = null, ticket = null, policy = 'follow
|
|
|
998
1119
|
return layoutless(`Watch ${g.name}`, `
|
|
999
1120
|
<header class="wtop" data-top>
|
|
1000
1121
|
<span class="live" data-live><i aria-hidden="true"></i><b>Live</b></span>
|
|
1122
|
+
<span data-update role="status" hidden></span>
|
|
1001
1123
|
<span class="what"><b>${esc(g.name)}</b><span data-room-label>finding a room…</span></span>
|
|
1002
1124
|
<span class="clock" data-clock hidden></span>
|
|
1003
1125
|
<span class="grow"></span>
|
|
@@ -1013,7 +1135,7 @@ export function watchPage(cat, g, { room = null, ticket = null, policy = 'follow
|
|
|
1013
1135
|
<script>window.__HOMIE_WATCH=${JSON.stringify(boot).replace(/</g, '\\u003c')};</script>
|
|
1014
1136
|
<script>window.__HOMIE_CHAT=${JSON.stringify(chatBoot(g, { surface: 'watch', owner, acct, member })).replace(/</g, '\\u003c')};</script>
|
|
1015
1137
|
<script>${CHAT_JS}</script>${owner ? `<script>${CHAT_OWNER_JS}</script>` : ''}
|
|
1016
|
-
<script>${WATCH_JS}</script>`, css, frameAncestors(cat));
|
|
1138
|
+
<script>${WATCH_JS}</script>`, css, frameAncestors(cat), { head: gameSocialTags(cat, g, origin) });
|
|
1017
1139
|
}
|
|
1018
1140
|
|
|
1019
1141
|
/*
|
|
@@ -1094,12 +1216,12 @@ body.idle { cursor: none; }
|
|
|
1094
1216
|
[hidden] { display: none !important; }`;
|
|
1095
1217
|
|
|
1096
1218
|
/* The watch page's script (the site's own origin; the game runs in the sandboxed frame as a watcher). */
|
|
1097
|
-
const WATCH_JS = String.raw
|
|
1219
|
+
const WATCH_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
1098
1220
|
'use strict';
|
|
1099
1221
|
var boot = window.__HOMIE_WATCH;
|
|
1100
1222
|
var params = new URLSearchParams(location.search);
|
|
1101
1223
|
var hand = params.get('hand');
|
|
1102
|
-
var device = hand === 'phone' || hand === 'desk' || hand === 'tv' ? hand : Math.min(innerWidth, innerHeight) <= 540 ? 'phone' : 'desk';
|
|
1224
|
+
var device = hand === 'phone' || hand === 'desk' || hand === 'tv' ? hand : (boot.embed ? matchMedia('(pointer:coarse)').matches : Math.min(innerWidth, innerHeight) <= 540) ? 'phone' : 'desk';
|
|
1103
1225
|
var frame = document.querySelector('iframe.game');
|
|
1104
1226
|
var q1 = function (s) { return document.querySelector(s); };
|
|
1105
1227
|
var strip = q1('[data-strip]'), note = q1('[data-note]');
|
|
@@ -1175,10 +1297,22 @@ const WATCH_JS = String.raw`(function () {
|
|
|
1175
1297
|
if (params.get('debug') === '1') q.set('debug', '1');
|
|
1176
1298
|
['cam', 'view'].forEach(function (k) { var v = params.get(k); if (v && /^[A-Za-z0-9_-]{1,16}$/.test(v)) q.set(k, v); });
|
|
1177
1299
|
frame.src = '/' + boot.game + '/__game/?' + q.toString();
|
|
1300
|
+
var updating = buildRetry(function () { frame.src = '/' + boot.game + '/__game/?' + q.toString(); }, function (text) { var el = q1('[data-update]'); if (el) { el.textContent = text; el.hidden = !text; } });
|
|
1301
|
+
frame.addEventListener('load', updating.loaded);
|
|
1302
|
+
frame.addEventListener('error', updating.failed);
|
|
1178
1303
|
addEventListener('message', function (ev) {
|
|
1179
1304
|
if (ev.source !== frame.contentWindow) return;
|
|
1180
1305
|
var m = ev.data;
|
|
1181
1306
|
if (!m || typeof m !== 'object' || m.t !== 'homie-net') return;
|
|
1307
|
+
if (m.what === 'rematch' && m.room === room) {
|
|
1308
|
+
var fresh = new URL(location.href); fresh.searchParams.delete('room'); fresh.searchParams.set('not', room);
|
|
1309
|
+
location.replace(fresh.href); return;
|
|
1310
|
+
}
|
|
1311
|
+
if (m.what === 'wait') updating.stop();
|
|
1312
|
+
else if (m.what === 'stale' && m.final !== true && m.immediate !== true) updating.stop(true);
|
|
1313
|
+
if (m.what === 'stale' && (m.final === true || m.immediate === true)) updating.request(m.ver, m.immediate);
|
|
1314
|
+
if (m.what === 'build') updating.acknowledge(m.ver);
|
|
1315
|
+
if (m.what === 'attached' || m.what === 'ready') updating.reported();
|
|
1182
1316
|
if (m.what === 'attached') {
|
|
1183
1317
|
state.attached = true;
|
|
1184
1318
|
// A helper from before revision 5 never says what it renders: after a moment, the room's overview it is.
|
|
@@ -1390,3 +1524,15 @@ const WATCH_JS = String.raw`(function () {
|
|
|
1390
1524
|
look();
|
|
1391
1525
|
}
|
|
1392
1526
|
}());`;
|
|
1527
|
+
|
|
1528
|
+
export function embedAncestors(cat, origin, preview = false) {
|
|
1529
|
+
const normal = playerAncestors(cat);
|
|
1530
|
+
if (!preview || !/^http:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/.test(origin)) return normal;
|
|
1531
|
+
const port = new URL(origin).port;
|
|
1532
|
+
return `${normal === "'none'" ? '' : normal} http://localhost${port ? ':' + port : ''} http://127.0.0.1${port ? ':' + port : ''}`.trim();
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
export function embedPreview(cat, g, origin) {
|
|
1536
|
+
const frameOrigin = new URL(origin); frameOrigin.hostname = frameOrigin.hostname === 'localhost' ? '127.0.0.1' : 'localhost';
|
|
1537
|
+
return layoutless(`Preview ${g.name} in a post`, `<h1>Player card preview</h1><p>A cross-origin mock post using the sandbox observed in X’s web client. X’s phone apps open the player full screen; its website opens third-party games in a new tab.</p><iframe title="${esc(g.name)}" src="${esc(frameOrigin.origin)}/${esc(g.id)}/play/embed" sandbox="${PLAYER_SANDBOX}" allow="autoplay; fullscreen; web-share" allowfullscreen scrolling="no" style="width:100%;aspect-ratio:${PLAYER_SIZE.width}/${PLAYER_SIZE.height};border:0"></iframe>`, 'body{max-width:480px;margin:24px auto;background:#101622;color:white;font:16px system-ui}');
|
|
1538
|
+
}
|