@homie-rocks/studio 0.48.3 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/app-starters/gathering/README.md +4 -0
- package/app-starters/gathering/app.json +162 -0
- package/app-starters/gathering/index.html +3 -0
- package/app-starters/gathering/map/main.json +1 -0
- package/app-starters/gathering/src/move.ts +3 -0
- package/app-starters/gathering/src/rules.ts +21 -0
- package/app-starters/gathering/src/view.ts +74 -0
- package/app-starters/giving/README.md +5 -0
- package/app-starters/giving/app.json +162 -0
- package/app-starters/giving/index.html +3 -0
- package/app-starters/giving/map/main.json +1 -0
- package/app-starters/giving/src/move.ts +3 -0
- package/app-starters/giving/src/rules.ts +21 -0
- package/app-starters/giving/src/view.ts +74 -0
- package/app-starters/jobs/README.md +4 -0
- package/app-starters/jobs/app.json +162 -0
- package/app-starters/jobs/index.html +3 -0
- package/app-starters/jobs/map/main.json +1 -0
- package/app-starters/jobs/src/move.ts +3 -0
- package/app-starters/jobs/src/rules.ts +21 -0
- package/app-starters/jobs/src/view.ts +74 -0
- package/app-starters/quiz/README.md +4 -0
- package/app-starters/quiz/app.json +162 -0
- package/app-starters/quiz/index.html +3 -0
- package/app-starters/quiz/map/main.json +1 -0
- package/app-starters/quiz/src/move.ts +3 -0
- package/app-starters/quiz/src/rules.ts +21 -0
- package/app-starters/quiz/src/view.ts +74 -0
- package/app-starters/welcome/app.json +25 -2
- package/app-starters/welcome/index.html +1 -1
- package/app-starters/welcome/src/view.ts +20 -8
- package/bin/homie-studio.mjs +15 -11
- package/guides/app/SKILL.md +72 -0
- package/guides/parts/SKILL.md +158 -0
- package/guides/shop/SKILL.md +149 -0
- package/guides/studio-setup/SKILL.md +419 -0
- package/lib/authoring-guides.mjs +5 -4
- package/lib/build.mjs +6 -4
- package/lib/check-site.mjs +6 -6
- package/lib/check.mjs +17 -6
- package/lib/dev.mjs +4 -2
- package/lib/doctor.mjs +1 -1
- package/lib/handoff.mjs +13 -8
- package/lib/mcp-tools.mjs +10 -9
- package/lib/parts.mjs +6 -6
- package/lib/rules-wiring.mjs +30 -0
- package/lib/scaffold.mjs +36 -10
- package/lib/shop-links.mjs +2 -2
- package/lib/shop.mjs +5 -3
- package/lib/studio.mjs +5 -4
- package/lib/template-history.json +84 -0
- package/package.json +4 -4
- package/worker/app-format.mjs +1 -1
- package/worker/build-id.mjs +1 -1
- package/worker/index.mjs +30 -10
- package/worker/pages.mjs +4 -2
- package/worker/parts.mjs +4 -4
- package/worker/rules-wiring.mjs +2 -0
- package/worker/shop-page.mjs +21 -15
- package/worker/shop-rules.mjs +4 -3
- package/worker/site.mjs +4 -2
- package/worker/version.mjs +1 -1
package/worker/index.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { RULES_WIRING_MESSAGE } from './rules-wiring.mjs';
|
|
1
2
|
import { characterStore, characterRequest } from './characters.mjs';
|
|
2
3
|
export { useFunctions } from './functions.mjs';
|
|
3
4
|
import { appAccess, appRecordsRoute } from './app-records.mjs';
|
|
@@ -530,7 +531,8 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
|
|
|
530
531
|
...(!agent && !watching && await availableShellShop(env, cat, game) ? { shop: true } : {}),
|
|
531
532
|
};
|
|
532
533
|
let html = await res.text();
|
|
533
|
-
const
|
|
534
|
+
const fallback = meta?.kind === 'app' ? `<script>addEventListener('DOMContentLoaded',function(){if(!document.querySelector('canvas')||document.querySelector('[data-webgl-fallback]'))return;try{var c=document.createElement('canvas');if(c.getContext('webgl2'))return;}catch(e){}var p=document.createElement('p');p.dataset.webglFallback='';p.setAttribute('role','status');p.textContent=${JSON.stringify(meta.words?.fallback ?? 'This device cannot display the 3D scene. Try a browser with WebGL enabled; use any available controls below.').replace(/</g, '\u003c')};p.style.cssText='position:fixed;z-index:2147483647;left:16px;right:16px;top:80px;padding:16px;background:#fff;color:#222;font:16px system-ui;border-radius:12px';document.body.append(p);});</script>` : '';
|
|
535
|
+
const head = fallback + `${embedded ? `<script>${EMBED_GAME_JS}</script>` : ''}<script>window.HOMIE_NET=${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script>`;
|
|
534
536
|
html = /<head[^>]*>/i.test(html) ? html.replace(/<head([^>]*)>/i, `<head$1>${head}`) : head + html;
|
|
535
537
|
return new Response(html, {
|
|
536
538
|
headers: {
|
|
@@ -763,7 +765,7 @@ async function route(request, env, ctx) {
|
|
|
763
765
|
}
|
|
764
766
|
if (path === '/api/games') {
|
|
765
767
|
const cat = await getCat();
|
|
766
|
-
return json({ studio: cat.studio, games: cat.games, live: await liveCounts(env, cat.games ?? []) });
|
|
768
|
+
return json({ studio: cat.studio, games: (cat.games ?? []).map(g => g.room?.host === 'server' && !hostedGame(g.id) ? { ...g, rulesError: RULES_WIRING_MESSAGE } : g), live: await liveCounts(env, cat.games ?? []) });
|
|
767
769
|
}
|
|
768
770
|
if (path === '/api/rooms') {
|
|
769
771
|
const cat = await getCat();
|
|
@@ -1051,7 +1053,7 @@ async function route(request, env, ctx) {
|
|
|
1051
1053
|
// The big screen picks its room now, so the QR it shows puts every phone in that same room.
|
|
1052
1054
|
let room = asked;
|
|
1053
1055
|
if (!room) {
|
|
1054
|
-
try { room = (await (await lobby().fetch(`https://lobby/join?max=${humanSeats(pol)}&server=${srv.id}&rooms=${srv.roomsMax}${versionOf(meta?.netplay?.version) ? `&ver=${encodeURIComponent(versionOf(meta.netplay.version))}` : ''}`, { method: 'POST' })).json()).room ?? null; } catch { room = null; }
|
|
1056
|
+
try { room = (await (await lobby().fetch(`https://lobby/join?host=${meta?.room?.host === 'server' ? 'server' : 'browser'}&max=${humanSeats(pol)}&server=${srv.id}&rooms=${srv.roomsMax}${versionOf(meta?.netplay?.version) ? `&ver=${encodeURIComponent(versionOf(meta.netplay.version))}` : ''}`, { method: 'POST' })).json()).room ?? null; } catch { room = null; }
|
|
1055
1057
|
}
|
|
1056
1058
|
const joinUrl = `${url.origin}${openPath(meta)}${room ? `?room=${encodeURIComponent(room)}` : ''}`;
|
|
1057
1059
|
// Local development: no phone can open this computer's own address, so no code for it; the card says to deploy.
|
|
@@ -1124,7 +1126,7 @@ async function route(request, env, ctx) {
|
|
|
1124
1126
|
if (not.split(',').includes(room)) return json({ ok: false, error: 'world-unavailable', message: 'This world is unavailable. Choose another named world.' }, 409);
|
|
1125
1127
|
return json({ room, players: 0, max: humanSeats(pol), server: srv.id });
|
|
1126
1128
|
}
|
|
1127
|
-
return lobby().fetch(`https://lobby/join?max=${humanSeats(pol)}&server=${srv.id}&rooms=${srv.roomsMax}${not ? `¬=${encodeURIComponent(not)}` : ''}${ver ? `&ver=${encodeURIComponent(ver)}` : ''}${app ? '&app=1' : ''}`, { method: 'POST' });
|
|
1129
|
+
return lobby().fetch(`https://lobby/join?host=${meta?.room?.host === 'server' ? 'server' : 'browser'}&max=${humanSeats(pol)}&server=${srv.id}&rooms=${srv.roomsMax}${not ? `¬=${encodeURIComponent(not)}` : ''}${ver ? `&ver=${encodeURIComponent(ver)}` : ''}${app ? '&app=1' : ''}`, { method: 'POST' });
|
|
1128
1130
|
}
|
|
1129
1131
|
if (sub === 'api/agent') {
|
|
1130
1132
|
// An AI's seat (worker/agents.mjs): its pass, the server's policy, a room with people in it, a ticket.
|
|
@@ -1136,7 +1138,7 @@ async function route(request, env, ctx) {
|
|
|
1136
1138
|
join: async (srv, pol) => {
|
|
1137
1139
|
try {
|
|
1138
1140
|
const reserve = pol.aiSeats + pol.guides;
|
|
1139
|
-
const r = await (await lobby().fetch(`https://lobby/join?max=${max}&server=${srv.id}&agent=1&ai=${reserve}${versionOf(meta?.netplay?.version) ? `&ver=${encodeURIComponent(versionOf(meta.netplay.version))}` : ''}`, { method: 'POST' })).json();
|
|
1141
|
+
const r = await (await lobby().fetch(`https://lobby/join?host=${meta?.room?.host === 'server' ? 'server' : 'browser'}&max=${max}&server=${srv.id}&agent=1&ai=${reserve}${versionOf(meta?.netplay?.version) ? `&ver=${encodeURIComponent(versionOf(meta.netplay.version))}` : ''}`, { method: 'POST' })).json();
|
|
1140
1142
|
return r.room ?? null;
|
|
1141
1143
|
} catch { return null; }
|
|
1142
1144
|
},
|
|
@@ -1429,7 +1431,7 @@ export class Table {
|
|
|
1429
1431
|
ensureHost(room, hosted) {
|
|
1430
1432
|
if (this.hostRt || !this.game) return;
|
|
1431
1433
|
if (!hostedGame(this.game)) {
|
|
1432
|
-
if (hosted) room.failServerHost(
|
|
1434
|
+
if (hosted) room.failServerHost(RULES_WIRING_MESSAGE);
|
|
1433
1435
|
return;
|
|
1434
1436
|
}
|
|
1435
1437
|
let restoring = false;
|
|
@@ -1461,7 +1463,7 @@ export class Table {
|
|
|
1461
1463
|
this.finishRoom(why);
|
|
1462
1464
|
room.forget(); room.ended = null;
|
|
1463
1465
|
// Only a holder returning to the old match is rematched. A new visitor uses this name immediately.
|
|
1464
|
-
if (!expired) room.endedMatch = { why, ver: build.build, tokens, until: Date.now() + ROOM_PAUSE_MS };
|
|
1466
|
+
if (!expired && pooledRoom(this.code)) room.endedMatch = { why, ver: build.build, tokens, until: Date.now() + ROOM_PAUSE_MS };
|
|
1465
1467
|
this.room = room; saved = null; restoring = false; boot = { epoch: 0, count: 0 };
|
|
1466
1468
|
}
|
|
1467
1469
|
this.roomSave = saved;
|
|
@@ -2469,7 +2471,7 @@ export class Table {
|
|
|
2469
2471
|
const lobby = this.env.LOBBY.get(this.env.LOBBY.idFromName(this.game));
|
|
2470
2472
|
// The build this room runs (section 23): the Lobby sends a visitor only to a room of the live build.
|
|
2471
2473
|
const ver = room.gameVer === undefined ? undefined : room.gameVer ?? '';
|
|
2472
|
-
this.ctx.waitUntil(lobby.fetch(`https://lobby/report?game=${encodeURIComponent(this.game)}`, { method: 'POST', body: JSON.stringify({ room: this.code, players, people, agents, ai, ...(ver !== undefined ? { ver } : {}) }) }).catch(() => {}));
|
|
2474
|
+
this.ctx.waitUntil(lobby.fetch(`https://lobby/report?game=${encodeURIComponent(this.game)}`, { method: 'POST', body: JSON.stringify({ room: this.code, players, people, agents, ai, host: room.server ? 'server' : 'browser', ...(ver !== undefined ? { ver } : {}) }) }).catch(() => {}));
|
|
2473
2475
|
}
|
|
2474
2476
|
|
|
2475
2477
|
/** A round the host called over becomes one D1 row (once per round number). */
|
|
@@ -2523,6 +2525,7 @@ export class Lobby {
|
|
|
2523
2525
|
ctx.blockConcurrencyWhile(async () => {
|
|
2524
2526
|
const saved = await ctx.storage.get('lobby');
|
|
2525
2527
|
if (saved) {
|
|
2528
|
+
this.current = saved.current ?? null;
|
|
2526
2529
|
this.next = typeof saved.next === 'number' ? { public: saved.next } : saved.next && typeof saved.next === 'object' ? { public: 1, ...saved.next } : { public: 1 };
|
|
2527
2530
|
for (const r of saved.rooms ?? []) this.rooms.set(r.name, { ...r, server: r.server ?? roomServer(r.name), agents: r.agents ?? 0, ai: r.ai ?? 0, ver: typeof r.ver === 'string' ? r.ver : '', pending: [], agentPending: [] });
|
|
2528
2531
|
for (const [name, until] of saved.closed ?? []) if (until > Date.now()) this.closed.set(name, until);
|
|
@@ -2550,7 +2553,7 @@ export class Lobby {
|
|
|
2550
2553
|
if (list.length) this.ctx.waitUntil(this.env.DB.batch(list).catch(() => {}));
|
|
2551
2554
|
}
|
|
2552
2555
|
|
|
2553
|
-
save() { this.ctx.storage.put('lobby', { next: this.next, rooms: [...this.rooms.values()].map(({ name, players, agents, ai, server, at, ver }) => ({ name, players, agents, ai, server, at, ...(ver ? { ver } : {}) })), closed: [...this.closed] }).catch(() => {}); }
|
|
2556
|
+
save() { this.ctx.storage.put('lobby', { current: this.current ?? null, next: this.next, rooms: [...this.rooms.values()].map(({ name, players, agents, ai, server, at, ver, host }) => ({ name, players, agents, ai, server, at, ...(host ? { host } : {}), ...(ver ? { ver } : {}) })), closed: [...this.closed] }).catch(() => {}); }
|
|
2554
2557
|
|
|
2555
2558
|
prune(now) {
|
|
2556
2559
|
for (const [name, r] of this.rooms) {
|
|
@@ -2578,6 +2581,17 @@ export class Lobby {
|
|
|
2578
2581
|
// the build of a game that names none ('').
|
|
2579
2582
|
const ver = versionOf(url.searchParams.get('ver')) ?? '';
|
|
2580
2583
|
if (url.pathname === '/join') {
|
|
2584
|
+
// A server build can run only its current rules. Persisted browser rooms
|
|
2585
|
+
// (including pre-kind records) and old server revisions must not consume
|
|
2586
|
+
// the pool or become the fallback of a server with roomsMax=1.
|
|
2587
|
+
const host = url.searchParams.get('host');
|
|
2588
|
+
if ((host === 'server' || host === 'browser') && (this.current?.host !== host || this.current?.ver !== ver)) {
|
|
2589
|
+
this.current = { host, ver };
|
|
2590
|
+
for (const [name, r] of this.rooms) if (
|
|
2591
|
+
host === 'server' ? (r.host ?? 'browser') !== host || (r.ver ?? '') !== ver : r.host === 'server'
|
|
2592
|
+
) { this.rooms.delete(name); this.seen.delete(name); this.live.delete(name); }
|
|
2593
|
+
this.save();
|
|
2594
|
+
}
|
|
2581
2595
|
const max = seatCount(url.searchParams.get('max'));
|
|
2582
2596
|
const sid = url.searchParams.get('server') ?? 'public';
|
|
2583
2597
|
const server = sid === 'public' || SERVER_ID.test(sid) ? sid : 'public';
|
|
@@ -2640,7 +2654,7 @@ export class Lobby {
|
|
|
2640
2654
|
if (!room) {
|
|
2641
2655
|
let n = this.next[server] ?? 1;
|
|
2642
2656
|
while (not.has(roomCode(server, n)) || this.closed.has(roomCode(server, n)) || this.rooms.has(roomCode(server, n))) n += 1;
|
|
2643
|
-
room = { name: roomCode(server, n), server, players: 0, agents: 0, ai: 0, at: now, ver, pending: [], agentPending: [] };
|
|
2657
|
+
room = { name: roomCode(server, n), server, players: 0, agents: 0, ai: 0, at: now, ver, ...(host ? { host } : {}), pending: [], agentPending: [] };
|
|
2644
2658
|
this.next[server] = n + 1;
|
|
2645
2659
|
this.rooms.set(room.name, room);
|
|
2646
2660
|
}
|
|
@@ -2659,6 +2673,11 @@ export class Lobby {
|
|
|
2659
2673
|
this.save();
|
|
2660
2674
|
return json({ ok: true, closed: body.closed > now });
|
|
2661
2675
|
}
|
|
2676
|
+
// A retiring isolate may send its last report after the new pool was
|
|
2677
|
+
// selected. It cannot resurrect an incompatible public room.
|
|
2678
|
+
if (pooledRoom(body.room) && this.current && (this.current.host === 'server'
|
|
2679
|
+
? (body.host ?? 'browser') !== 'server' || (body.ver ?? '') !== this.current.ver
|
|
2680
|
+
: body.host === 'server')) return json({ ok: true, retired: true });
|
|
2662
2681
|
const seated = Math.max(0, Math.floor(Number(body.players) || 0));
|
|
2663
2682
|
const agents = Math.max(0, Math.floor(Number(body.agents) || 0));
|
|
2664
2683
|
const ai = Math.max(agents, Math.floor(Number(body.ai) || 0));
|
|
@@ -2674,6 +2693,7 @@ export class Lobby {
|
|
|
2674
2693
|
const rver = typeof body.ver === 'string' ? versionOf(body.ver) ?? '' : r.ver ?? '';
|
|
2675
2694
|
const changed = !this.rooms.has(r.name) || r.players !== seated || (r.agents ?? 0) !== agents || (r.ai ?? 0) !== ai || (r.ver ?? '') !== rver;
|
|
2676
2695
|
r.ver = rver;
|
|
2696
|
+
if (body.host === 'server' || body.host === 'browser') r.host = body.host;
|
|
2677
2697
|
r.pending.splice(0, grew);
|
|
2678
2698
|
if ((r.agents ?? 0) < agents) (r.agentPending ?? []).splice(0, agents - (r.agents ?? 0));
|
|
2679
2699
|
r.players = seated;
|
package/worker/pages.mjs
CHANGED
|
@@ -597,7 +597,8 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
597
597
|
// playing in its own room: it loads again at the round's break, or at once on a big screen (nothing to lose).
|
|
598
598
|
if (m.what === 'rematch' && m.room === room) {
|
|
599
599
|
var fresh = new URL(location.href);
|
|
600
|
-
fresh.searchParams.delete('room'); fresh.searchParams.set('not', room);
|
|
600
|
+
if (/^(?:pub-[1-9][0-9]*|s-[a-z0-9][a-z0-9-]*-[1-9][0-9]*)$/.test(room)) { fresh.searchParams.delete('room'); fresh.searchParams.set('not', room); }
|
|
601
|
+
else { fresh.searchParams.set('room', room); fresh.searchParams.delete('not'); }
|
|
601
602
|
location.replace(fresh.href);
|
|
602
603
|
return;
|
|
603
604
|
}
|
|
@@ -1357,7 +1358,8 @@ const WATCH_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
1357
1358
|
var m = ev.data;
|
|
1358
1359
|
if (!m || typeof m !== 'object' || m.t !== 'homie-net') return;
|
|
1359
1360
|
if (m.what === 'rematch' && m.room === room) {
|
|
1360
|
-
var fresh = new URL(location.href); fresh.searchParams.delete('room'); fresh.searchParams.set('not', room);
|
|
1361
|
+
var fresh = new URL(location.href); if (/^(?:pub-[1-9][0-9]*|s-[a-z0-9][a-z0-9-]*-[1-9][0-9]*)$/.test(room)) { fresh.searchParams.delete('room'); fresh.searchParams.set('not', room); }
|
|
1362
|
+
else { fresh.searchParams.set('room', room); fresh.searchParams.delete('not'); }
|
|
1361
1363
|
location.replace(fresh.href); return;
|
|
1362
1364
|
}
|
|
1363
1365
|
if (m.what === 'wait') updating.stop();
|
package/worker/parts.mjs
CHANGED
|
@@ -263,13 +263,13 @@ ${sayRow('Say', sayAdd(p))}
|
|
|
263
263
|
* build something new from them. Empty when the game shared none. It stands by itself: its own markup and styles.
|
|
264
264
|
*/
|
|
265
265
|
export function partsBand(g, parts, host) {
|
|
266
|
-
const mine = parts.filter((p) => (p.from?.game ?? p.from?.app) === g.id);
|
|
266
|
+
const mine = parts.filter((p) => (p.from?.game ?? p.from?.app ?? (p.from?.kind === 'app' ? p.from.id : null)) === g.id);
|
|
267
267
|
if (!mine.length) return '';
|
|
268
|
-
const say = mine.length === 1 ? sayAdd({ ...mine[0], add: `${host}/${mine[0].id}` }) : `Make a new game in my Homie studio with these parts of ${g.name}: ${mine.slice(0, 4).map((p) => `${host}/${p.id}`).join(', ')}`;
|
|
268
|
+
const say = mine.length === 1 ? sayAdd({ ...mine[0], add: `${host}/${mine[0].id}` }) : `Make a new ${g.kind === 'app' ? 'app' : 'game'} in my Homie studio with these parts of ${g.name}: ${mine.slice(0, 4).map((p) => `${host}/${p.id}`).join(', ')}`;
|
|
269
269
|
return `${CSS}<section class="pband" aria-labelledby="parts-title" data-parts-band>
|
|
270
270
|
<p class="ptag">Take a piece</p>
|
|
271
|
-
<h2 id="parts-title">Parts from this game</h2>
|
|
272
|
-
<p class="plead">Pieces of ${esc(g.name)} you can put in a game of your own, each with its licence
|
|
271
|
+
<h2 id="parts-title">Parts from this ${g.kind === 'app' ? 'app' : 'game'}</h2>
|
|
272
|
+
<p class="plead">Pieces of ${esc(g.name)} you can put in ${g.kind === 'app' ? 'an app' : 'a game'} of your own, each with its licence.</p>
|
|
273
273
|
<ul class="pgrid">${mine.map(card).join('')}</ul>
|
|
274
274
|
${sayRow('In a chat with your Homie studio open, say', say)}
|
|
275
275
|
<div class="keys"><a class="btn" href="/parts/#from-${esc(g.id)}">${icon('spark')}<span>See the parts</span></a></div>
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
/** Shared build/live diagnosis: the studio's Worker must register its generated rules. */
|
|
2
|
+
export const RULES_WIRING_MESSAGE = "site/src/worker.mjs does not register this studio's server rules. Add hostRules to its import from '@homie-rocks/studio/worker', then add these two lines at module scope:\nimport rules from './rules/index.mjs';\nhostRules(rules);\nBuild and deploy again.";
|
package/worker/shop-page.mjs
CHANGED
|
@@ -61,17 +61,21 @@ export const WAY_LINES = Object.freeze({
|
|
|
61
61
|
export function shopPage(cat, shop, { origin = '', game = null, item = null, open = false, mode = null } = {}) {
|
|
62
62
|
const studio = cat.studio?.name ?? 'Studio';
|
|
63
63
|
const g = game ? (cat.games ?? []).find((x) => x.id === game) : null;
|
|
64
|
-
const
|
|
64
|
+
const preview = !open && /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(?::|\/|$)/.test(origin);
|
|
65
|
+
const giving = Boolean(shop.giving);
|
|
66
|
+
const previewItems = preview ? shop.items.map(i => `<article class="item"><h2>${esc(i.name)}</h2><span class="price">${esc(money(i.price === 'choose' ? i.min : i.price, shop.currency))}${i.interval ? ' / month' : ''}</span><p>${esc(i.blurb ?? '')}</p><button disabled>Preview only</button></article>`).join('') : '';
|
|
67
|
+
const boot = { giving, preview, game: g ? g.id : null, item: ITEM_ID.test(String(item ?? '')) ? item : null, studio };
|
|
65
68
|
const main = `<section class="shop" data-shop>
|
|
66
69
|
<p class="kicker">${esc(studio)}</p>
|
|
67
|
-
<h1>${g ? `${esc(g.name)}: shop` : 'Shop'}</h1>
|
|
70
|
+
<h1>${giving ? 'Give' : g ? `${esc(g.name)}: shop` : 'Shop'}</h1>
|
|
68
71
|
${mode === 'test' ? '<p class="test">TEST MODE: no real money moves (Stripe test cards only)</p>' : ''}
|
|
69
|
-
<p class="lead"
|
|
72
|
+
<p class="lead">${giving ? `Support ${esc(studio)} with a one-time or monthly gift. Gifts go directly to the organisation through its own Stripe account.` : `Things you can buy here, in real money, from ${esc(studio)} itself. Paid purchases open Stripe's checkout page. Guest purchases stay in this browser; keep its cookies or sign in to keep access.`} ${g ? `<a href="/${esc(g.id)}/">Back to ${esc(g.name)}</a>` : ''}</p>
|
|
70
73
|
<noscript><p class="lead">This page needs JavaScript.</p></noscript>
|
|
71
74
|
<div data-shop-age hidden></div>
|
|
72
|
-
<div class="items" data-shop-items><p>${open ? 'Reading the shop…' : '
|
|
75
|
+
<div class="items" data-shop-items><p>${open ? 'Reading the shop…' : preview ? 'PREVIEW — checkout is disabled. Connect Stripe to open.' : 'Connect the organisation’s Stripe account to open giving or sales.'}</p>${previewItems}</div>
|
|
76
|
+
${giving && shop.giving.receiptText ? `<p class="note">${esc(shop.giving.receiptText)}</p>` : ''}
|
|
73
77
|
<p class="msg" data-shop-msg role="status" aria-live="polite"></p>
|
|
74
|
-
<p class="note">${esc(studio)} is the seller: it sets these prices, answers for refunds and receives the money through its own Stripe account. Homie takes nothing. ${refundWords(shop)} <a href="/shop/refunds/">refunds</a>. Stripe keeps your card and email and sends your receipt; ${esc(studio)} never sees them
|
|
78
|
+
<p class="note">${giving ? `${esc(studio)} receives your gift through its own Stripe account. Stripe emails the payment receipt.` : `${esc(studio)} is the seller: it sets these prices, answers for refunds and receives the money through its own Stripe account. Homie takes nothing. ${refundWords(shop)} <a href="/shop/refunds/">refunds</a>. Stripe keeps your card and email and sends your receipt; ${esc(studio)} never sees them.`}</p>
|
|
75
79
|
</section>
|
|
76
80
|
<script type="application/json" id="shop-boot">${JSON.stringify(boot).replace(/</g, '\\u003c')}</script>
|
|
77
81
|
<script src="/_homie/shop.js" defer></script>`;
|
|
@@ -81,13 +85,13 @@ ${mode === 'test' ? '<p class="test">TEST MODE: no real money moves (Stripe test
|
|
|
81
85
|
export function thanksPage(cat, shop, { session = null, game = null } = {}) {
|
|
82
86
|
const studio = cat.studio?.name ?? 'Studio';
|
|
83
87
|
const g = game ? (cat.games ?? []).find((x) => x.id === game) : null;
|
|
84
|
-
const boot = { thanks: session, game: g ? g.id : null, studio };
|
|
88
|
+
const boot = { giving: Boolean(shop.giving), thanks: session, game: g ? g.id : null, studio };
|
|
85
89
|
const main = `<section class="shop" data-shop-thanks>
|
|
86
90
|
<p class="kicker">${esc(studio)}</p>
|
|
87
91
|
<h1 data-thanks-title>Thank you</h1>
|
|
88
|
-
<p class="lead" data-thanks-line>Stripe is confirming the payment. This page says when it is yours (a few seconds)
|
|
92
|
+
<p class="lead" data-thanks-line>Stripe is confirming the payment. ${shop.giving ? 'Thank you for supporting our work.' : 'This page says when it is yours (a few seconds).'}</p>
|
|
89
93
|
<p>${g ? `<a class="btn" href="/${esc(g.id)}/play">Back to ${esc(g.name)}</a>` : '<a class="btn" href="/">Back to the studio</a>'} <a href="/account/">Your account</a></p>
|
|
90
|
-
<p class="note">Your receipt comes from Stripe by email. ${refundWords(shop)}</p>
|
|
94
|
+
<p class="note">Your receipt comes from Stripe by email. ${shop.giving ? esc(shop.giving.receiptText ?? '') : refundWords(shop)}</p>
|
|
91
95
|
</section>
|
|
92
96
|
<script type="application/json" id="shop-boot">${JSON.stringify(boot).replace(/</g, '\\u003c')}</script>
|
|
93
97
|
<script src="/_homie/shop.js" defer></script>`;
|
|
@@ -97,6 +101,7 @@ export function thanksPage(cat, shop, { session = null, game = null } = {}) {
|
|
|
97
101
|
/** The refund policy (Stripe asks every seller for one; the kit's is generous by default). */
|
|
98
102
|
export function refundsPage(cat, shop) {
|
|
99
103
|
const studio = cat.studio?.name ?? 'Studio';
|
|
104
|
+
if (shop.giving) return layout(cat, { title: `Giving · ${studio}`, page: 'shop', main: `<section class="shop"><h1>Giving</h1><p>For questions about your gift, contact ${esc(studio)}.</p><p>${esc(shop.giving.receiptText ?? '')}</p></section>` });
|
|
100
105
|
const main = `<section class="shop">
|
|
101
106
|
<p class="kicker">${esc(studio)}</p>
|
|
102
107
|
<h1>Refunds</h1>
|
|
@@ -182,9 +187,10 @@ export const SHOP_JS = String.raw`(function () {
|
|
|
182
187
|
cartBox.appendChild(checkout);
|
|
183
188
|
}
|
|
184
189
|
function draw(s) {
|
|
190
|
+
if (boot.preview && !s.open) return;
|
|
185
191
|
list.textContent = '';
|
|
186
192
|
if (!s.ok) { list.appendChild(el('p', null, s.message || 'The shop did not answer.')); return; }
|
|
187
|
-
if (!s.open) { list.appendChild(el('p', null, s.kids ? 'Nothing is sold here.' : '
|
|
193
|
+
if (!s.open) { list.appendChild(el('p', null, s.kids ? 'Nothing is sold here.' : 'Connect the organisation’s Stripe account to open giving or sales.')); return; }
|
|
188
194
|
var age = $('[data-shop-age]');
|
|
189
195
|
var needsAge = s.items.some(function (i) { return i.way === 'age-question'; });
|
|
190
196
|
if (age) {
|
|
@@ -217,7 +223,7 @@ export const SHOP_JS = String.raw`(function () {
|
|
|
217
223
|
if (i.max !== null) amount.setAttribute('max', String(i.max / s.currencyScale));
|
|
218
224
|
act.appendChild(amount);
|
|
219
225
|
}
|
|
220
|
-
var b = el('button', { type: 'button', class: 'buy' }, i.price === 'choose' ? 'Give on Stripe' : 'Buy on Stripe');
|
|
226
|
+
var b = el('button', { type: 'button', class: 'buy' }, boot.giving || i.kind === 'donation' || i.price === 'choose' ? 'Give on Stripe' : 'Buy on Stripe');
|
|
221
227
|
b.addEventListener('click', function () {
|
|
222
228
|
b.disabled = true; say('Opening Stripe…');
|
|
223
229
|
api('POST', '/api/shop/buy', { item: i.id, game: boot.game || undefined, amount: amount ? units(amount.value, s.currencyScale) : undefined }).then(function (r) {
|
|
@@ -230,7 +236,7 @@ export const SHOP_JS = String.raw`(function () {
|
|
|
230
236
|
var add = el('button', { type: 'button', class: 'plain' }, 'Add to cart');
|
|
231
237
|
add.onclick = function () { cart.push({ item: i.id, name: i.name, quantity: Number(quantity.value), amount: amount ? units(amount.value, s.currencyScale) : undefined }); drawCart(); };
|
|
232
238
|
if (!s.keyless) { act.appendChild(quantity); act.appendChild(add); }
|
|
233
|
-
else if (i.price !== 0) act.appendChild(el('p', { class: 'note' }, i.price === 'choose' ? 'Choose your amount on Stripe.' : 'Confirm quantity on Stripe. Buy different items separately.'));
|
|
239
|
+
else if (i.price !== 0) act.appendChild(el('p', { class: 'note' }, i.price === 'choose' ? 'Choose your amount on Stripe.' : boot.giving ? 'Confirm your gift on Stripe.' : 'Confirm quantity on Stripe. Buy different items separately.'));
|
|
234
240
|
} else if (i.way === 'owned') {
|
|
235
241
|
act.appendChild(el('span', { class: 'owned' }, 'Yours ✓'));
|
|
236
242
|
} else if (i.way === 'make-an-account') {
|
|
@@ -255,7 +261,7 @@ export const SHOP_JS = String.raw`(function () {
|
|
|
255
261
|
list.appendChild(card);
|
|
256
262
|
});
|
|
257
263
|
if (s.nextCursor) { var next = el('button', { type: 'button' }, 'Next items'); next.onclick = function () { load(s.nextCursor); }; list.appendChild(next); }
|
|
258
|
-
if (!s.items.length) list.appendChild(el('p', null, 'Nothing for sale here right now.'));
|
|
264
|
+
if (!s.items.length) list.appendChild(el('p', null, boot.giving ? 'No gift options are available right now.' : 'Nothing for sale here right now.'));
|
|
259
265
|
if (boot.item) { var at = document.getElementById('item-' + boot.item); if (at && at.scrollIntoView) at.scrollIntoView({ block: 'center' }); }
|
|
260
266
|
}
|
|
261
267
|
function load(cursor) { api('GET', '/api/shop' + q + (q ? '&' : '?') + 'cursor=' + encodeURIComponent(cursor || '')).then(draw).catch(function () { draw({ ok: false }); }); }
|
|
@@ -276,9 +282,9 @@ export const SHOP_JS = String.raw`(function () {
|
|
|
276
282
|
(function poll() {
|
|
277
283
|
tries += 1;
|
|
278
284
|
api('GET', '/api/shop/order?session=' + encodeURIComponent(boot.thanks)).then(function (r) {
|
|
279
|
-
if (r.status === 'paid') { returnToPage(false); $('[data-thanks-title]').textContent = 'It\'s yours'; $('[data-thanks-line]').textContent = 'Paid. It is on your account now, in every game of ' + boot.studio + ' that uses it.'; return; }
|
|
285
|
+
if (r.status === 'paid') { returnToPage(false); $('[data-thanks-title]').textContent = boot.giving ? 'Thank you for your gift' : 'It\'s yours'; $('[data-thanks-line]').textContent = boot.giving ? 'Your gift is confirmed. Your receipt comes from Stripe by email.' : 'Paid. It is on your account now, in every game of ' + boot.studio + ' that uses it.'; return; }
|
|
280
286
|
if (tries < 40) setTimeout(poll, 1500);
|
|
281
|
-
else $('[data-thanks-line]').textContent = 'Stripe has not confirmed it yet. It will be on your account as soon as it does; your receipt comes by email.';
|
|
287
|
+
else $('[data-thanks-line]').textContent = boot.giving ? 'Stripe has not confirmed your gift yet. Check your email for its receipt.' : 'Stripe has not confirmed it yet. It will be on your account as soon as it does; your receipt comes by email.';
|
|
282
288
|
}).catch(function () { if (tries < 40) setTimeout(poll, 3000); });
|
|
283
289
|
})();
|
|
284
290
|
}
|
|
@@ -459,7 +465,7 @@ export const SHOP_SHELL_JS = String.raw`(function () {
|
|
|
459
465
|
listBox.appendChild(row);
|
|
460
466
|
});
|
|
461
467
|
if (s.nextCursor) { var next = el('button', { type: 'button' }, 'Next items'); next.onclick = function () { loadItems(s.nextCursor); }; listBox.appendChild(next); }
|
|
462
|
-
if (!s.items.length) listBox.appendChild(el('p', { class: 'sub' }, 'Nothing for sale here right now.'));
|
|
468
|
+
if (!s.items.length) listBox.appendChild(el('p', { class: 'sub' }, boot.giving ? 'No gift options are available right now.' : 'Nothing for sale here right now.'));
|
|
463
469
|
}).catch(function () { listBox.appendChild(el('p', { class: 'sub' }, 'The shop did not answer.')); });
|
|
464
470
|
}
|
|
465
471
|
loadItems('');
|
package/worker/shop-rules.mjs
CHANGED
|
@@ -169,6 +169,7 @@ export function checkShop(raw, { games = null, audience = 'general', studioName
|
|
|
169
169
|
if (why) err(`${at}.price`, why);
|
|
170
170
|
else price = numeric(it.price);
|
|
171
171
|
}
|
|
172
|
+
if (it.interval !== undefined && (it.interval !== 'month' || chosen)) err(`${at}.interval`, 'monthly gifts need a fixed suggested amount');
|
|
172
173
|
const charge = chosen ? min : price;
|
|
173
174
|
if (currencyScale(currency) === 1000 && charge % 10) warnings.push({ at: `${at}.price`, message: 'This three-decimal amount uses the smallest minor unit. Confirm support with your Stripe payment method; Stripe decides acceptance (https://docs.stripe.com/currencies).' });
|
|
174
175
|
if (charge > 0 && charge < minimumCharge(currency)) warnings.push({ at: `${at}.price`, message: `Stripe's minimum for settlement in ${currency.toUpperCase()} is ${money(minimumCharge(currency), currency)}. Your settlement currency and payment method determine the actual minimum at checkout.` });
|
|
@@ -196,10 +197,10 @@ export function checkShop(raw, { games = null, audience = 'general', studioName
|
|
|
196
197
|
const advantage = it.advantage === true;
|
|
197
198
|
const badge = gives.find((g) => g.startsWith('badge:'));
|
|
198
199
|
for (const key of Object.keys(it)) {
|
|
199
|
-
if (!['id', 'kind', 'name', 'blurb', 'description', 'price', 'min', 'max', 'gives', 'badge', 'days', 'starts', 'ends', 'game', 'taxCode', 'advantage'].includes(key)) warnings.push({ at: `${at}.${key}`, message: 'This field is not published by the shop. Describe any buyer-facing meaning in blurb so buyers can read it.' });
|
|
200
|
+
if (!['id', 'kind', 'name', 'blurb', 'description', 'interval', 'price', 'min', 'max', 'gives', 'badge', 'days', 'starts', 'ends', 'game', 'taxCode', 'advantage'].includes(key)) warnings.push({ at: `${at}.${key}`, message: 'This field is not published by the shop. Describe any buyer-facing meaning in blurb so buyers can read it.' });
|
|
200
201
|
}
|
|
201
202
|
items.push({
|
|
202
|
-
id, kind, name, ...(blurb ? { blurb } : {}),
|
|
203
|
+
id, kind, name, ...(it.interval === 'month' ? { interval: 'month' } : {}), ...(blurb ? { blurb } : {}),
|
|
203
204
|
...(chosen ? { price: 'choose', min, max } : { price }),
|
|
204
205
|
gives, ...(badge ? { badge: badgeWord(badge, it) } : {}),
|
|
205
206
|
...(days !== null ? { days } : {}), ...(Number.isFinite(starts) ? { starts: new Date(starts).toISOString() } : {}), ...(Number.isFinite(ends) ? { ends: new Date(ends).toISOString() } : {}),
|
|
@@ -236,7 +237,7 @@ export function checkShop(raw, { games = null, audience = 'general', studioName
|
|
|
236
237
|
const finalTill = kids ? 'off' : TILLS.includes(till) ? till : 'off';
|
|
237
238
|
if (kids && till !== 'off') warnings.push({ at: 'till', message: 'studio.json says "audience": "kids": the studio sells nothing in its games (the till is off under policy.kidsStudio).' });
|
|
238
239
|
const shop = {
|
|
239
|
-
v: 1, policy, purchasesTill: TILLS.includes(raw.purchasesTill ?? till) ? (raw.purchasesTill ?? till) : 'off', ...(/^https:\/\/billing\.stripe\.com\/p\/login\/[A-Za-z0-9_]+$/.test(raw.purchasesPortalLogin ?? '') ? { purchasesPortalLogin: raw.purchasesPortalLogin } : {}), checkoutMinutes, requestBytes, automaticTax: raw.automaticTax === true, referralNewPlayersOnly: raw.referralNewPlayersOnly === true, guestBuyersPerAddressPerHour, purchaseAttemptsPerMinute, purchaseAttemptsPerAddressPerMinute, till: finalTill, currency: CURRENCY.test(currency) ? currency : 'usd', refundDays, capPerPlayerMonth: cap, items, referrals,
|
|
240
|
+
v: 1, ...(isObj(raw.giving) ? { giving: { receiptText: String(raw.giving.receiptText ?? '') } } : {}), policy, purchasesTill: TILLS.includes(raw.purchasesTill ?? till) ? (raw.purchasesTill ?? till) : 'off', ...(/^https:\/\/billing\.stripe\.com\/p\/login\/[A-Za-z0-9_]+$/.test(raw.purchasesPortalLogin ?? '') ? { purchasesPortalLogin: raw.purchasesPortalLogin } : {}), checkoutMinutes, requestBytes, automaticTax: raw.automaticTax === true, referralNewPlayersOnly: raw.referralNewPlayersOnly === true, guestBuyersPerAddressPerHour, purchaseAttemptsPerMinute, purchaseAttemptsPerAddressPerMinute, till: finalTill, currency: CURRENCY.test(currency) ? currency : 'usd', refundDays, capPerPlayerMonth: cap, items, referrals,
|
|
240
241
|
open: finalTill !== 'off' && items.length > 0, ...(kids ? { audience: 'kids' } : {}), ...(catalog.length ? { catalog } : {}),
|
|
241
242
|
};
|
|
242
243
|
// https://docs.stripe.com/api/checkout/sessions/create#create_checkout_session-custom_text-submit-message
|
package/worker/site.mjs
CHANGED
|
@@ -555,6 +555,8 @@ export function sectionsOf(cat) {
|
|
|
555
555
|
const games = (cat.games ?? []).some((g) => g.kind !== 'app');
|
|
556
556
|
const apps = (cat.games ?? []).some((g) => g.kind === 'app');
|
|
557
557
|
return [
|
|
558
|
+
...(cat.site?.pages ?? []).filter(p => /^\/[a-z0-9][a-z0-9._-]*\/$/.test(p) && !['/shop/', '/apps/', '/games/', '/rooms/', '/posts/', '/music/', '/videos/', '/lounge/'].includes(p)).map(p => ({ key: p, href: p, label: p.slice(1, -1).replaceAll('-', ' ').replace(/^./, c => c.toUpperCase()) })),
|
|
559
|
+
cat.shop && { key: 'shop', href: '/shop/', label: cat.shop.giving ? 'Give' : 'Shop' },
|
|
558
560
|
apps && { key: 'apps', href: '/apps/', label: 'Apps' },
|
|
559
561
|
games && { key: 'games', href: '/games/', label: 'Games' },
|
|
560
562
|
(cat.songs ?? []).length > 0 && { key: 'music', href: '/music/', label: 'Music' },
|
|
@@ -859,11 +861,11 @@ export function homePage(cat, { origin = '', rooms = [], live = {} } = {}) {
|
|
|
859
861
|
<div class="hero-copy">
|
|
860
862
|
<p class="kicker">${esc(tagline || 'A Homie studio')}</p>
|
|
861
863
|
<h1 class="title ${titleClass(name)}" id="hero-title">${esc(name)}</h1>
|
|
862
|
-
<p class="line"
|
|
864
|
+
<p class="line">${['business', 'cause', 'app'].includes(cat.studio?.kind) ? 'Your app is coming soon.' : 'First game coming soon.'}</p>
|
|
863
865
|
<p class="live"><span class="live-dot" aria-hidden="true"></span><span>${posts.length ? 'In the making · the latest news is below' : 'In the making'}</span></p>
|
|
864
866
|
</div>
|
|
865
867
|
</section>`;
|
|
866
|
-
const next = [
|
|
868
|
+
const next = ['business', 'cause', 'app'].includes(cat.studio?.kind) ? [['Apps', 'Your live app', 'Open it on a phone, wall or staff tablet.'], ['Pages', 'Your own pages', 'Services, events and information for your community.']] : [
|
|
867
869
|
['Games', 'The first game', 'It opens here with a Play button: a live public room on a phone, a computer or a TV, with bots in the empty seats.'],
|
|
868
870
|
['Posts', 'News and drops', 'The studio\'s posts land on this page, with feeds to follow.'],
|
|
869
871
|
['Music and videos', 'Songs and trailers', 'Scores, songs and trailers get pages of their own.'],
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.49.0';
|