@homie-rocks/studio 0.23.0 → 0.24.2
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 +56 -1
- package/README.md +22 -0
- package/bin/homie-studio.mjs +34 -1
- package/lib/build.mjs +13 -2
- package/lib/scaffold.mjs +48 -2
- package/lib/shop.mjs +270 -0
- package/lib/template-history.json +78 -0
- package/netplay/NETPLAY.md +3 -1
- package/netplay/netplay.ts +6 -0
- package/package.json +6 -1
- package/shop/SELLING.md +83 -0
- package/shop/SHOP.md +154 -0
- package/shop/shop.ts +130 -0
- package/site/SITE.md +11 -0
- package/worker/account-page.mjs +14 -2
- package/worker/index.mjs +43 -4
- package/worker/office-page.mjs +5 -5
- package/worker/office.mjs +36 -0
- package/worker/pages.mjs +7 -5
- package/worker/players.mjs +8 -0
- package/worker/referrals.mjs +328 -0
- package/worker/room.mjs +3 -1
- package/worker/shop-page.mjs +532 -0
- package/worker/shop-rules.mjs +277 -0
- package/worker/shop-store.mjs +217 -0
- package/worker/shop.mjs +686 -0
- package/worker/stripe.mjs +149 -0
- package/worker/version.mjs +1 -1
package/worker/index.mjs
CHANGED
|
@@ -54,6 +54,10 @@
|
|
|
54
54
|
* /<game>/api/chat/report a player reports one chat line (POST, this site's pages): the room's own copy of it is kept
|
|
55
55
|
* for the owner, 30 days (worker/chat-store.mjs); room chat itself rides the sockets above
|
|
56
56
|
* (NETPLAY.md section 19) and is never stored
|
|
57
|
+
* /shop/, /api/shop/... the studio's shop (worker/shop.mjs, shop/SHOP.md): its items in real money, the studio's
|
|
58
|
+
* OWN Stripe Checkout, the signed webhook, what a player owns (/api/player/owns), refunds;
|
|
59
|
+
* closed until the owner's restricted key is in; never on a kids server or a television
|
|
60
|
+
* /api/referrals/statement another studio's signed referral statement to this one (worker/referrals.mjs)
|
|
57
61
|
*
|
|
58
62
|
* A page in the studio's site/pages wins over the generated one at the same address. Every HTML answer is
|
|
59
63
|
* `no-transform` (an edge in front of a custom domain injects nothing) and is never framed by another site.
|
|
@@ -104,6 +108,8 @@ import {
|
|
|
104
108
|
// every device, and the owner's own account (`homie-studio players owner`) counts as the owner.
|
|
105
109
|
usePlayers(playerAccounts);
|
|
106
110
|
import { STUDIO_VERSION_TAG } from './version.mjs';
|
|
111
|
+
import { roomBadge, shellShop, shopOf, shopRoutes } from './shop.mjs';
|
|
112
|
+
import { arrivalCookie, manifestReferrals } from './referrals.mjs';
|
|
107
113
|
import { licenseOf, remixAllowed, remixRow } from './license.mjs';
|
|
108
114
|
|
|
109
115
|
export { SEAT_MAX } from './seats.mjs';
|
|
@@ -461,6 +467,9 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
|
|
|
461
467
|
...(meta?.movement ? { movement: meta.movement } : {}),
|
|
462
468
|
// game.json "saves": the play shell around this frame answers @homie-rocks/studio/saves (saves/SAVES.md).
|
|
463
469
|
...(meta?.saves && want === 'play' ? { saves: true } : {}),
|
|
470
|
+
// The studio sells something in this game (shop/SHOP.md): the play shell answers @homie-rocks/studio/shop. A
|
|
471
|
+
// watcher has no shop; the shell itself says "kids" on a kids server and shows only a code on a television.
|
|
472
|
+
...(!agent && !watching && shellShop(cat, game) ? { shop: true } : {}),
|
|
464
473
|
};
|
|
465
474
|
let html = await res.text();
|
|
466
475
|
const head = `<script>window.HOMIE_NET=${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script>`;
|
|
@@ -539,7 +548,18 @@ function finish(res, path) {
|
|
|
539
548
|
|
|
540
549
|
export default {
|
|
541
550
|
async fetch(request, env, ctx) {
|
|
542
|
-
|
|
551
|
+
const url = new URL(request.url);
|
|
552
|
+
const res = finish(await route(request, env, ctx), url.pathname);
|
|
553
|
+
// A referral's arrival (worker/referrals.mjs): only a person's page load with ?via= of another site, on a studio
|
|
554
|
+
// that pays referrals. The page is answered first; a cookie is added to it only then.
|
|
555
|
+
if (url.searchParams.has('via') && res && res.status === 200 && /text\/html/i.test(res.headers.get('content-type') ?? '')) {
|
|
556
|
+
try {
|
|
557
|
+
const cat = await catalogue(env, url.origin);
|
|
558
|
+
const cookie = await arrivalCookie(request, env, url, shopOf(cat));
|
|
559
|
+
if (cookie) { const out = new Response(res.body, res); out.headers.append('set-cookie', cookie); return out; }
|
|
560
|
+
} catch { /* an arrival is never worth a failed page */ }
|
|
561
|
+
}
|
|
562
|
+
return res;
|
|
543
563
|
},
|
|
544
564
|
};
|
|
545
565
|
|
|
@@ -562,6 +582,11 @@ async function route(request, env, ctx) {
|
|
|
562
582
|
}
|
|
563
583
|
if (path.startsWith('/_studio/')) return (await officeRoutes(request, env, url, { catalogueOf: getAll })) ?? ownerRoutes(request, env, url, { catalogueOf: getAll });
|
|
564
584
|
if (path === '/__homie' || path.startsWith('/__homie/')) return notAHomie(request);
|
|
585
|
+
// The shop (0.24.0) before player accounts: /api/player/owns is the shop's.
|
|
586
|
+
{
|
|
587
|
+
const shopped = await shopRoutes(request, env, ctx, url, { catalogueOf: getAll });
|
|
588
|
+
if (shopped) return shopped;
|
|
589
|
+
}
|
|
565
590
|
if (path.startsWith('/api/player/') || path === '/account' || path === '/account/' || path === '/_homie/account.js') return playerRoutes(request, env, ctx, url, { catalogueOf: getCat });
|
|
566
591
|
if (path === '/_homie/site.js') return new Response(SITE_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': url.searchParams.get('v') === STUDIO_VERSION_TAG ? 'public, max-age=31536000, immutable' : 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
|
|
567
592
|
if (path === '/api/stats/beat' && request.method === 'POST') return mediaBeat(request, env, ctx, url);
|
|
@@ -622,6 +647,10 @@ async function route(request, env, ctx) {
|
|
|
622
647
|
...(shareRooms ? { rooms: `${url.origin}/api/rooms` } : {}),
|
|
623
648
|
// Shared only when studio.json says `stats.share`: two numbers for the whole studio, for the hub.
|
|
624
649
|
...(played ? { played } : {}),
|
|
650
|
+
// The shop (0.24.0): whether it sells and its till, never a key or a sale; and referrals: this studio takes
|
|
651
|
+
// signed statements as a referrer, with the public half of its statement key and, if it sells, its terms.
|
|
652
|
+
...(shopOf(cat)?.open ? { shop: { open: true, till: shopOf(cat).till, currency: shopOf(cat).currency, page: `${url.origin}/shop/` } } : {}),
|
|
653
|
+
...(await manifestReferrals(env, url.origin, shopOf(cat), cat.studio).then((r) => (r ? { referrals: r } : {})).catch(() => ({}))),
|
|
625
654
|
}, 200, { 'access-control-allow-origin': '*' });
|
|
626
655
|
}
|
|
627
656
|
if (path === '/api/games') {
|
|
@@ -885,11 +914,15 @@ async function route(request, env, ctx) {
|
|
|
885
914
|
const local = isLocalOrigin(url.origin);
|
|
886
915
|
let qr = null;
|
|
887
916
|
if (!local) try { qr = qrSvg(joinUrl, { title: `Join ${meta.name ?? game}` }); } catch { /* too long for a QR: the address shows as text */ }
|
|
888
|
-
|
|
917
|
+
// The shop on a television is a code to buy on a phone (the TV never sells; never on a kids server).
|
|
918
|
+
const sh = shellShop(cat, game, { kids: pol.kids });
|
|
919
|
+
let shopQr = null;
|
|
920
|
+
if (sh && !local) try { shopQr = qrSvg(`${url.origin}/shop/?game=${encodeURIComponent(game)}`, { title: `Shop: ${meta.name ?? game}` }); } catch { shopQr = null; }
|
|
921
|
+
return playPage(cat, meta, { screen: true, joinUrl, qr, local, room, ticket, owner: d.owner, launch, server, shop: sh ? { ...sh, qr: shopQr, url: `${url.origin}/shop/?game=${game}` } : null });
|
|
889
922
|
}
|
|
890
923
|
await countVisit(request, env, ctx, game, 'play');
|
|
891
924
|
shareDaily(cat, url, ctx);
|
|
892
|
-
return playPage(cat, meta, { ticket, owner: d.owner, launch, server, ...(await chatWho(env, game, srv, d.acct)) });
|
|
925
|
+
return playPage(cat, meta, { ticket, owner: d.owner, launch, server, ...(await chatWho(env, game, srv, d.acct)), shop: shellShop(cat, game, { kids: pol.kids }) });
|
|
893
926
|
}
|
|
894
927
|
if (sub === 'watch') {
|
|
895
928
|
// The same door as Play: a game that is private or an invite-only beta is watched only by whoever may play it.
|
|
@@ -1005,8 +1038,12 @@ async function route(request, env, ctx) {
|
|
|
1005
1038
|
const origin = request.headers.get('origin');
|
|
1006
1039
|
const hub = sub === '__watch' && Boolean(origin) && origin !== 'null' && origin !== url.origin;
|
|
1007
1040
|
const lean = { ...chat, emoji: chat.emoji.filter((e) => !REACTIONS.some((r) => r.k === e.k)) };
|
|
1041
|
+
// A supporter's badge (the shop, 0.24.0): what the player's account owns, looked up here, so a hello can never claim
|
|
1042
|
+
// one; none on a kids server, none for an AI or a watcher.
|
|
1043
|
+
const pid = !ag && !w && sub === '__net' ? parts.find((x) => x.startsWith('p-'))?.slice(2) : null;
|
|
1044
|
+
const badge = pid ? await roomBadge(env, cat, pid, { kids: pol.kids }) : null;
|
|
1008
1045
|
const stub = env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
|
|
1009
|
-
const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}${b ? `&b=${b}` : ''}${who ? `&via=${encodeURIComponent(who)}` : ''}${sub === '__net' ? `&wp=${policy}${w ? '&w=1' : ''}` : ''}&pol=${encodeFacts(pol)}&chat=${encodeFacts(lean)}${acct ? '&acct=1' : ''}${member ? '&mem=1' : ''}${hub ? '&hub=1' : ''}${ag ? `&ag=${encodeFacts(ag)}` : ''}`;
|
|
1046
|
+
const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}${b ? `&b=${b}` : ''}${who ? `&via=${encodeURIComponent(who)}` : ''}${sub === '__net' ? `&wp=${policy}${w ? '&w=1' : ''}` : ''}&pol=${encodeFacts(pol)}&chat=${encodeFacts(lean)}${acct ? '&acct=1' : ''}${member ? '&mem=1' : ''}${hub ? '&hub=1' : ''}${ag ? `&ag=${encodeFacts(ag)}` : ''}${badge ? `&bd=${encodeURIComponent(badge)}` : ''}`;
|
|
1010
1047
|
return stub.fetch(new Request(target, request));
|
|
1011
1048
|
}
|
|
1012
1049
|
// The same knock, relative to the game's own page: the same answer (see notAHomie).
|
|
@@ -1320,6 +1357,8 @@ export class Table {
|
|
|
1320
1357
|
watch: url.searchParams.get('w') === '1',
|
|
1321
1358
|
// An AI with a pass the Worker verified (section 17): its pass, role, hands and name. Only the Worker sets it.
|
|
1322
1359
|
...(agent && typeof agent === 'object' && url.pathname === '/__net' ? { agent } : {}),
|
|
1360
|
+
// A badge the player's account owns (the shop): the Worker looked it up; a hello cannot set one.
|
|
1361
|
+
...(url.pathname === '/__net' && /^[\p{L}\p{N} .'&+-]{1,16}$/u.test(url.searchParams.get('bd') ?? '') ? { badge: url.searchParams.get('bd') } : {}),
|
|
1323
1362
|
watchPolicy: WATCH_POLICIES.includes(url.searchParams.get('wp')) ? url.searchParams.get('wp') : 'follow',
|
|
1324
1363
|
// House QA and `homie-studio check` mark their browsers; their rooms, rounds and peaks are not the studio's numbers.
|
|
1325
1364
|
qa: isQa(request),
|
package/worker/office-page.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { SKILLS } from './agents.mjs';
|
|
|
10
10
|
import { KIDS_LINE, POLICY_WORDS, SERVER_LIMITS } from './servers.mjs';
|
|
11
11
|
import { esc } from './site.mjs';
|
|
12
12
|
|
|
13
|
-
const PRIVATE = {
|
|
13
|
+
export const PRIVATE = {
|
|
14
14
|
'content-type': 'text/html; charset=utf-8',
|
|
15
15
|
'cache-control': 'no-store, private',
|
|
16
16
|
'x-robots-tag': 'noindex, nofollow',
|
|
@@ -832,7 +832,7 @@ async function officeScriptHash() {
|
|
|
832
832
|
return scriptHash;
|
|
833
833
|
}
|
|
834
834
|
|
|
835
|
-
function shell(cat, title, body, { status = 200, script = null, extra = {} } = {}) {
|
|
835
|
+
export function shell(cat, title, body, { status = 200, script = null, extra = {} } = {}) {
|
|
836
836
|
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
837
837
|
<meta name="robots" content="noindex, nofollow"><title>${esc(title)}</title><style>:root{--accent:${accentOf(cat)}}${CSS}</style></head><body><div class="wrap">${body}</div>${script ? `<script>${script}</script>` : ''}</body></html>`;
|
|
838
838
|
}
|
|
@@ -842,7 +842,7 @@ export async function officePage(cat, headers = {}) {
|
|
|
842
842
|
const name = cat.studio?.name ?? 'Studio';
|
|
843
843
|
const options = (cat.games ?? []).map((g) => `<option value="${esc(g.id)}">every room of ${esc(g.name)}</option>`).join('');
|
|
844
844
|
const body = `<header class="top"><h1>${esc(name)} <small>Office</small></h1>
|
|
845
|
-
<div class="links"><span class="live"><i class="dot" id="livedot"></i><span id="live">…</span></span><a href="/_studio/stats">Stats</a>
|
|
845
|
+
<div class="links"><span class="live"><i class="dot" id="livedot"></i><span id="live">…</span></span><a href="/_studio/stats">Stats</a>${cat.shop ? '<a href="/_studio/office/shop">Shop</a>' : ''}
|
|
846
846
|
<form method="post" action="/_studio/signout" style="display:inline"><button class="ghost small" type="submit">Sign out</button></form></div></header>
|
|
847
847
|
<form class="bar" id="announce-all"><label for="announce-text">Announce</label><input type="text" id="announce-text" maxlength="280" placeholder="A line every player sees, in the game" autocomplete="off">
|
|
848
848
|
<select id="announce-scope" aria-label="Who sees it"><option value="">every room of every game</option>${options}</select><button type="submit">Send</button>
|
|
@@ -871,7 +871,7 @@ export function lockedPage(cat, { what = 'office', ask = null } = {}) {
|
|
|
871
871
|
return new Response(shell(cat, `${what === 'confirm' ? 'Confirm' : 'Office'} · private`, body), { status: 401, headers: { ...PRIVATE, 'content-security-policy': NO_SCRIPT_CSP } });
|
|
872
872
|
}
|
|
873
873
|
|
|
874
|
-
const VERB = { kick: 'Kick', mute: 'Mute', close: 'Close the room', announce: 'Announce', game: 'Change it', 'server-set': 'Change it', 'server-close': 'Close it', member: 'Remove', 'agents-brain': 'Let them talk' };
|
|
874
|
+
const VERB = { refund: 'Refund it', 'shop-settle': 'Mark it paid', kick: 'Kick', mute: 'Mute', close: 'Close the room', announce: 'Announce', game: 'Change it', 'server-set': 'Change it', 'server-close': 'Close it', member: 'Remove', 'agents-brain': 'Let them talk' };
|
|
875
875
|
|
|
876
876
|
/** The owner's one tap for what their AI asked, and what came of it. */
|
|
877
877
|
export function confirmPage(cat, ask, { missing = false } = {}) {
|
|
@@ -883,7 +883,7 @@ export function confirmPage(cat, ask, { missing = false } = {}) {
|
|
|
883
883
|
const verb = ask.action?.op === 'mute' && ask.action.off ? 'Unmute' : ask.action?.op === 'close' && ask.action.reopen ? 'Open it again' : VERB[ask.action?.op] ?? 'Yes';
|
|
884
884
|
body = `<h1>${esc(name)} <small>Your AI asks</small></h1>
|
|
885
885
|
<p style="font-size:19px;line-height:1.45;max-width:46ch">${esc(ask.what)}</p>
|
|
886
|
-
<form method="post" class="bar" style="background:transparent;border:0;padding:0"><button type="submit" name="do" value="yes" class="${['kick', 'close', 'server-close', 'member'].includes(ask.action?.op) && !ask.action?.reopen ? 'armed' : ''}">${esc(verb)}</button><button type="submit" name="do" value="no" class="ghost">No</button></form>
|
|
886
|
+
<form method="post" class="bar" style="background:transparent;border:0;padding:0"><button type="submit" name="do" value="yes" class="${['kick', 'close', 'server-close', 'member', 'refund'].includes(ask.action?.op) && !ask.action?.reopen ? 'armed' : ''}">${esc(verb)}</button><button type="submit" name="do" value="no" class="ghost">No</button></form>
|
|
887
887
|
<p class="note">Only you can confirm this: your AI asked through the studio's office key, which cannot say yes. It lasts until ${esc(new Date(ask.expiresAt).toISOString().slice(11, 16))} UTC.</p>`;
|
|
888
888
|
} else {
|
|
889
889
|
const said = { done: 'Done.', failed: 'That did not work.', cancelled: 'Not done: you said no.', expired: 'This ask ran out (15 minutes). Your AI can ask again.', working: 'Working on it…' }[ask.state] ?? ask.state;
|
package/worker/office.mjs
CHANGED
|
@@ -49,6 +49,10 @@
|
|
|
49
49
|
* reports players made (each the one message it is about) with Dismiss, and the review's day (Workers AI neurons).
|
|
50
50
|
* From an office key, a change that opens chat up (more allowed, fewer checks) is an ASK; one that tightens it, or
|
|
51
51
|
* taking a message down, happens at once.
|
|
52
|
+
*
|
|
53
|
+
* THE SHOP (0.24.0, worker/shop.mjs): /_studio/office/shop and /_studio/api/shop (sales, refunds, disputes, payouts as
|
|
54
|
+
* links to the studio's own Stripe, a CSV, the referral books and signed statements). A refund is the owner's one tap;
|
|
55
|
+
* from an office key it is ALWAYS an ask (the AI may only propose one), and so is marking a referrer paid.
|
|
52
56
|
*/
|
|
53
57
|
import { SEAT_MAX, seatsOf } from './seats.mjs';
|
|
54
58
|
import { PUBLIC_SERVER, SERVER_LIMITS, checkServer, levelName, narrows, policyOf, roomServer, rowFor, serverView, serversOf, serverPassCookie, writeServer } from './servers.mjs';
|
|
@@ -60,6 +64,8 @@ import { confirmPage, lockedPage, officePage } from './office-page.mjs';
|
|
|
60
64
|
import { licenseOf } from './license.mjs';
|
|
61
65
|
import { CHAT_MODES, CHAT_WHO, checkChatRules, publicChat } from './chat.mjs';
|
|
62
66
|
import { chatDay, chatOf, chatRowsOf, clearChatRules, dismissReport, reportsOf, writeChatRules } from './chat-store.mjs';
|
|
67
|
+
import { checkRefund, checkSettle, describeRefund, officeShopPage, ordersCsv, performRefund, settleReferrer, shopOffice, statementsOut, statementsSend } from './shop.mjs';
|
|
68
|
+
import { orderById } from './shop-store.mjs';
|
|
63
69
|
|
|
64
70
|
export { OFFICE_MIGRATION, OFFICE_MIGRATION_FILE } from './office-schema.mjs';
|
|
65
71
|
|
|
@@ -673,6 +679,8 @@ function checkAction(cat, op, body) {
|
|
|
673
679
|
if (!(level >= 1 && level <= 5)) return bad('level is 1 to 5 (Rookie, Steady, Fair, Strong, Maxed)');
|
|
674
680
|
return { ok: true, action: { op, game: meta.id, room, level } };
|
|
675
681
|
}
|
|
682
|
+
case 'refund': return checkRefund(body);
|
|
683
|
+
case 'shop-settle': return checkSettle(body);
|
|
676
684
|
case 'agents-brain': {
|
|
677
685
|
if (!meta) return bad('game is one of this studio\'s game ids');
|
|
678
686
|
if (typeof body.server !== 'string' || !(body.server === 'public' || /^[a-z0-9][a-z0-9-]{1,19}$/.test(body.server))) return bad('server is the server\'s id');
|
|
@@ -770,6 +778,8 @@ export function describe(cat, a) {
|
|
|
770
778
|
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}.`;
|
|
771
779
|
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".`;
|
|
772
780
|
case 'room-level': return `Set the AI in ${where} to ${levelName(a.level)} (level ${a.level}).`;
|
|
781
|
+
case 'refund': return describeRefund(cat, a, a.item ? { item: a.item, amount: a.amount, currency: a.currency } : null);
|
|
782
|
+
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.`;
|
|
773
783
|
case 'agents-brain': return ['workers-ai', 'owner-key'].includes(a.mode)
|
|
774
784
|
? `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'})`}.`
|
|
775
785
|
: `Set the AI guides' brain on ${gname}'s server ${a.server} to ${a.mode}.`;
|
|
@@ -800,6 +810,8 @@ export function describe(cat, a) {
|
|
|
800
810
|
/** Whether an office key (the owner's AI) only ASKS for this (DESIGN D13): what takes something away, or consent. */
|
|
801
811
|
export async function needsAsk(env, cat, a) {
|
|
802
812
|
if (DESTRUCTIVE.has(a.op)) return true;
|
|
813
|
+
// Money: the owner's AI may only propose a refund or a settlement; the owner says yes.
|
|
814
|
+
if (a.op === 'refund' || a.op === 'shop-settle') return true;
|
|
803
815
|
if (a.op === 'server-close') return !a.reopen;
|
|
804
816
|
if (a.op === 'member') return a.remove === true;
|
|
805
817
|
if (a.op === 'server-set') {
|
|
@@ -1020,6 +1032,8 @@ export async function perform(env, cat, a) {
|
|
|
1020
1032
|
if (a.mode === 'off') notes.push('The guides are the game\'s plain bots.');
|
|
1021
1033
|
return { ok: true, op: a.op, game: meta.id, server: sv.id, brain: sv.brain, budget: day.budget, note: `Saved. ${notes.join(' ')}`.trim() };
|
|
1022
1034
|
}
|
|
1035
|
+
case 'refund': return performRefund(env, cat, a);
|
|
1036
|
+
case 'shop-settle': return settleReferrer(env, a);
|
|
1023
1037
|
default: return { ok: false, error: 'op' };
|
|
1024
1038
|
}
|
|
1025
1039
|
}
|
|
@@ -1155,11 +1169,21 @@ async function api(request, env, url, cat) {
|
|
|
1155
1169
|
const view = await officeView(env, { ...cat, games: metas }, url.origin);
|
|
1156
1170
|
return json({ ok: true, day: view.chat, games: view.games.map((g) => ({ id: g.id, name: g.name, chat: g.chat, rooms: g.rooms.map((r) => ({ room: r.room, label: r.label, players: r.players, chat: r.chat })) })) });
|
|
1157
1171
|
}
|
|
1172
|
+
// The shop (0.24.0): the owner's view, the accountant's CSV, the referral statements.
|
|
1173
|
+
if (path === '/_studio/api/shop' && request.method === 'GET') return json(await shopOffice(env, cat, url.origin));
|
|
1174
|
+
if (path === '/_studio/api/shop/orders.csv' && request.method === 'GET') {
|
|
1175
|
+
try {
|
|
1176
|
+
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"` } });
|
|
1177
|
+
} catch { return json({ ok: false, error: 'not-migrated', message: 'The shop needs migration 0008_studio_shop.sql (npm run deploy).' }, 503); }
|
|
1178
|
+
}
|
|
1179
|
+
if (path === '/_studio/api/shop/statements' && request.method === 'GET') return json(await statementsOut(env, cat, url.origin, url.searchParams.get('period')));
|
|
1180
|
+
if (path === '/_studio/api/shop/statements/send' && request.method === 'POST') return json(await statementsSend(env, cat, url.origin, body.period));
|
|
1158
1181
|
const OPS = {
|
|
1159
1182
|
'/_studio/api/chat/rules': 'chat-rules', '/_studio/api/chat/remove': 'chat-remove', '/_studio/api/chat/report': 'chat-report', '/_studio/api/chat/budget': 'chat-budget',
|
|
1160
1183
|
'/_studio/api/kick': 'kick', '/_studio/api/mute': 'mute', '/_studio/api/close': 'close', '/_studio/api/announce': 'announce', '/_studio/api/game': 'game',
|
|
1161
1184
|
'/_studio/api/servers': 'server-create', '/_studio/api/servers/set': 'server-set', '/_studio/api/servers/close': 'server-close', '/_studio/api/servers/member': 'member',
|
|
1162
1185
|
'/_studio/api/agents/pass': 'pass', '/_studio/api/room-level': 'room-level', '/_studio/api/agents/brain': 'agents-brain',
|
|
1186
|
+
'/_studio/api/shop/refund': 'refund', '/_studio/api/shop/settle': 'shop-settle',
|
|
1163
1187
|
};
|
|
1164
1188
|
const op = OPS[path];
|
|
1165
1189
|
if (!op || request.method !== 'POST') return json({ ok: false, error: 'not-found' }, 404);
|
|
@@ -1167,6 +1191,12 @@ async function api(request, env, url, cat) {
|
|
|
1167
1191
|
if (!checked.ok) return json(checked, 400);
|
|
1168
1192
|
const action = checked.action;
|
|
1169
1193
|
if (op === 'server-create' || op === 'pass') action.origin = url.origin;
|
|
1194
|
+
if (op === 'refund') {
|
|
1195
|
+
// The ask and the confirm page say what the order is (its item and price), read from the books now.
|
|
1196
|
+
const o = await orderById(env, action.order);
|
|
1197
|
+
if (!o) return json({ ok: false, error: 'order', message: 'No such order (homie-studio shop orders lists them).' }, 404);
|
|
1198
|
+
Object.assign(action, { item: o.item, amount: Number(o.amount), currency: o.currency });
|
|
1199
|
+
}
|
|
1170
1200
|
// An office key (the owner's AI) only ASKS for what takes something away; the owner confirms with one tap.
|
|
1171
1201
|
if (who === 'office' && await needsAsk(env, cat, action)) {
|
|
1172
1202
|
if (op === 'kick' || op === 'mute') {
|
|
@@ -1199,6 +1229,12 @@ export async function officeRoutes(request, env, url, { catalogueOf }) {
|
|
|
1199
1229
|
const secure = url.protocol === 'https:' ? '; Secure' : '';
|
|
1200
1230
|
return officePage(cat, session ? { 'set-cookie': `${OWNER_COOKIE}=${session}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${30 * 24 * 3600}${secure}` } : {});
|
|
1201
1231
|
}
|
|
1232
|
+
if (path === '/_studio/office/shop' || path === '/_studio/office/shop/') {
|
|
1233
|
+
const cat = await catalogueOf();
|
|
1234
|
+
if (request.method !== 'GET') return new Response('method', { status: 405 });
|
|
1235
|
+
if (!(await isOwner(request, env))) return lockedPage(cat, { what: 'office' });
|
|
1236
|
+
return officeShopPage(cat);
|
|
1237
|
+
}
|
|
1202
1238
|
const confirm = /^\/_studio\/confirm\/(ask_[a-f0-9]{16})\/?$/.exec(path);
|
|
1203
1239
|
if (confirm) {
|
|
1204
1240
|
const cat = await catalogueOf();
|
package/worker/pages.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { SKILLS } from './agents.mjs';
|
|
|
8
8
|
import { KIDS_LINE, POLICY_WORDS } from './servers.mjs';
|
|
9
9
|
import { SAVES_SHELL_CSS, SAVES_SHELL_JS } from './saves-shell.mjs';
|
|
10
10
|
import { CHAT_CSS, CHAT_JS, CHAT_OWNER_JS, chatBoot } from './chat-page.mjs';
|
|
11
|
+
import { SHOP_SHELL_CSS, SHOP_SHELL_JS } from './shop-page.mjs';
|
|
11
12
|
|
|
12
13
|
export { homePage, mediaIndexPage, notFoundPage, songPage, videoPage } from './site.mjs';
|
|
13
14
|
|
|
@@ -85,7 +86,7 @@ export function sharePlaces(value) {
|
|
|
85
86
|
* the edge (game.json `screen.share`, per device: sharePlaces) opens Invite, Big screen and the room code; nothing
|
|
86
87
|
* covers the middle of the screen or a thumb.
|
|
87
88
|
*/
|
|
88
|
-
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 } = {}) {
|
|
89
|
+
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 } = {}) {
|
|
89
90
|
const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
|
|
90
91
|
const corner = (name, fallback) => (['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(g.screen?.[name]) ? g.screen[name] : fallback);
|
|
91
92
|
const places = sharePlaces(g.screen?.share);
|
|
@@ -160,14 +161,14 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
|
|
|
160
161
|
.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); }
|
|
161
162
|
.notice .acts a.primary { background: var(--hot); color: #0b0b10; border-color: transparent; }
|
|
162
163
|
[hidden] { display: none !important; }
|
|
163
|
-
${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${pillUi}`;
|
|
164
|
+
${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${shop ? SHOP_SHELL_CSS : ''}${pillUi}`;
|
|
164
165
|
// The room button's place on each device (sharePlaces); the shell moves it to this browser's once it knows the device.
|
|
165
166
|
// A ticket (a game that is not public) and the owner's overlay ride along only for the browser they are for.
|
|
166
167
|
// The server this page plays on (0.16.0): its pool (the Lobby), its badge and line for the chip, its ceiling.
|
|
167
168
|
const srv = server ? { id: server.id, name: server.name, badge: server.badge, line: server.line, policy: server.policy, kids: Boolean(server.kids), mentor: Boolean(server.mentor), levelMax: server.levelMax } : null;
|
|
168
169
|
// game.json "agents": { "vote": "game" } draws its own vote card; false turns the vote off (section 17).
|
|
169
170
|
const vote = g.agents?.vote === 'game' || g.agents?.vote === false ? g.agents.vote : 'shell';
|
|
170
|
-
const boot = { game: g.id, name: g.name, screen: Boolean(screen), share: places, ...(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 };
|
|
171
|
+
const boot = { game: g.id, name: g.name, screen: Boolean(screen), share: places, ...(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 } : {}) };
|
|
171
172
|
// game.json "screen": { "join": "top-left" | "top-right" | "bottom-left" | "bottom-right" } keeps the card off the game's own HUD.
|
|
172
173
|
const joinCorner = corner('join', 'bottom-right');
|
|
173
174
|
const first = places.desk;
|
|
@@ -190,7 +191,7 @@ ${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${pillUi}`;
|
|
|
190
191
|
<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>
|
|
191
192
|
</div>
|
|
192
193
|
<span class="link" data-room-link></span>
|
|
193
|
-
<div class="foot"><a href="/${esc(g.id)}/">← ${esc(g.name)}</a
|
|
194
|
+
<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 ? `
|
|
194
195
|
<div class="who-row" data-who hidden><span data-who-name></span><button type="button" data-who-act></button></div>` : ''}
|
|
195
196
|
</div>
|
|
196
197
|
</div>
|
|
@@ -206,7 +207,8 @@ ${joinCard}${share}
|
|
|
206
207
|
<script>window.__HOMIE_CHAT=${JSON.stringify(chatBoot(g, { surface: screen ? 'tv' : 'play', owner, acct, member })).replace(/</g, '\\u003c')};</script>
|
|
207
208
|
<script>${CHAT_JS}</script>
|
|
208
209
|
<script>${SHELL_JS}</script>${g.saves && !screen ? `
|
|
209
|
-
<script>${SAVES_SHELL_JS}</script>` : ''}${
|
|
210
|
+
<script>${SAVES_SHELL_JS}</script>` : ''}${shop ? `
|
|
211
|
+
<script>${SHOP_SHELL_JS}</script>` : ''}${owner ? `<style>${OWNER_CSS}</style><script>${OWNER_JS}</script><script>${CHAT_OWNER_JS}</script>` : ''}`, css, frameAncestors(cat));
|
|
210
212
|
}
|
|
211
213
|
|
|
212
214
|
/*
|
package/worker/players.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import { cookieValue } from './stats.mjs';
|
|
|
30
30
|
import { adoptStatements, bumpPlayerStats, dropPlayerData, fall, fallenList, getSave, listSaves, playerData, readPlayerStats, SAVE_LIMITS, savesSummary, wipeSaves, writeSaves } from './saves.mjs';
|
|
31
31
|
import { bytesToB64url, randomToken, sha256Hex, verifyAssertion, verifyRegistration } from './webauthn.mjs';
|
|
32
32
|
import { ACCOUNT_JS, accountPage } from './account-page.mjs';
|
|
33
|
+
import { forgetPlayerShop, livePurchases, shopDataOf } from './shop-store.mjs';
|
|
33
34
|
|
|
34
35
|
export const PLAYERS_MIGRATION_FILE = '0004_players.sql';
|
|
35
36
|
export const PLAYERS_MIGRATION = `-- Players and cloud saves (@homie-rocks/studio 0.12.0): accounts on this studio only (saves/SAVES.md).
|
|
@@ -323,6 +324,8 @@ async function removePlayer(env, id) {
|
|
|
323
324
|
]);
|
|
324
325
|
// Their servers' memberships too (0.16.0); a studio before migration 0006 has none to delete.
|
|
325
326
|
try { await env.DB.prepare('DELETE FROM server_members WHERE player = ?1').bind(id).run(); } catch { /* not migrated */ }
|
|
327
|
+
// What they bought goes with them; the orders stay, without the player (0.24.0; none before migration 0008).
|
|
328
|
+
try { await env.DB.batch(forgetPlayerShop(env, id)); } catch { /* not migrated */ }
|
|
326
329
|
await data.after();
|
|
327
330
|
}
|
|
328
331
|
|
|
@@ -508,6 +511,8 @@ async function apiRoute(request, env, ctx, url, parts, { catalogueOf, read, f })
|
|
|
508
511
|
player: me,
|
|
509
512
|
passkeys: keys.map((k) => ({ label: k.label, synced: Number(k.synced) === 1, createdAt: new Date(Number(k.created_at)).toISOString(), lastUsedAt: new Date(Number(k.used_at)).toISOString() })),
|
|
510
513
|
games: await playerData(env, s.player.id),
|
|
514
|
+
// The shop (0.24.0): their orders, what they own and the age band they gave; never a card or an email.
|
|
515
|
+
...(await shopDataOf(env, s.player.id).then((x) => (x ? { shop: x } : {}))),
|
|
511
516
|
note: 'Everything this studio keeps about this player. A passkey\'s private key never left your device; this site keeps only its public key.',
|
|
512
517
|
};
|
|
513
518
|
return json(body, 200, { 'content-disposition': `attachment; filename="${s.player.id}.json"` });
|
|
@@ -770,6 +775,9 @@ async function apiRoute(request, env, ctx, url, parts, { catalogueOf, read, f })
|
|
|
770
775
|
|
|
771
776
|
if (head === 'delete' && parts.length === 1) {
|
|
772
777
|
if (body.confirm !== 'delete') return fail(400, 'confirm', 'send { "confirm": "delete" } to delete this player and everything they kept');
|
|
778
|
+
// Things they bought go with the account: say so first (the shop, 0.24.0).
|
|
779
|
+
const owned = await livePurchases(env, s.player.id).catch(() => 0);
|
|
780
|
+
if (owned && body.purchases !== 'forfeit') return fail(409, 'purchases', `This account owns ${owned} thing${owned === 1 ? '' : 's'} bought here; deleting it gives ${owned === 1 ? 'it' : 'them'} up for good (an unused item can be refunded first, from this page). Send { "confirm": "delete", "purchases": "forfeit" } to delete anyway.`, { owned });
|
|
773
781
|
await removePlayer(env, s.player.id);
|
|
774
782
|
return withCookies(json({ ok: true, deleted: s.player.id }), [clearCookie(url)]);
|
|
775
783
|
}
|