@homie-rocks/studio 0.21.0 → 0.23.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 +68 -1
- package/README.md +92 -0
- package/assets/assets.ts +500 -0
- package/assets/render-page.ts +629 -0
- package/assets/safety.mjs +194 -0
- package/bin/homie-studio.mjs +81 -2
- package/chat/CHAT.md +101 -0
- package/chat/OWNERS.md +78 -0
- package/lib/art-cli.mjs +296 -0
- package/lib/art-tools.mjs +331 -0
- package/lib/asset-check.mjs +189 -0
- package/lib/asset-import.mjs +136 -0
- package/lib/asset-manifest.mjs +318 -0
- package/lib/build.mjs +32 -2
- package/lib/chat-cli.mjs +89 -0
- package/lib/cloudflare.mjs +5 -2
- package/lib/codex.mjs +43 -1
- package/lib/colour.mjs +160 -0
- package/lib/decisions.mjs +800 -0
- package/lib/directory.mjs +9 -1
- package/lib/library.mjs +169 -0
- package/lib/mcp-tools.mjs +22 -4
- package/lib/mcp.mjs +6 -3
- package/lib/optimise.mjs +380 -0
- package/lib/remix-assets.mjs +84 -0
- package/lib/render3d.mjs +81 -0
- package/lib/scaffold.mjs +39 -2
- package/lib/site.mjs +6 -1
- package/lib/studio.mjs +136 -9
- package/lib/style-board.mjs +227 -0
- package/lib/style-presets.mjs +121 -0
- package/lib/template-history.json +76 -0
- package/mcp/ui/card.css +15 -0
- package/mcp/ui/cast.js +62 -0
- package/mcp/ui/decision.js +61 -0
- package/mcp/ui/lineup.js +68 -0
- package/mcp/ui/rights.js +30 -0
- package/mcp/ui/style.js +154 -0
- package/netplay/NETPLAY.md +191 -3
- package/netplay/netplay.ts +150 -3
- package/package.json +15 -2
- package/port/index.ts +3 -2
- package/port/view.ts +238 -0
- package/site/SITE.md +11 -1
- package/starters/ember-vale/src/main.ts +22 -2
- package/starters/gem-rush/src/main.ts +21 -1
- package/starters/gem-rush-3d/assets/RIGHTS.md +203 -0
- package/starters/gem-rush-3d/assets/manifest.json +2318 -0
- package/starters/gem-rush-3d/credits.json +31 -0
- package/starters/gem-rush-3d/game.json +11 -0
- package/starters/gem-rush-3d/index.html +20 -0
- package/starters/gem-rush-3d/lab.json +19 -0
- package/starters/gem-rush-3d/src/main.ts +2107 -0
- package/starters/gem-rush-3d/style.json +66 -0
- package/starters/gem-rush-3d/tunables.json +13 -0
- 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 +153 -8
- package/worker/office-page.mjs +115 -0
- package/worker/office.mjs +137 -6
- package/worker/pages.mjs +32 -9
- package/worker/qr.mjs +11 -0
- package/worker/room.mjs +288 -10
- package/worker/servers.mjs +5 -1
- package/worker/site.mjs +9 -4
- package/worker/stats.mjs +4 -1
- package/worker/version.mjs +1 -1
package/worker/office.mjs
CHANGED
|
@@ -42,6 +42,13 @@
|
|
|
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.
|
|
45
52
|
*/
|
|
46
53
|
import { SEAT_MAX, seatsOf } from './seats.mjs';
|
|
47
54
|
import { PUBLIC_SERVER, SERVER_LIMITS, checkServer, levelName, narrows, policyOf, roomServer, rowFor, serverView, serversOf, serverPassCookie, writeServer } from './servers.mjs';
|
|
@@ -51,6 +58,8 @@ import { OWNER_COOKIE, cookieValues, ownerAllowed, ownerSession, today } from '.
|
|
|
51
58
|
import { esc, layout, notFoundPage } from './site.mjs';
|
|
52
59
|
import { confirmPage, lockedPage, officePage } from './office-page.mjs';
|
|
53
60
|
import { licenseOf } from './license.mjs';
|
|
61
|
+
import { CHAT_MODES, CHAT_WHO, checkChatRules, publicChat } from './chat.mjs';
|
|
62
|
+
import { chatDay, chatOf, chatRowsOf, clearChatRules, dismissReport, reportsOf, writeChatRules } from './chat-store.mjs';
|
|
54
63
|
|
|
55
64
|
export { OFFICE_MIGRATION, OFFICE_MIGRATION_FILE } from './office-schema.mjs';
|
|
56
65
|
|
|
@@ -456,6 +465,8 @@ async function roomRow(env, meta, room, max) {
|
|
|
456
465
|
host: f.host ?? null, announce: f.announce ?? null,
|
|
457
466
|
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
467
|
clients, bans: office.bans ?? [], mutes: office.mutes ?? [],
|
|
468
|
+
// Room chat (0.23.0): the room's last minutes, with who sent each line (a client id and account; never an address).
|
|
469
|
+
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
470
|
// The house guides' brains (0.17.0): which brain, why not when it is not, and the last decisions (seats and ids).
|
|
460
471
|
...(f.brains && typeof f.brains === 'object' ? { brains: {
|
|
461
472
|
brain: String(f.brains.brain ?? ''), why: f.brains.why ? oneLine(f.brains.why, 120) : null, calls: Number(f.brains.calls) || 0,
|
|
@@ -517,14 +528,23 @@ export async function officeView(env, cat, origin) {
|
|
|
517
528
|
const row = settings.get(g.id);
|
|
518
529
|
const max = seatsFor(g, settings);
|
|
519
530
|
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);
|
|
531
|
+
.filter((r) => r.players + r.screens > 0 || r.closedUntil || r.bans.length || (r.chat && r.chat.lines.length));
|
|
521
532
|
rooms.sort((a, b) => b.players - a.players || a.room.localeCompare(b.room));
|
|
522
533
|
const servers = await serversOf(env, g, { fresh: true });
|
|
523
534
|
const byServer = {};
|
|
524
535
|
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
536
|
const members = await memberCountsOf(env, g.id);
|
|
537
|
+
const chatRows = await chatRowsOf(env, { fresh: true });
|
|
526
538
|
return {
|
|
527
539
|
id: g.id, name: g.name, launch, launchFrom: row?.launch ? 'office' : g.launch ? 'game.json' : 'default',
|
|
540
|
+
// Room chat (0.23.0): the game's rules as Quick play has them, where each came from, and each server's own.
|
|
541
|
+
chat: {
|
|
542
|
+
rules: publicChat(chatOf(g, servers.find((x) => x.id === 'public') ?? null, chatRows)),
|
|
543
|
+
from: chatRows.get(`${g.id}/`) ? 'office' : g.chat !== undefined ? 'game.json' : 'default',
|
|
544
|
+
office: chatRows.get(`${g.id}/`) ?? null,
|
|
545
|
+
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 })),
|
|
546
|
+
reports: await reportsOf(env, { game: g.id }),
|
|
547
|
+
},
|
|
528
548
|
// 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
549
|
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
550
|
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 +565,7 @@ export async function officeView(env, cat, origin) {
|
|
|
545
565
|
fillSpot: { available: Boolean(fillSpot()), note: 'Let a fill-a-spot service seat AI (coming later)' },
|
|
546
566
|
agentsTalk: await talkConsented(env),
|
|
547
567
|
brain: await brainDay(env),
|
|
568
|
+
chat: await chatDay(env, today()),
|
|
548
569
|
};
|
|
549
570
|
}
|
|
550
571
|
async function memberCountsOf(env, game) {
|
|
@@ -579,8 +600,10 @@ function checkAction(cat, op, body) {
|
|
|
579
600
|
if (!room) return bad('room is the room\'s code (pub-3, or a named room)');
|
|
580
601
|
const id = typeof body.id === 'string' && /^[A-Za-z0-9_-]{1,16}$/.test(body.id) ? body.id : null;
|
|
581
602
|
const seat = Number.isInteger(body.seat) && body.seat >= 0 && body.seat < SEAT_MAX ? body.seat : null;
|
|
582
|
-
|
|
583
|
-
|
|
603
|
+
// From a chat line (0.23.0): its sender, whoever they are now (a watcher too); `purge` takes their lines down.
|
|
604
|
+
const line = typeof body.line === 'string' && /^[A-Za-z0-9_-]{1,16}$/.test(body.line) ? body.line : null;
|
|
605
|
+
if (id === null && seat === null && line === null) return bad('name the player: id (from the office), seat, or line (a chat message of theirs)');
|
|
606
|
+
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
607
|
}
|
|
585
608
|
case 'close': {
|
|
586
609
|
if (!meta) return bad('game is one of this studio\'s game ids');
|
|
@@ -659,11 +682,56 @@ function checkAction(cat, op, body) {
|
|
|
659
682
|
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
683
|
return { ok: true, action: { op, game: meta.id, server: body.server, mode: body.mode, ...(budget !== undefined ? { budget } : {}) } };
|
|
661
684
|
}
|
|
685
|
+
case 'chat-rules': {
|
|
686
|
+
if (!meta) return bad('game is one of this studio\'s game ids');
|
|
687
|
+
const server = body.server === undefined || body.server === null || body.server === '' ? '' : String(body.server);
|
|
688
|
+
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');
|
|
689
|
+
if (body.reset === true) return { ok: true, action: { op, game: meta.id, server, reset: true } };
|
|
690
|
+
const { game: _g, server: _s, ...rest } = body;
|
|
691
|
+
const c = checkChatRules(rest);
|
|
692
|
+
if (!c.ok) return c;
|
|
693
|
+
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`);
|
|
694
|
+
return { ok: true, action: { op, game: meta.id, server, fields: c.fields } };
|
|
695
|
+
}
|
|
696
|
+
case 'chat-remove': {
|
|
697
|
+
if (!meta) return bad('game is one of this studio\'s game ids');
|
|
698
|
+
if (!room) return bad('room is the room\'s code');
|
|
699
|
+
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);
|
|
700
|
+
if (!ids.length && body.all !== true) return bad('id is the message\'s id (from the office), or all: true');
|
|
701
|
+
return { ok: true, action: { op, game: meta.id, room, ...(body.all === true ? { all: true } : { ids }) } };
|
|
702
|
+
}
|
|
703
|
+
case 'chat-report': {
|
|
704
|
+
if (!/^cr_[a-f0-9]{16}$/.test(String(body.id ?? ''))) return bad('id is the report\'s id');
|
|
705
|
+
return { ok: true, action: { op, id: body.id } };
|
|
706
|
+
}
|
|
707
|
+
case 'chat-budget': {
|
|
708
|
+
const n = Math.floor(Number(body.neurons));
|
|
709
|
+
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)');
|
|
710
|
+
return { ok: true, action: { op, neurons: n } };
|
|
711
|
+
}
|
|
662
712
|
default:
|
|
663
713
|
return bad('unknown control');
|
|
664
714
|
}
|
|
665
715
|
}
|
|
666
716
|
|
|
717
|
+
/** Whether a chat change opens chat up (an office key only ASKS for these): more allowed, fewer checks, wider doors. */
|
|
718
|
+
export function chatOpensUp(before, f) {
|
|
719
|
+
const rank = { off: 0, emoji: 1, lines: 2, text: 3 };
|
|
720
|
+
const who = { members: 0, 'signed-in': 1, anyone: 2 };
|
|
721
|
+
if (f.mode !== undefined && rank[f.mode] > rank[before.mode]) return true;
|
|
722
|
+
for (const k of ['who', 'react']) if (f[k] !== undefined && who[f[k]] > who[before[k]]) return true;
|
|
723
|
+
if (f.slow !== undefined && f.slow < before.slow) return true;
|
|
724
|
+
if (f.max !== undefined && f.max > before.max) return true;
|
|
725
|
+
if (f.links === 'allow' && before.links !== 'allow') return true;
|
|
726
|
+
if (f.swears === 'allow' && before.swears !== 'allow') return true;
|
|
727
|
+
for (const k of ['watchers', 'hub']) if (f[k] === true && !before[k]) return true;
|
|
728
|
+
if (f.ai === false && before.ai) return true;
|
|
729
|
+
if (f.allow !== undefined && f.allow.some((w) => !(before.allow ?? []).includes(w))) return true;
|
|
730
|
+
if (f.block !== undefined && (before.block ?? []).some((w) => !f.block.includes(w))) return true;
|
|
731
|
+
if (f.lines !== undefined || f.emoji !== undefined) return true;
|
|
732
|
+
return false;
|
|
733
|
+
}
|
|
734
|
+
|
|
667
735
|
/** The action in plain words, for the owner's confirm page and the AI's card. */
|
|
668
736
|
export function describe(cat, a) {
|
|
669
737
|
const g = (cat.games ?? []).find((x) => x.id === a.game);
|
|
@@ -705,6 +773,26 @@ export function describe(cat, a) {
|
|
|
705
773
|
case 'agents-brain': return ['workers-ai', 'owner-key'].includes(a.mode)
|
|
706
774
|
? `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
775
|
: `Set the AI guides' brain on ${gname}'s server ${a.server} to ${a.mode}.`;
|
|
776
|
+
case 'chat-rules': {
|
|
777
|
+
const where = a.server ? `${gname}'s server ${a.server}` : gname;
|
|
778
|
+
if (a.reset) return `Give ${where} its own chat rules back (the game's game.json, or Homie's defaults).`;
|
|
779
|
+
const f = a.fields;
|
|
780
|
+
const bits = [];
|
|
781
|
+
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]);
|
|
782
|
+
if (f.who) bits.push(`typing for ${f.who === 'anyone' ? 'anyone, guests too' : f.who === 'signed-in' ? 'signed-in players (a passkey)' : 'members only'}`);
|
|
783
|
+
if (f.react) bits.push(`emoji and quick lines for ${f.react === 'anyone' ? 'anyone' : f.react === 'signed-in' ? 'signed-in players' : 'members only'}`);
|
|
784
|
+
if (f.slow !== undefined) bits.push(f.slow ? `slow mode: one message every ${f.slow} s` : 'no slow mode');
|
|
785
|
+
if (f.max !== undefined) bits.push(`messages up to ${f.max} characters`);
|
|
786
|
+
if (f.links) bits.push(f.links === 'allow' ? 'allow links' : 'hold links');
|
|
787
|
+
if (f.swears) bits.push(f.swears === 'allow' ? 'allow swearing' : 'hold swearing');
|
|
788
|
+
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)');
|
|
789
|
+
const rest = Object.keys(f).filter((k) => !['mode', 'who', 'react', 'slow', 'max', 'links', 'swears', 'ai'].includes(k));
|
|
790
|
+
if (rest.length) bits.push(`change its ${rest.join(', ')}`);
|
|
791
|
+
return `Room chat in ${where}: ${bits.join('; ')}.`;
|
|
792
|
+
}
|
|
793
|
+
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}.`;
|
|
794
|
+
case 'chat-report': return 'Dismiss a chat report (it is deleted).';
|
|
795
|
+
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
796
|
default: return 'A control.';
|
|
709
797
|
}
|
|
710
798
|
}
|
|
@@ -719,6 +807,15 @@ export async function needsAsk(env, cat, a) {
|
|
|
719
807
|
const before = meta ? (await serversOf(env, meta, { fresh: true })).find((x) => x.id === a.server) : null;
|
|
720
808
|
return narrows(before, a.fields);
|
|
721
809
|
}
|
|
810
|
+
// A bigger review budget can cost money on Workers Paid: the owner says yes. A smaller one happens at once.
|
|
811
|
+
if (a.op === 'chat-budget') return a.neurons > (await chatDay(env, today())).budget.neurons;
|
|
812
|
+
if (a.op === 'chat-rules') {
|
|
813
|
+
if (a.reset) return true;
|
|
814
|
+
const meta = (cat.games ?? []).find((g) => g.id === a.game);
|
|
815
|
+
const servers = meta ? await serversOf(env, meta, { fresh: true }) : [];
|
|
816
|
+
const before = chatOf(meta, servers.find((x) => x.id === (a.server || 'public')) ?? null, await chatRowsOf(env, { fresh: true }));
|
|
817
|
+
return chatOpensUp(before, a.fields);
|
|
818
|
+
}
|
|
722
819
|
if (a.op === 'agents-brain') {
|
|
723
820
|
if (['workers-ai', 'owner-key'].includes(a.mode) && !(await talkConsented(env))) return true;
|
|
724
821
|
// 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 +832,7 @@ async function serverRooms(env, game, server) {
|
|
|
735
832
|
|
|
736
833
|
/** A server's policy reaches every live room of it now (a signed `policy` control; section 17). */
|
|
737
834
|
async function pushPolicy(env, meta, settings, server) {
|
|
738
|
-
const pol = policyOf(server, { seats: seatsFor(meta, settings) });
|
|
835
|
+
const pol = policyOf(server, { seats: seatsFor(meta, settings), chat: chatOf(meta, server, await chatRowsOf(env, { fresh: true })) });
|
|
739
836
|
const rooms = await serverRooms(env, meta.id, server.id);
|
|
740
837
|
const res = await Promise.all(rooms.map((r) => roomControl(env, meta, settings, r.room, 'policy', { pol })));
|
|
741
838
|
return { rooms: rooms.length, leaving: res.reduce((n, r) => n + (r.leaving ?? 0), 0) };
|
|
@@ -748,9 +845,10 @@ export async function perform(env, cat, a) {
|
|
|
748
845
|
switch (a.op) {
|
|
749
846
|
case 'kick':
|
|
750
847
|
case 'mute': {
|
|
848
|
+
const line = a.line ? { line: a.line, purge: a.purge === true } : {};
|
|
751
849
|
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 });
|
|
850
|
+
? { id: a.id, seat: a.seat, minutes: a.minutes, address: a.address, ...line, ...(a.message ? { message: a.message } : {}) }
|
|
851
|
+
: { id: a.id, seat: a.seat, minutes: a.minutes, off: a.off, ...line });
|
|
754
852
|
return r;
|
|
755
853
|
}
|
|
756
854
|
case 'close': return roomControl(env, meta, settings, a.room, 'close', a.reopen ? { reopen: true } : { minutes: a.minutes, ...(a.message ? { message: a.message } : {}) });
|
|
@@ -868,6 +966,32 @@ export async function perform(env, cat, a) {
|
|
|
868
966
|
} 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
967
|
}
|
|
870
968
|
case 'room-level': return roomControl(env, meta, settings, a.room, 'level', { level: a.level });
|
|
969
|
+
case 'chat-rules': {
|
|
970
|
+
if (!env?.DB) return { ok: false, error: 'no-db', message: 'This studio has no D1 for its office.' };
|
|
971
|
+
try {
|
|
972
|
+
if (a.reset) await clearChatRules(env, meta.id, a.server);
|
|
973
|
+
else await writeChatRules(env, meta.id, a.server, a.fields);
|
|
974
|
+
} 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)})` }; }
|
|
975
|
+
// Every live room the change reaches hears its new rules now (a signed policy control, as a server change does).
|
|
976
|
+
const servers = await serversOf(env, meta, { fresh: true });
|
|
977
|
+
const reach = a.server ? servers.filter((x) => x.id === a.server) : servers;
|
|
978
|
+
let rooms = 0;
|
|
979
|
+
for (const sv of reach) rooms += (await pushPolicy(env, meta, settings, sv)).rooms;
|
|
980
|
+
const rows = await chatRowsOf(env, { fresh: true });
|
|
981
|
+
const rules = publicChat(chatOf(meta, servers.find((x) => x.id === (a.server || 'public')) ?? PUBLIC_SERVER, rows));
|
|
982
|
+
return { ok: true, op: a.op, game: meta.id, server: a.server || null, rules, rooms };
|
|
983
|
+
}
|
|
984
|
+
case 'chat-remove': {
|
|
985
|
+
const r = await roomControl(env, meta, settings, a.room, 'unsay', a.all ? { all: true } : { ids: a.ids });
|
|
986
|
+
return r;
|
|
987
|
+
}
|
|
988
|
+
case 'chat-report': {
|
|
989
|
+
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) }; }
|
|
990
|
+
}
|
|
991
|
+
case 'chat-budget': {
|
|
992
|
+
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) }; }
|
|
993
|
+
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).` };
|
|
994
|
+
}
|
|
871
995
|
case 'agents-brain': {
|
|
872
996
|
const before = (await serversOf(env, meta, { fresh: true })).find((x) => x.id === a.server);
|
|
873
997
|
if (!before) return { ok: false, error: 'no-server', message: `${meta.name} has no server called ${a.server}.` };
|
|
@@ -1025,7 +1149,14 @@ async function api(request, env, url, cat) {
|
|
|
1025
1149
|
return json({ ok: true, game, server, members });
|
|
1026
1150
|
} 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
1151
|
}
|
|
1152
|
+
// Room chat (0.23.0): every game's rules, every live room's last minutes, the reports, the review's day.
|
|
1153
|
+
if (path === '/_studio/api/chat' && request.method === 'GET') {
|
|
1154
|
+
const metas = (cat.games ?? []).filter((g) => !url.searchParams.get('game') || g.id === url.searchParams.get('game'));
|
|
1155
|
+
const view = await officeView(env, { ...cat, games: metas }, url.origin);
|
|
1156
|
+
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
|
+
}
|
|
1028
1158
|
const OPS = {
|
|
1159
|
+
'/_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
1160
|
'/_studio/api/kick': 'kick', '/_studio/api/mute': 'mute', '/_studio/api/close': 'close', '/_studio/api/announce': 'announce', '/_studio/api/game': 'game',
|
|
1030
1161
|
'/_studio/api/servers': 'server-create', '/_studio/api/servers/set': 'server-set', '/_studio/api/servers/close': 'server-close', '/_studio/api/servers/member': 'member',
|
|
1031
1162
|
'/_studio/api/agents/pass': 'pass', '/_studio/api/room-level': 'room-level', '/_studio/api/agents/brain': 'agents-brain',
|
package/worker/pages.mjs
CHANGED
|
@@ -7,6 +7,7 @@ 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';
|
|
10
11
|
|
|
11
12
|
export { homePage, mediaIndexPage, notFoundPage, songPage, videoPage } from './site.mjs';
|
|
12
13
|
|
|
@@ -84,12 +85,16 @@ export function sharePlaces(value) {
|
|
|
84
85
|
* the edge (game.json `screen.share`, per device: sharePlaces) opens Invite, Big screen and the room code; nothing
|
|
85
86
|
* covers the middle of the screen or a thumb.
|
|
86
87
|
*/
|
|
87
|
-
export function playPage(cat, g, { screen = false, joinUrl = null, qr = null, room = null, ticket = null, owner = false, launch = 'public', server = null } = {}) {
|
|
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 } = {}) {
|
|
88
89
|
const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
|
|
89
90
|
const corner = (name, fallback) => (['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(g.screen?.[name]) ? g.screen[name] : fallback);
|
|
90
91
|
const places = sharePlaces(g.screen?.share);
|
|
91
92
|
// The phone's glass belongs to the game: no page pan, pinch-zoom, text selection or callout under a thumb
|
|
92
93
|
// (a pinch between a stick thumb and a button thumb made the browser cancel both touches).
|
|
94
|
+
// A game with an art direction (games.json `ui`, from its style.json): the pills wear its paper and text, like its HUD.
|
|
95
|
+
const okHex = (v) => /^#[0-9a-f]{6}$/i.test(String(v ?? ''));
|
|
96
|
+
const ui = okHex(g.ui?.paper) && okHex(g.ui?.text) ? g.ui : null;
|
|
97
|
+
const pillUi = ui ? `\n.pill, .chip, .pill.dim { background: ${ui.paper}e6; color: ${ui.text}; border-color: ${ui.text}22; }\n.pill svg { color: ${ui.text}; }` : '';
|
|
93
98
|
const css = `:root{--hot:${/^#[0-9a-f]{3,8}$/i.test(accent) ? accent : '#ffcf5a'}}
|
|
94
99
|
html, body { height: 100%; margin: 0; overflow: hidden; overscroll-behavior: none; background: #04060c; touch-action: none; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent; }
|
|
95
100
|
iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #04060c; touch-action: none; }
|
|
@@ -155,7 +160,7 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
|
|
|
155
160
|
.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); }
|
|
156
161
|
.notice .acts a.primary { background: var(--hot); color: #0b0b10; border-color: transparent; }
|
|
157
162
|
[hidden] { display: none !important; }
|
|
158
|
-
${SERVER_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}`;
|
|
163
|
+
${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${pillUi}`;
|
|
159
164
|
// The room button's place on each device (sharePlaces); the shell moves it to this browser's once it knows the device.
|
|
160
165
|
// A ticket (a game that is not public) and the owner's overlay ride along only for the browser they are for.
|
|
161
166
|
// The server this page plays on (0.16.0): its pool (the Lobby), its badge and line for the chip, its ceiling.
|
|
@@ -166,7 +171,9 @@ ${SERVER_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}`;
|
|
|
166
171
|
// game.json "screen": { "join": "top-left" | "top-right" | "bottom-left" | "bottom-right" } keeps the card off the game's own HUD.
|
|
167
172
|
const joinCorner = corner('join', 'bottom-right');
|
|
168
173
|
const first = places.desk;
|
|
169
|
-
|
|
174
|
+
// Local development: no join card at all on the big screen (no phone can open this computer's address); the room
|
|
175
|
+
// button's sheet says to deploy to share, for whoever looks for a link.
|
|
176
|
+
const joinCard = screen && joinUrl && !local ? `<div class="join join-${joinCorner}" data-join>${qr ? `<div class="qr">${qr}</div>` : ''}<div><b>Scan to play</b><span>${esc(joinUrl.replace(/^https?:\/\//, ''))}</span></div></div>` : '';
|
|
170
177
|
const share = screen ? '' : `<div class="room at-${first.at}" style="--dx:${first.x}px;--dy:${first.y}px" data-room-ui>
|
|
171
178
|
<div class="pills"><button class="pill${first.label ? '' : ' icon'}" type="button" data-share-toggle aria-expanded="false" aria-controls="share-sheet" aria-label="Room, invite and big screen"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5.5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="18.5" r="2.5"/><path d="m8.2 10.8 7.6-4.1M8.2 13.2l7.6 4.1"/></svg><span data-room-code>Room</span></button>
|
|
172
179
|
<button class="pill spill" type="button" data-server-toggle aria-expanded="false" aria-controls="server-sheet" hidden><span class="sdot" aria-hidden="true"></span><span data-server-label></span></button></div>
|
|
@@ -196,8 +203,10 @@ ${SERVER_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}`;
|
|
|
196
203
|
<div class="card" data-screen hidden></div>
|
|
197
204
|
${joinCard}${share}
|
|
198
205
|
<script>window.__HOMIE_PLAY=${JSON.stringify(boot).replace(/</g, '\\u003c')};</script>
|
|
206
|
+
<script>window.__HOMIE_CHAT=${JSON.stringify(chatBoot(g, { surface: screen ? 'tv' : 'play', owner, acct, member })).replace(/</g, '\\u003c')};</script>
|
|
207
|
+
<script>${CHAT_JS}</script>
|
|
199
208
|
<script>${SHELL_JS}</script>${g.saves && !screen ? `
|
|
200
|
-
<script>${SAVES_SHELL_JS}</script>` : ''}${owner ? `<style>${OWNER_CSS}</style><script>${OWNER_JS}</script>` : ''}`, css, frameAncestors(cat));
|
|
209
|
+
<script>${SAVES_SHELL_JS}</script>` : ''}${owner ? `<style>${OWNER_CSS}</style><script>${OWNER_JS}</script><script>${CHAT_OWNER_JS}</script>` : ''}`, css, frameAncestors(cat));
|
|
201
210
|
}
|
|
202
211
|
|
|
203
212
|
/*
|
|
@@ -267,6 +276,8 @@ const SHELL_JS = String.raw`(function () {
|
|
|
267
276
|
var boot = window.__HOMIE_PLAY;
|
|
268
277
|
var params = new URLSearchParams(location.search);
|
|
269
278
|
var screenMode = params.get('screen') === '1' || boot.screen === true;
|
|
279
|
+
// Local development (this computer's own address): never hand a phone a link it cannot open.
|
|
280
|
+
var LOCAL = /^(localhost|.*\.localhost|127\..*|\[::1\]|0\.0\.0\.0)$/.test(location.hostname);
|
|
270
281
|
var asked = params.get('hand');
|
|
271
282
|
var device = asked === 'phone' || asked === 'desk' || asked === 'tv' ? asked : Math.min(innerWidth, innerHeight) <= 540 ? 'phone' : 'desk';
|
|
272
283
|
var want = screenMode ? 'screen' : 'play';
|
|
@@ -352,7 +363,7 @@ const SHELL_JS = String.raw`(function () {
|
|
|
352
363
|
state.link = link;
|
|
353
364
|
ui.querySelector('[data-room-code]').textContent = labelOf(room);
|
|
354
365
|
ui.querySelector('[data-room-label]').textContent = labelOf(room);
|
|
355
|
-
ui.querySelector('[data-room-link]').textContent = link.replace(/^https?:\/\//, '');
|
|
366
|
+
ui.querySelector('[data-room-link]').textContent = LOCAL ? 'Deploy to share: this preview address works on this computer only.' : link.replace(/^https?:\/\//, '');
|
|
356
367
|
ui.querySelector('[data-bigscreen]').href = '/' + boot.game + '/tv?room=' + encodeURIComponent(room);
|
|
357
368
|
toggle.addEventListener('click', function (e) { e.stopPropagation(); open(sheet.hidden); });
|
|
358
369
|
ui.querySelector('[data-invite]').addEventListener('click', function () {
|
|
@@ -391,6 +402,8 @@ const SHELL_JS = String.raw`(function () {
|
|
|
391
402
|
if (params.get('debug') === '1') q.set('debug', '1');
|
|
392
403
|
// Quiet AI (this browser hides AI speech): the helper starts hushed, and hears a change by message.
|
|
393
404
|
if (state.quietAi) q.set('hush', '1');
|
|
405
|
+
// Room chat (section 19): this browser's "Show chat" and "Show my messages over my character", for the game's bubbles.
|
|
406
|
+
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) {}
|
|
394
407
|
// The frame cannot read this page's address (it is an opaque origin): hand it the game's own switches.
|
|
395
408
|
['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); });
|
|
396
409
|
frame.src = '/' + boot.game + '/__game/?' + q.toString();
|
|
@@ -401,7 +414,7 @@ const SHELL_JS = String.raw`(function () {
|
|
|
401
414
|
var m = ev.data;
|
|
402
415
|
if (!m || typeof m !== 'object' || m.t !== 'homie-net') return;
|
|
403
416
|
if (m.what === 'attached') state.attached = true;
|
|
404
|
-
if (m.what === 'token' && typeof m.token === 'string') { state.seat = m.seat; try { sessionStorage.setItem(KEY, m.token); } catch (e) {} }
|
|
417
|
+
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(); }
|
|
405
418
|
if (m.what === 'stats') state.stats = m.stats;
|
|
406
419
|
if (m.what === 'round') onRound(m.round);
|
|
407
420
|
if (m.what === 'roster') state.roster = m.slots;
|
|
@@ -412,7 +425,7 @@ const SHELL_JS = String.raw`(function () {
|
|
|
412
425
|
});
|
|
413
426
|
watch(room);
|
|
414
427
|
shareReady(room);
|
|
415
|
-
if (screenMode && !document.querySelector('[data-join]')) {
|
|
428
|
+
if (screenMode && !document.querySelector('[data-join]') && !LOCAL) {
|
|
416
429
|
var h2 = document.createElement('h2'); h2.textContent = 'Join on your phone';
|
|
417
430
|
var div = document.createElement('div'); div.textContent = location.origin + '/' + boot.game + '/play?room=' + encodeURIComponent(room);
|
|
418
431
|
screenCard.append(h2, div); screenCard.hidden = false;
|
|
@@ -447,6 +460,8 @@ const SHELL_JS = String.raw`(function () {
|
|
|
447
460
|
try { ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/' + boot.game + '/__watch?room=' + encodeURIComponent(room) + extra); }
|
|
448
461
|
catch (e) { setTimeout(function () { watch(room); }, 2000); return; }
|
|
449
462
|
state.watchSocket = ws;
|
|
463
|
+
// Room chat (section 19) speaks on this socket: lines, reactions and what the room keeps of the last minutes.
|
|
464
|
+
if (window.__homieChat) window.__homieChat.socket(ws, room);
|
|
450
465
|
ws.onmessage = function (ev) {
|
|
451
466
|
var m; try { m = JSON.parse(ev.data); } catch (e) { return; }
|
|
452
467
|
if (!m || typeof m !== 'object') return;
|
|
@@ -455,7 +470,9 @@ const SHELL_JS = String.raw`(function () {
|
|
|
455
470
|
if (m.t === 'announce') { banner(m); return; }
|
|
456
471
|
if (m.t === 'muted') { mutedNote(m); return; }
|
|
457
472
|
if (m.t === 'vote') { onVote(m); return; }
|
|
473
|
+
if (window.__homieChat && window.__homieChat.receive(m)) return;
|
|
458
474
|
if (m.t !== 'net') return;
|
|
475
|
+
if (window.__homieChat) window.__homieChat.facts(m);
|
|
459
476
|
state.facts = m;
|
|
460
477
|
if (typeof m.st === 'number') state.offset = m.st - Date.now();
|
|
461
478
|
if (m.announce && m.announce.text) banner(m.announce, true);
|
|
@@ -826,11 +843,11 @@ const OWNER_JS = String.raw`(function () {
|
|
|
826
843
|
* watchers see the whole room only. A private or invite-only game is watched only by whoever may play it (the door
|
|
827
844
|
* is the play door's). Every name a player typed is set with textContent.
|
|
828
845
|
*/
|
|
829
|
-
export function watchPage(cat, g, { room = null, ticket = null, policy = 'follow' } = {}) {
|
|
846
|
+
export function watchPage(cat, g, { room = null, ticket = null, policy = 'follow', owner = false, acct = false, member = false } = {}) {
|
|
830
847
|
const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
|
|
831
848
|
const hot = /^#[0-9a-f]{3,8}$/i.test(accent) ? accent : '#ffcf5a';
|
|
832
849
|
const boot = { game: g.id, name: g.name, policy, palette: NET_PALETTE, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}) };
|
|
833
|
-
const css = `:root{--hot:${hot}}${WATCH_CSS}`;
|
|
850
|
+
const css = `:root{--hot:${hot}}${WATCH_CSS}${CHAT_CSS}`;
|
|
834
851
|
return layoutless(`Watch ${g.name}`, `
|
|
835
852
|
<header class="wtop" data-top>
|
|
836
853
|
<span class="live" data-live><i aria-hidden="true"></i><b>Live</b></span>
|
|
@@ -847,6 +864,8 @@ export function watchPage(cat, g, { room = null, ticket = null, policy = 'follow
|
|
|
847
864
|
</footer>
|
|
848
865
|
<div class="wnote" data-note hidden></div>
|
|
849
866
|
<script>window.__HOMIE_WATCH=${JSON.stringify(boot).replace(/</g, '\\u003c')};</script>
|
|
867
|
+
<script>window.__HOMIE_CHAT=${JSON.stringify(chatBoot(g, { surface: 'watch', owner, acct, member })).replace(/</g, '\\u003c')};</script>
|
|
868
|
+
<script>${CHAT_JS}</script>${owner ? `<script>${CHAT_OWNER_JS}</script>` : ''}
|
|
850
869
|
<script>${WATCH_JS}</script>`, css, frameAncestors(cat));
|
|
851
870
|
}
|
|
852
871
|
|
|
@@ -1047,12 +1066,16 @@ const WATCH_JS = String.raw`(function () {
|
|
|
1047
1066
|
try { ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/' + boot.game + '/__watch?room=' + encodeURIComponent(room) + extra); }
|
|
1048
1067
|
catch (e) { setTimeout(function () { watch(room); }, 2000); return; }
|
|
1049
1068
|
state.socket = ws;
|
|
1069
|
+
// Room chat (section 19): a watcher reads the room's chat and sends what the room's rules let a watcher send.
|
|
1070
|
+
if (window.__homieChat) window.__homieChat.socket(ws, room);
|
|
1050
1071
|
ws.onmessage = function (ev) {
|
|
1051
1072
|
var m; try { m = JSON.parse(ev.data); } catch (e) { return; }
|
|
1052
1073
|
if (!m || typeof m !== 'object') return;
|
|
1053
1074
|
if (m.t === 'kicked') { shut('kicked', m); return; }
|
|
1054
1075
|
if (m.t === 'closed') { shut('closed', m); return; }
|
|
1076
|
+
if (window.__homieChat && window.__homieChat.receive(m)) return;
|
|
1055
1077
|
if (m.t !== 'net') return;
|
|
1078
|
+
if (window.__homieChat) window.__homieChat.facts(m);
|
|
1056
1079
|
state.facts = m;
|
|
1057
1080
|
if (typeof m.st === 'number') state.offset = m.st - Date.now();
|
|
1058
1081
|
if (m.round) state.round = m.round;
|
package/worker/qr.mjs
CHANGED
|
@@ -489,6 +489,17 @@ export function encodeQr(text, { minLevel = 'M' } = {}) {
|
|
|
489
489
|
|
|
490
490
|
|
|
491
491
|
/** The symbol as an SVG string: one path, dark modules on a light square with the quiet zone. */
|
|
492
|
+
/**
|
|
493
|
+
* An address only this computer can open (local development: localhost, 127.x, ::1, 0.0.0.0). A page never puts one in
|
|
494
|
+
* a QR code or tells a phone to open it: a phone cannot reach it. It says "deploy to share" instead.
|
|
495
|
+
*/
|
|
496
|
+
export function isLocalOrigin(origin) {
|
|
497
|
+
let host = '';
|
|
498
|
+
try { host = new URL(String(origin)).hostname; } catch { return false; }
|
|
499
|
+
host = host.replace(/^\[|\]$/g, '');
|
|
500
|
+
return host === 'localhost' || host.endsWith('.localhost') || /^127\./.test(host) || host === '::1' || host === '0.0.0.0';
|
|
501
|
+
}
|
|
502
|
+
|
|
492
503
|
export function qrSvg(text, { minLevel = 'M', quiet = 4, dark = '#000000', light = '#ffffff', title = '' } = {}) {
|
|
493
504
|
const q = encodeQr(text, { minLevel });
|
|
494
505
|
const total = q.size + quiet * 2;
|