@homie-rocks/studio 0.22.0 → 0.24.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 +61 -1
- package/README.md +59 -0
- package/bin/homie-studio.mjs +68 -2
- package/chat/CHAT.md +101 -0
- package/chat/OWNERS.md +78 -0
- package/lib/build.mjs +20 -2
- package/lib/chat-cli.mjs +89 -0
- package/lib/cloudflare.mjs +5 -2
- package/lib/scaffold.mjs +59 -2
- package/lib/shop.mjs +270 -0
- package/lib/template-history.json +77 -0
- package/netplay/NETPLAY.md +194 -4
- package/netplay/netplay.ts +156 -3
- package/package.json +7 -1
- package/port/index.ts +3 -2
- package/port/view.ts +238 -0
- package/shop/SELLING.md +83 -0
- package/shop/SHOP.md +154 -0
- package/shop/shop.ts +130 -0
- package/site/SITE.md +22 -1
- package/starters/ember-vale/src/main.ts +22 -2
- package/starters/gem-rush/src/main.ts +21 -1
- package/starters/gem-rush-3d/src/main.ts +22 -1
- package/worker/account-page.mjs +14 -2
- package/worker/agents.mjs +3 -3
- package/worker/chat-page.mjs +556 -0
- package/worker/chat-store.mjs +161 -0
- package/worker/chat-words.mjs +46 -0
- package/worker/chat.mjs +419 -0
- package/worker/index.mjs +189 -7
- package/worker/office-page.mjs +120 -5
- package/worker/office.mjs +173 -6
- package/worker/pages.mjs +25 -8
- package/worker/players.mjs +8 -0
- package/worker/referrals.mjs +328 -0
- package/worker/room.mjs +291 -11
- package/worker/servers.mjs +5 -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/site.mjs +4 -1
- package/worker/stats.mjs +4 -1
- package/worker/stripe.mjs +149 -0
- package/worker/version.mjs +1 -1
package/worker/office.mjs
CHANGED
|
@@ -42,6 +42,17 @@
|
|
|
42
42
|
* passes (issued and revoked at once; the secret is shown once), a room's dial (room_level), and whether AI guides
|
|
43
43
|
* may talk (the first time is an ASK: the owner's consent). A server change reaches its live rooms as a signed
|
|
44
44
|
* `policy` control; AI leave after the round when a server becomes humans-only.
|
|
45
|
+
*
|
|
46
|
+
* ROOM CHAT (0.23.0, worker/chat.mjs and worker/chat-store.mjs; NETPLAY.md section 19): each game's chat rules (its
|
|
47
|
+
* game.json defaults, the owner's for the game, the owner's for each server), the last minutes of every live room's
|
|
48
|
+
* chat with Remove (a signed `unsay`), and Mute or Kick from a line (its sender, with their lines taken down), the
|
|
49
|
+
* reports players made (each the one message it is about) with Dismiss, and the review's day (Workers AI neurons).
|
|
50
|
+
* From an office key, a change that opens chat up (more allowed, fewer checks) is an ASK; one that tightens it, or
|
|
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.
|
|
45
56
|
*/
|
|
46
57
|
import { SEAT_MAX, seatsOf } from './seats.mjs';
|
|
47
58
|
import { PUBLIC_SERVER, SERVER_LIMITS, checkServer, levelName, narrows, policyOf, roomServer, rowFor, serverView, serversOf, serverPassCookie, writeServer } from './servers.mjs';
|
|
@@ -51,6 +62,10 @@ import { OWNER_COOKIE, cookieValues, ownerAllowed, ownerSession, today } from '.
|
|
|
51
62
|
import { esc, layout, notFoundPage } from './site.mjs';
|
|
52
63
|
import { confirmPage, lockedPage, officePage } from './office-page.mjs';
|
|
53
64
|
import { licenseOf } from './license.mjs';
|
|
65
|
+
import { CHAT_MODES, CHAT_WHO, checkChatRules, publicChat } from './chat.mjs';
|
|
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';
|
|
54
69
|
|
|
55
70
|
export { OFFICE_MIGRATION, OFFICE_MIGRATION_FILE } from './office-schema.mjs';
|
|
56
71
|
|
|
@@ -456,6 +471,8 @@ async function roomRow(env, meta, room, max) {
|
|
|
456
471
|
host: f.host ?? null, announce: f.announce ?? null,
|
|
457
472
|
slots: slots.slice(0, 64).map((s) => ({ slot: s.slot, seat: s.seat ?? null, name: oneLine(s.name, 40), bot: Boolean(s.bot), ...(s.agent ? { agent: { role: s.agent.role ?? 'party', seat: s.agent.seat ?? null } } : {}) })),
|
|
458
473
|
clients, bans: office.bans ?? [], mutes: office.mutes ?? [],
|
|
474
|
+
// Room chat (0.23.0): the room's last minutes, with who sent each line (a client id and account; never an address).
|
|
475
|
+
chat: office.chat ? { mode: office.chat.rules?.mode ?? null, pending: office.chat.pending ?? 0, lines: (office.chat.lines ?? []).slice(-50).map((l) => ({ id: l.id, at: l.at, t: l.t, kind: l.kind, name: oneLine(l.name, 40), seat: l.seat ?? null, by: l.by, text: l.text ?? null, glyph: l.glyph ?? null, react: l.react ?? null, say: l.say ?? null, acct: Boolean(l.acct), owner: Boolean(l.owner), client: l.client ?? null, player: l.player ?? null, browser: l.browser ?? null, ...(l.review ? { review: l.review } : {}) })) } : null,
|
|
459
476
|
// The house guides' brains (0.17.0): which brain, why not when it is not, and the last decisions (seats and ids).
|
|
460
477
|
...(f.brains && typeof f.brains === 'object' ? { brains: {
|
|
461
478
|
brain: String(f.brains.brain ?? ''), why: f.brains.why ? oneLine(f.brains.why, 120) : null, calls: Number(f.brains.calls) || 0,
|
|
@@ -517,14 +534,23 @@ export async function officeView(env, cat, origin) {
|
|
|
517
534
|
const row = settings.get(g.id);
|
|
518
535
|
const max = seatsFor(g, settings);
|
|
519
536
|
const rooms = (await Promise.all((await liveRooms(env, g.id)).map((r) => roomRow(env, g, r.room, max)))).filter(Boolean)
|
|
520
|
-
.filter((r) => r.players + r.screens > 0 || r.closedUntil || r.bans.length);
|
|
537
|
+
.filter((r) => r.players + r.screens > 0 || r.closedUntil || r.bans.length || (r.chat && r.chat.lines.length));
|
|
521
538
|
rooms.sort((a, b) => b.players - a.players || a.room.localeCompare(b.room));
|
|
522
539
|
const servers = await serversOf(env, g, { fresh: true });
|
|
523
540
|
const byServer = {};
|
|
524
541
|
for (const r of rooms) { const b = (byServer[r.server] ??= { rooms: 0, players: 0, ai: 0 }); b.rooms += 1; b.players += r.players; b.ai += r.ai; }
|
|
525
542
|
const members = await memberCountsOf(env, g.id);
|
|
543
|
+
const chatRows = await chatRowsOf(env, { fresh: true });
|
|
526
544
|
return {
|
|
527
545
|
id: g.id, name: g.name, launch, launchFrom: row?.launch ? 'office' : g.launch ? 'game.json' : 'default',
|
|
546
|
+
// Room chat (0.23.0): the game's rules as Quick play has them, where each came from, and each server's own.
|
|
547
|
+
chat: {
|
|
548
|
+
rules: publicChat(chatOf(g, servers.find((x) => x.id === 'public') ?? null, chatRows)),
|
|
549
|
+
from: chatRows.get(`${g.id}/`) ? 'office' : g.chat !== undefined ? 'game.json' : 'default',
|
|
550
|
+
office: chatRows.get(`${g.id}/`) ?? null,
|
|
551
|
+
servers: servers.filter((x) => x.id !== 'public' || chatRows.get(`${g.id}/public`)).map((sv) => ({ id: sv.id, name: sv.name, rules: publicChat(chatOf(g, sv, chatRows)), office: chatRows.get(`${g.id}/${sv.id}`) ?? null })),
|
|
552
|
+
reports: await reportsOf(env, { game: g.id }),
|
|
553
|
+
},
|
|
528
554
|
// Servers (0.16.0): each with its policy and what is on it now; its build reads the dial only from netplay rev 6.
|
|
529
555
|
servers: servers.map((sv) => ({ ...serverView(sv, { origin, game: g.id }), live: byServer[sv.id] ?? { rooms: 0, players: 0, ai: 0 }, members: members[sv.id] ?? 0 })),
|
|
530
556
|
build: { netplayRev: Number.isInteger(g.netplayRev) ? g.netplayRev : null, predates: !(Number(g.netplayRev) >= 6), caps: g.caps ?? null, guides: Number(g.netplayRev) >= 7 },
|
|
@@ -545,6 +571,7 @@ export async function officeView(env, cat, origin) {
|
|
|
545
571
|
fillSpot: { available: Boolean(fillSpot()), note: 'Let a fill-a-spot service seat AI (coming later)' },
|
|
546
572
|
agentsTalk: await talkConsented(env),
|
|
547
573
|
brain: await brainDay(env),
|
|
574
|
+
chat: await chatDay(env, today()),
|
|
548
575
|
};
|
|
549
576
|
}
|
|
550
577
|
async function memberCountsOf(env, game) {
|
|
@@ -579,8 +606,10 @@ function checkAction(cat, op, body) {
|
|
|
579
606
|
if (!room) return bad('room is the room\'s code (pub-3, or a named room)');
|
|
580
607
|
const id = typeof body.id === 'string' && /^[A-Za-z0-9_-]{1,16}$/.test(body.id) ? body.id : null;
|
|
581
608
|
const seat = Number.isInteger(body.seat) && body.seat >= 0 && body.seat < SEAT_MAX ? body.seat : null;
|
|
582
|
-
|
|
583
|
-
|
|
609
|
+
// From a chat line (0.23.0): its sender, whoever they are now (a watcher too); `purge` takes their lines down.
|
|
610
|
+
const line = typeof body.line === 'string' && /^[A-Za-z0-9_-]{1,16}$/.test(body.line) ? body.line : null;
|
|
611
|
+
if (id === null && seat === null && line === null) return bad('name the player: id (from the office), seat, or line (a chat message of theirs)');
|
|
612
|
+
return { ok: true, action: { op, game: meta.id, room, id, seat, ...(line ? { line, purge: body.purge === true } : {}), minutes, ...(op === 'kick' ? { address: body.address === true, message: oneLine(body.message, 200) || null } : { off: body.off === true }), name: oneLine(body.name, 40) || null } };
|
|
584
613
|
}
|
|
585
614
|
case 'close': {
|
|
586
615
|
if (!meta) return bad('game is one of this studio\'s game ids');
|
|
@@ -650,6 +679,8 @@ function checkAction(cat, op, body) {
|
|
|
650
679
|
if (!(level >= 1 && level <= 5)) return bad('level is 1 to 5 (Rookie, Steady, Fair, Strong, Maxed)');
|
|
651
680
|
return { ok: true, action: { op, game: meta.id, room, level } };
|
|
652
681
|
}
|
|
682
|
+
case 'refund': return checkRefund(body);
|
|
683
|
+
case 'shop-settle': return checkSettle(body);
|
|
653
684
|
case 'agents-brain': {
|
|
654
685
|
if (!meta) return bad('game is one of this studio\'s game ids');
|
|
655
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');
|
|
@@ -659,11 +690,56 @@ function checkAction(cat, op, body) {
|
|
|
659
690
|
if (budget !== undefined && (!['workers-ai', 'owner-key'].includes(body.mode) || !(budget >= 0) || budget > (body.mode === 'owner-key' ? 100 : 1e7))) return bad('budget goes with workers-ai (neurons a day; the free allocation is 10,000 an account) or owner-key (dollars a day, at most 100)');
|
|
660
691
|
return { ok: true, action: { op, game: meta.id, server: body.server, mode: body.mode, ...(budget !== undefined ? { budget } : {}) } };
|
|
661
692
|
}
|
|
693
|
+
case 'chat-rules': {
|
|
694
|
+
if (!meta) return bad('game is one of this studio\'s game ids');
|
|
695
|
+
const server = body.server === undefined || body.server === null || body.server === '' ? '' : String(body.server);
|
|
696
|
+
if (server && !(server === 'public' || /^[a-z0-9][a-z0-9-]{1,19}$/.test(server))) return bad('server is a server\'s id, or leave it out for the whole game');
|
|
697
|
+
if (body.reset === true) return { ok: true, action: { op, game: meta.id, server, reset: true } };
|
|
698
|
+
const { game: _g, server: _s, ...rest } = body;
|
|
699
|
+
const c = checkChatRules(rest);
|
|
700
|
+
if (!c.ok) return c;
|
|
701
|
+
if (!Object.keys(c.fields).length) return bad(`say what to change: mode (${CHAT_MODES.join(', ')}), who / react (${CHAT_WHO.join(', ')}), slow, max, links, swears, ai, bubbles, watchers, hub, block, allow, lines, emoji; or reset: true`);
|
|
702
|
+
return { ok: true, action: { op, game: meta.id, server, fields: c.fields } };
|
|
703
|
+
}
|
|
704
|
+
case 'chat-remove': {
|
|
705
|
+
if (!meta) return bad('game is one of this studio\'s game ids');
|
|
706
|
+
if (!room) return bad('room is the room\'s code');
|
|
707
|
+
const ids = (Array.isArray(body.ids) ? body.ids : body.id !== undefined ? [body.id] : []).filter((x) => typeof x === 'string' && /^[A-Za-z0-9_-]{1,16}$/.test(x)).slice(0, 64);
|
|
708
|
+
if (!ids.length && body.all !== true) return bad('id is the message\'s id (from the office), or all: true');
|
|
709
|
+
return { ok: true, action: { op, game: meta.id, room, ...(body.all === true ? { all: true } : { ids }) } };
|
|
710
|
+
}
|
|
711
|
+
case 'chat-report': {
|
|
712
|
+
if (!/^cr_[a-f0-9]{16}$/.test(String(body.id ?? ''))) return bad('id is the report\'s id');
|
|
713
|
+
return { ok: true, action: { op, id: body.id } };
|
|
714
|
+
}
|
|
715
|
+
case 'chat-budget': {
|
|
716
|
+
const n = Math.floor(Number(body.neurons));
|
|
717
|
+
if (!(n >= 0 && n <= 1e7)) return bad('neurons is the review\'s day for the whole studio (0 to 10,000,000; Workers AI gives an account 10,000 a day free)');
|
|
718
|
+
return { ok: true, action: { op, neurons: n } };
|
|
719
|
+
}
|
|
662
720
|
default:
|
|
663
721
|
return bad('unknown control');
|
|
664
722
|
}
|
|
665
723
|
}
|
|
666
724
|
|
|
725
|
+
/** Whether a chat change opens chat up (an office key only ASKS for these): more allowed, fewer checks, wider doors. */
|
|
726
|
+
export function chatOpensUp(before, f) {
|
|
727
|
+
const rank = { off: 0, emoji: 1, lines: 2, text: 3 };
|
|
728
|
+
const who = { members: 0, 'signed-in': 1, anyone: 2 };
|
|
729
|
+
if (f.mode !== undefined && rank[f.mode] > rank[before.mode]) return true;
|
|
730
|
+
for (const k of ['who', 'react']) if (f[k] !== undefined && who[f[k]] > who[before[k]]) return true;
|
|
731
|
+
if (f.slow !== undefined && f.slow < before.slow) return true;
|
|
732
|
+
if (f.max !== undefined && f.max > before.max) return true;
|
|
733
|
+
if (f.links === 'allow' && before.links !== 'allow') return true;
|
|
734
|
+
if (f.swears === 'allow' && before.swears !== 'allow') return true;
|
|
735
|
+
for (const k of ['watchers', 'hub']) if (f[k] === true && !before[k]) return true;
|
|
736
|
+
if (f.ai === false && before.ai) return true;
|
|
737
|
+
if (f.allow !== undefined && f.allow.some((w) => !(before.allow ?? []).includes(w))) return true;
|
|
738
|
+
if (f.block !== undefined && (before.block ?? []).some((w) => !f.block.includes(w))) return true;
|
|
739
|
+
if (f.lines !== undefined || f.emoji !== undefined) return true;
|
|
740
|
+
return false;
|
|
741
|
+
}
|
|
742
|
+
|
|
667
743
|
/** The action in plain words, for the owner's confirm page and the AI's card. */
|
|
668
744
|
export function describe(cat, a) {
|
|
669
745
|
const g = (cat.games ?? []).find((x) => x.id === a.game);
|
|
@@ -702,9 +778,31 @@ export function describe(cat, a) {
|
|
|
702
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}.`;
|
|
703
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".`;
|
|
704
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.`;
|
|
705
783
|
case 'agents-brain': return ['workers-ai', 'owner-key'].includes(a.mode)
|
|
706
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'})`}.`
|
|
707
785
|
: `Set the AI guides' brain on ${gname}'s server ${a.server} to ${a.mode}.`;
|
|
786
|
+
case 'chat-rules': {
|
|
787
|
+
const where = a.server ? `${gname}'s server ${a.server}` : gname;
|
|
788
|
+
if (a.reset) return `Give ${where} its own chat rules back (the game's game.json, or Homie's defaults).`;
|
|
789
|
+
const f = a.fields;
|
|
790
|
+
const bits = [];
|
|
791
|
+
if (f.mode) bits.push({ off: 'turn chat off', emoji: 'keep chat to emoji', lines: 'keep chat to emoji and quick lines', text: 'let players type' }[f.mode]);
|
|
792
|
+
if (f.who) bits.push(`typing for ${f.who === 'anyone' ? 'anyone, guests too' : f.who === 'signed-in' ? 'signed-in players (a passkey)' : 'members only'}`);
|
|
793
|
+
if (f.react) bits.push(`emoji and quick lines for ${f.react === 'anyone' ? 'anyone' : f.react === 'signed-in' ? 'signed-in players' : 'members only'}`);
|
|
794
|
+
if (f.slow !== undefined) bits.push(f.slow ? `slow mode: one message every ${f.slow} s` : 'no slow mode');
|
|
795
|
+
if (f.max !== undefined) bits.push(`messages up to ${f.max} characters`);
|
|
796
|
+
if (f.links) bits.push(f.links === 'allow' ? 'allow links' : 'hold links');
|
|
797
|
+
if (f.swears) bits.push(f.swears === 'allow' ? 'allow swearing' : 'hold swearing');
|
|
798
|
+
if (f.ai !== undefined) bits.push(f.ai ? 'review typed messages with this studio\'s Workers AI' : 'stop the AI review (the word list still runs)');
|
|
799
|
+
const rest = Object.keys(f).filter((k) => !['mode', 'who', 'react', 'slow', 'max', 'links', 'swears', 'ai'].includes(k));
|
|
800
|
+
if (rest.length) bits.push(`change its ${rest.join(', ')}`);
|
|
801
|
+
return `Room chat in ${where}: ${bits.join('; ')}.`;
|
|
802
|
+
}
|
|
803
|
+
case 'chat-remove': return a.all ? `Take every message down in ${where}.` : `Take ${a.ids.length === 1 ? 'a message' : `${a.ids.length} messages`} down in ${where}.`;
|
|
804
|
+
case 'chat-report': return 'Dismiss a chat report (it is deleted).';
|
|
805
|
+
case 'chat-budget': return `Let room chat's review use up to ${a.neurons.toLocaleString('en-US')} Workers AI neurons a day for the whole studio (about ${a.neurons.toLocaleString('en-US')} typed messages; the free allocation is 10,000 an account, shared with the AI guides; past it on Workers Paid, $0.011 per 1,000).`;
|
|
708
806
|
default: return 'A control.';
|
|
709
807
|
}
|
|
710
808
|
}
|
|
@@ -712,6 +810,8 @@ export function describe(cat, a) {
|
|
|
712
810
|
/** Whether an office key (the owner's AI) only ASKS for this (DESIGN D13): what takes something away, or consent. */
|
|
713
811
|
export async function needsAsk(env, cat, a) {
|
|
714
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;
|
|
715
815
|
if (a.op === 'server-close') return !a.reopen;
|
|
716
816
|
if (a.op === 'member') return a.remove === true;
|
|
717
817
|
if (a.op === 'server-set') {
|
|
@@ -719,6 +819,15 @@ export async function needsAsk(env, cat, a) {
|
|
|
719
819
|
const before = meta ? (await serversOf(env, meta, { fresh: true })).find((x) => x.id === a.server) : null;
|
|
720
820
|
return narrows(before, a.fields);
|
|
721
821
|
}
|
|
822
|
+
// A bigger review budget can cost money on Workers Paid: the owner says yes. A smaller one happens at once.
|
|
823
|
+
if (a.op === 'chat-budget') return a.neurons > (await chatDay(env, today())).budget.neurons;
|
|
824
|
+
if (a.op === 'chat-rules') {
|
|
825
|
+
if (a.reset) return true;
|
|
826
|
+
const meta = (cat.games ?? []).find((g) => g.id === a.game);
|
|
827
|
+
const servers = meta ? await serversOf(env, meta, { fresh: true }) : [];
|
|
828
|
+
const before = chatOf(meta, servers.find((x) => x.id === (a.server || 'public')) ?? null, await chatRowsOf(env, { fresh: true }));
|
|
829
|
+
return chatOpensUp(before, a.fields);
|
|
830
|
+
}
|
|
722
831
|
if (a.op === 'agents-brain') {
|
|
723
832
|
if (['workers-ai', 'owner-key'].includes(a.mode) && !(await talkConsented(env))) return true;
|
|
724
833
|
// Raising the cap on the owner's own money is the owner's to confirm; Workers AI's free allocation is not money.
|
|
@@ -735,7 +844,7 @@ async function serverRooms(env, game, server) {
|
|
|
735
844
|
|
|
736
845
|
/** A server's policy reaches every live room of it now (a signed `policy` control; section 17). */
|
|
737
846
|
async function pushPolicy(env, meta, settings, server) {
|
|
738
|
-
const pol = policyOf(server, { seats: seatsFor(meta, settings) });
|
|
847
|
+
const pol = policyOf(server, { seats: seatsFor(meta, settings), chat: chatOf(meta, server, await chatRowsOf(env, { fresh: true })) });
|
|
739
848
|
const rooms = await serverRooms(env, meta.id, server.id);
|
|
740
849
|
const res = await Promise.all(rooms.map((r) => roomControl(env, meta, settings, r.room, 'policy', { pol })));
|
|
741
850
|
return { rooms: rooms.length, leaving: res.reduce((n, r) => n + (r.leaving ?? 0), 0) };
|
|
@@ -748,9 +857,10 @@ export async function perform(env, cat, a) {
|
|
|
748
857
|
switch (a.op) {
|
|
749
858
|
case 'kick':
|
|
750
859
|
case 'mute': {
|
|
860
|
+
const line = a.line ? { line: a.line, purge: a.purge === true } : {};
|
|
751
861
|
const r = await roomControl(env, meta, settings, a.room, a.op, a.op === 'kick'
|
|
752
|
-
? { id: a.id, seat: a.seat, minutes: a.minutes, address: a.address, ...(a.message ? { message: a.message } : {}) }
|
|
753
|
-
: { id: a.id, seat: a.seat, minutes: a.minutes, off: a.off });
|
|
862
|
+
? { id: a.id, seat: a.seat, minutes: a.minutes, address: a.address, ...line, ...(a.message ? { message: a.message } : {}) }
|
|
863
|
+
: { id: a.id, seat: a.seat, minutes: a.minutes, off: a.off, ...line });
|
|
754
864
|
return r;
|
|
755
865
|
}
|
|
756
866
|
case 'close': return roomControl(env, meta, settings, a.room, 'close', a.reopen ? { reopen: true } : { minutes: a.minutes, ...(a.message ? { message: a.message } : {}) });
|
|
@@ -868,6 +978,32 @@ export async function perform(env, cat, a) {
|
|
|
868
978
|
} catch (error) { return { ok: false, error: 'not-migrated', message: `Agent passes need migration 0006_studio_servers.sql (npm run deploy). (${String(error?.message ?? error).slice(0, 120)})` }; }
|
|
869
979
|
}
|
|
870
980
|
case 'room-level': return roomControl(env, meta, settings, a.room, 'level', { level: a.level });
|
|
981
|
+
case 'chat-rules': {
|
|
982
|
+
if (!env?.DB) return { ok: false, error: 'no-db', message: 'This studio has no D1 for its office.' };
|
|
983
|
+
try {
|
|
984
|
+
if (a.reset) await clearChatRules(env, meta.id, a.server);
|
|
985
|
+
else await writeChatRules(env, meta.id, a.server, a.fields);
|
|
986
|
+
} catch (error) { return { ok: false, error: 'not-migrated', message: `Chat rules need migration 0007_studio_chat.sql (npm run deploy). (${String(error?.message ?? error).slice(0, 120)})` }; }
|
|
987
|
+
// Every live room the change reaches hears its new rules now (a signed policy control, as a server change does).
|
|
988
|
+
const servers = await serversOf(env, meta, { fresh: true });
|
|
989
|
+
const reach = a.server ? servers.filter((x) => x.id === a.server) : servers;
|
|
990
|
+
let rooms = 0;
|
|
991
|
+
for (const sv of reach) rooms += (await pushPolicy(env, meta, settings, sv)).rooms;
|
|
992
|
+
const rows = await chatRowsOf(env, { fresh: true });
|
|
993
|
+
const rules = publicChat(chatOf(meta, servers.find((x) => x.id === (a.server || 'public')) ?? PUBLIC_SERVER, rows));
|
|
994
|
+
return { ok: true, op: a.op, game: meta.id, server: a.server || null, rules, rooms };
|
|
995
|
+
}
|
|
996
|
+
case 'chat-remove': {
|
|
997
|
+
const r = await roomControl(env, meta, settings, a.room, 'unsay', a.all ? { all: true } : { ids: a.ids });
|
|
998
|
+
return r;
|
|
999
|
+
}
|
|
1000
|
+
case 'chat-report': {
|
|
1001
|
+
try { return { op: a.op, ...(await dismissReport(env, a.id)) }; } catch (error) { return { ok: false, error: 'not-migrated', message: String(error?.message ?? error).slice(0, 120) }; }
|
|
1002
|
+
}
|
|
1003
|
+
case 'chat-budget': {
|
|
1004
|
+
try { await env.DB.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('chat_budget', ?1)").bind(JSON.stringify({ neurons: a.neurons })).run(); } catch (error) { return { ok: false, error: 'no-db', message: String(error?.message ?? error).slice(0, 120) }; }
|
|
1005
|
+
return { ok: true, op: a.op, budget: { neurons: a.neurons }, note: `Room chat's review may use ${a.neurons.toLocaleString('en-US')} neurons a day from now on (rooms read it again within a minute).` };
|
|
1006
|
+
}
|
|
871
1007
|
case 'agents-brain': {
|
|
872
1008
|
const before = (await serversOf(env, meta, { fresh: true })).find((x) => x.id === a.server);
|
|
873
1009
|
if (!before) return { ok: false, error: 'no-server', message: `${meta.name} has no server called ${a.server}.` };
|
|
@@ -896,6 +1032,8 @@ export async function perform(env, cat, a) {
|
|
|
896
1032
|
if (a.mode === 'off') notes.push('The guides are the game\'s plain bots.');
|
|
897
1033
|
return { ok: true, op: a.op, game: meta.id, server: sv.id, brain: sv.brain, budget: day.budget, note: `Saved. ${notes.join(' ')}`.trim() };
|
|
898
1034
|
}
|
|
1035
|
+
case 'refund': return performRefund(env, cat, a);
|
|
1036
|
+
case 'shop-settle': return settleReferrer(env, a);
|
|
899
1037
|
default: return { ok: false, error: 'op' };
|
|
900
1038
|
}
|
|
901
1039
|
}
|
|
@@ -1025,10 +1163,27 @@ async function api(request, env, url, cat) {
|
|
|
1025
1163
|
return json({ ok: true, game, server, members });
|
|
1026
1164
|
} catch (error) { return json({ ok: false, error: 'not-migrated', message: `Servers need migration 0006_studio_servers.sql (npm run deploy). (${String(error?.message ?? error).slice(0, 120)})` }, 503); }
|
|
1027
1165
|
}
|
|
1166
|
+
// Room chat (0.23.0): every game's rules, every live room's last minutes, the reports, the review's day.
|
|
1167
|
+
if (path === '/_studio/api/chat' && request.method === 'GET') {
|
|
1168
|
+
const metas = (cat.games ?? []).filter((g) => !url.searchParams.get('game') || g.id === url.searchParams.get('game'));
|
|
1169
|
+
const view = await officeView(env, { ...cat, games: metas }, url.origin);
|
|
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 })) })) });
|
|
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));
|
|
1028
1181
|
const OPS = {
|
|
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',
|
|
1029
1183
|
'/_studio/api/kick': 'kick', '/_studio/api/mute': 'mute', '/_studio/api/close': 'close', '/_studio/api/announce': 'announce', '/_studio/api/game': 'game',
|
|
1030
1184
|
'/_studio/api/servers': 'server-create', '/_studio/api/servers/set': 'server-set', '/_studio/api/servers/close': 'server-close', '/_studio/api/servers/member': 'member',
|
|
1031
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',
|
|
1032
1187
|
};
|
|
1033
1188
|
const op = OPS[path];
|
|
1034
1189
|
if (!op || request.method !== 'POST') return json({ ok: false, error: 'not-found' }, 404);
|
|
@@ -1036,6 +1191,12 @@ async function api(request, env, url, cat) {
|
|
|
1036
1191
|
if (!checked.ok) return json(checked, 400);
|
|
1037
1192
|
const action = checked.action;
|
|
1038
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
|
+
}
|
|
1039
1200
|
// An office key (the owner's AI) only ASKS for what takes something away; the owner confirms with one tap.
|
|
1040
1201
|
if (who === 'office' && await needsAsk(env, cat, action)) {
|
|
1041
1202
|
if (op === 'kick' || op === 'mute') {
|
|
@@ -1068,6 +1229,12 @@ export async function officeRoutes(request, env, url, { catalogueOf }) {
|
|
|
1068
1229
|
const secure = url.protocol === 'https:' ? '; Secure' : '';
|
|
1069
1230
|
return officePage(cat, session ? { 'set-cookie': `${OWNER_COOKIE}=${session}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${30 * 24 * 3600}${secure}` } : {});
|
|
1070
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
|
+
}
|
|
1071
1238
|
const confirm = /^\/_studio\/confirm\/(ask_[a-f0-9]{16})\/?$/.exec(path);
|
|
1072
1239
|
if (confirm) {
|
|
1073
1240
|
const cat = await catalogueOf();
|
package/worker/pages.mjs
CHANGED
|
@@ -7,6 +7,8 @@ import { NET_PALETTE } from './room.mjs';
|
|
|
7
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
|
+
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';
|
|
10
12
|
|
|
11
13
|
export { homePage, mediaIndexPage, notFoundPage, songPage, videoPage } from './site.mjs';
|
|
12
14
|
|
|
@@ -84,7 +86,7 @@ export function sharePlaces(value) {
|
|
|
84
86
|
* the edge (game.json `screen.share`, per device: sharePlaces) opens Invite, Big screen and the room code; nothing
|
|
85
87
|
* covers the middle of the screen or a thumb.
|
|
86
88
|
*/
|
|
87
|
-
export function playPage(cat, g, { screen = false, joinUrl = null, qr = null, local = false, room = null, ticket = null, owner = false, launch = 'public', server = null } = {}) {
|
|
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 } = {}) {
|
|
88
90
|
const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
|
|
89
91
|
const corner = (name, fallback) => (['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(g.screen?.[name]) ? g.screen[name] : fallback);
|
|
90
92
|
const places = sharePlaces(g.screen?.share);
|
|
@@ -159,14 +161,14 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
|
|
|
159
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); }
|
|
160
162
|
.notice .acts a.primary { background: var(--hot); color: #0b0b10; border-color: transparent; }
|
|
161
163
|
[hidden] { display: none !important; }
|
|
162
|
-
${SERVER_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${pillUi}`;
|
|
164
|
+
${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${shop ? SHOP_SHELL_CSS : ''}${pillUi}`;
|
|
163
165
|
// The room button's place on each device (sharePlaces); the shell moves it to this browser's once it knows the device.
|
|
164
166
|
// A ticket (a game that is not public) and the owner's overlay ride along only for the browser they are for.
|
|
165
167
|
// The server this page plays on (0.16.0): its pool (the Lobby), its badge and line for the chip, its ceiling.
|
|
166
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;
|
|
167
169
|
// game.json "agents": { "vote": "game" } draws its own vote card; false turns the vote off (section 17).
|
|
168
170
|
const vote = g.agents?.vote === 'game' || g.agents?.vote === false ? g.agents.vote : 'shell';
|
|
169
|
-
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 } : {}) };
|
|
170
172
|
// game.json "screen": { "join": "top-left" | "top-right" | "bottom-left" | "bottom-right" } keeps the card off the game's own HUD.
|
|
171
173
|
const joinCorner = corner('join', 'bottom-right');
|
|
172
174
|
const first = places.desk;
|
|
@@ -189,7 +191,7 @@ ${SERVER_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${pillUi}`;
|
|
|
189
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>
|
|
190
192
|
</div>
|
|
191
193
|
<span class="link" data-room-link></span>
|
|
192
|
-
<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 ? `
|
|
193
195
|
<div class="who-row" data-who hidden><span data-who-name></span><button type="button" data-who-act></button></div>` : ''}
|
|
194
196
|
</div>
|
|
195
197
|
</div>
|
|
@@ -202,8 +204,11 @@ ${SERVER_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${pillUi}`;
|
|
|
202
204
|
<div class="card" data-screen hidden></div>
|
|
203
205
|
${joinCard}${share}
|
|
204
206
|
<script>window.__HOMIE_PLAY=${JSON.stringify(boot).replace(/</g, '\\u003c')};</script>
|
|
207
|
+
<script>window.__HOMIE_CHAT=${JSON.stringify(chatBoot(g, { surface: screen ? 'tv' : 'play', owner, acct, member })).replace(/</g, '\\u003c')};</script>
|
|
208
|
+
<script>${CHAT_JS}</script>
|
|
205
209
|
<script>${SHELL_JS}</script>${g.saves && !screen ? `
|
|
206
|
-
<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));
|
|
207
212
|
}
|
|
208
213
|
|
|
209
214
|
/*
|
|
@@ -399,6 +404,8 @@ const SHELL_JS = String.raw`(function () {
|
|
|
399
404
|
if (params.get('debug') === '1') q.set('debug', '1');
|
|
400
405
|
// Quiet AI (this browser hides AI speech): the helper starts hushed, and hears a change by message.
|
|
401
406
|
if (state.quietAi) q.set('hush', '1');
|
|
407
|
+
// Room chat (section 19): this browser's "Show chat" and "Show my messages over my character", for the game's bubbles.
|
|
408
|
+
try { if (localStorage.getItem('homie-chat-show') === '0') q.set('chat', '0'); if (localStorage.getItem('homie-chat-bubble') === '0') q.set('bub', '0'); } catch (e) {}
|
|
402
409
|
// The frame cannot read this page's address (it is an opaque origin): hand it the game's own switches.
|
|
403
410
|
['touchdebug', 'cam', 'view'].forEach(function (k) { var v = params.get(k); if (v && /^[A-Za-z0-9_-]{1,16}$/.test(v)) q.set(k, v); });
|
|
404
411
|
frame.src = '/' + boot.game + '/__game/?' + q.toString();
|
|
@@ -409,7 +416,7 @@ const SHELL_JS = String.raw`(function () {
|
|
|
409
416
|
var m = ev.data;
|
|
410
417
|
if (!m || typeof m !== 'object' || m.t !== 'homie-net') return;
|
|
411
418
|
if (m.what === 'attached') state.attached = true;
|
|
412
|
-
if (m.what === 'token' && typeof m.token === 'string') { state.seat = m.seat; try { sessionStorage.setItem(KEY, m.token); } catch (e) {} }
|
|
419
|
+
if (m.what === 'token' && typeof m.token === 'string') { state.seat = m.seat; try { sessionStorage.setItem(KEY, m.token); } catch (e) {} if (window.__homieChat) window.__homieChat.seat(); }
|
|
413
420
|
if (m.what === 'stats') state.stats = m.stats;
|
|
414
421
|
if (m.what === 'round') onRound(m.round);
|
|
415
422
|
if (m.what === 'roster') state.roster = m.slots;
|
|
@@ -455,6 +462,8 @@ const SHELL_JS = String.raw`(function () {
|
|
|
455
462
|
try { ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/' + boot.game + '/__watch?room=' + encodeURIComponent(room) + extra); }
|
|
456
463
|
catch (e) { setTimeout(function () { watch(room); }, 2000); return; }
|
|
457
464
|
state.watchSocket = ws;
|
|
465
|
+
// Room chat (section 19) speaks on this socket: lines, reactions and what the room keeps of the last minutes.
|
|
466
|
+
if (window.__homieChat) window.__homieChat.socket(ws, room);
|
|
458
467
|
ws.onmessage = function (ev) {
|
|
459
468
|
var m; try { m = JSON.parse(ev.data); } catch (e) { return; }
|
|
460
469
|
if (!m || typeof m !== 'object') return;
|
|
@@ -463,7 +472,9 @@ const SHELL_JS = String.raw`(function () {
|
|
|
463
472
|
if (m.t === 'announce') { banner(m); return; }
|
|
464
473
|
if (m.t === 'muted') { mutedNote(m); return; }
|
|
465
474
|
if (m.t === 'vote') { onVote(m); return; }
|
|
475
|
+
if (window.__homieChat && window.__homieChat.receive(m)) return;
|
|
466
476
|
if (m.t !== 'net') return;
|
|
477
|
+
if (window.__homieChat) window.__homieChat.facts(m);
|
|
467
478
|
state.facts = m;
|
|
468
479
|
if (typeof m.st === 'number') state.offset = m.st - Date.now();
|
|
469
480
|
if (m.announce && m.announce.text) banner(m.announce, true);
|
|
@@ -834,11 +845,11 @@ const OWNER_JS = String.raw`(function () {
|
|
|
834
845
|
* watchers see the whole room only. A private or invite-only game is watched only by whoever may play it (the door
|
|
835
846
|
* is the play door's). Every name a player typed is set with textContent.
|
|
836
847
|
*/
|
|
837
|
-
export function watchPage(cat, g, { room = null, ticket = null, policy = 'follow' } = {}) {
|
|
848
|
+
export function watchPage(cat, g, { room = null, ticket = null, policy = 'follow', owner = false, acct = false, member = false } = {}) {
|
|
838
849
|
const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
|
|
839
850
|
const hot = /^#[0-9a-f]{3,8}$/i.test(accent) ? accent : '#ffcf5a';
|
|
840
851
|
const boot = { game: g.id, name: g.name, policy, palette: NET_PALETTE, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}) };
|
|
841
|
-
const css = `:root{--hot:${hot}}${WATCH_CSS}`;
|
|
852
|
+
const css = `:root{--hot:${hot}}${WATCH_CSS}${CHAT_CSS}`;
|
|
842
853
|
return layoutless(`Watch ${g.name}`, `
|
|
843
854
|
<header class="wtop" data-top>
|
|
844
855
|
<span class="live" data-live><i aria-hidden="true"></i><b>Live</b></span>
|
|
@@ -855,6 +866,8 @@ export function watchPage(cat, g, { room = null, ticket = null, policy = 'follow
|
|
|
855
866
|
</footer>
|
|
856
867
|
<div class="wnote" data-note hidden></div>
|
|
857
868
|
<script>window.__HOMIE_WATCH=${JSON.stringify(boot).replace(/</g, '\\u003c')};</script>
|
|
869
|
+
<script>window.__HOMIE_CHAT=${JSON.stringify(chatBoot(g, { surface: 'watch', owner, acct, member })).replace(/</g, '\\u003c')};</script>
|
|
870
|
+
<script>${CHAT_JS}</script>${owner ? `<script>${CHAT_OWNER_JS}</script>` : ''}
|
|
858
871
|
<script>${WATCH_JS}</script>`, css, frameAncestors(cat));
|
|
859
872
|
}
|
|
860
873
|
|
|
@@ -1055,12 +1068,16 @@ const WATCH_JS = String.raw`(function () {
|
|
|
1055
1068
|
try { ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/' + boot.game + '/__watch?room=' + encodeURIComponent(room) + extra); }
|
|
1056
1069
|
catch (e) { setTimeout(function () { watch(room); }, 2000); return; }
|
|
1057
1070
|
state.socket = ws;
|
|
1071
|
+
// Room chat (section 19): a watcher reads the room's chat and sends what the room's rules let a watcher send.
|
|
1072
|
+
if (window.__homieChat) window.__homieChat.socket(ws, room);
|
|
1058
1073
|
ws.onmessage = function (ev) {
|
|
1059
1074
|
var m; try { m = JSON.parse(ev.data); } catch (e) { return; }
|
|
1060
1075
|
if (!m || typeof m !== 'object') return;
|
|
1061
1076
|
if (m.t === 'kicked') { shut('kicked', m); return; }
|
|
1062
1077
|
if (m.t === 'closed') { shut('closed', m); return; }
|
|
1078
|
+
if (window.__homieChat && window.__homieChat.receive(m)) return;
|
|
1063
1079
|
if (m.t !== 'net') return;
|
|
1080
|
+
if (window.__homieChat) window.__homieChat.facts(m);
|
|
1064
1081
|
state.facts = m;
|
|
1065
1082
|
if (typeof m.st === 'number') state.offset = m.st - Date.now();
|
|
1066
1083
|
if (m.round) state.round = m.round;
|
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
|
}
|