@stage5/lumine 0.2.89 → 0.2.91

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/lib/admin.js CHANGED
@@ -138,6 +138,8 @@ function readBuildReviewContextFile(filePath) {
138
138
 
139
139
  const MAX_REWARD_CONFIG_FILE_BYTES = 256 * 1024;
140
140
  const REWARD_REVIEW_STATUSES = ["pending", "approved", "all"];
141
+ const CHAT_REPORT_STATUSES = ["open", "reviewing", "resolved", "dismissed"];
142
+ const CHAT_REPORT_LIST_STATUSES = ["pending", ...CHAT_REPORT_STATUSES, "all"];
141
143
  const REWARD_REVIEW_DECISIONS = ["approve", "reject", "revoke"];
142
144
  const REWARD_PROPOSAL_MAX_FILES = 500;
143
145
  const REWARD_PROPOSAL_MAX_BYTES = 5 * 1024 * 1024;
@@ -648,6 +650,29 @@ export async function adminCommand(options) {
648
650
  },
649
651
  };
650
652
  }
653
+ if (operation.name === "chat-reports.export" && operation.evidenceDir) {
654
+ // The package goes to disk only; the printed result keeps paths, sizes
655
+ // and hashes, never the evidence itself.
656
+ const written = writeEvidencePackage({
657
+ directory: operation.evidenceDir,
658
+ files: result?.data?.files,
659
+ });
660
+ const { files: _files, manifest: _manifest, ...summary } = result?.data || {};
661
+ if (
662
+ summary.manifestSha256 &&
663
+ summary.manifestSha256 !== written.manifestSha256
664
+ ) {
665
+ const error = new Error(
666
+ "The written manifest.json does not match the hash the server recorded. Export again into a new directory.",
667
+ );
668
+ error.code = "LUMINE_ADMIN_EVIDENCE_HASH_MISMATCH";
669
+ throw error;
670
+ }
671
+ result = {
672
+ ...result,
673
+ data: { ...summary, evidenceDirectory: written.directory, files: written.files },
674
+ };
675
+ }
651
676
  if (operation.name === "news.claim") {
652
677
  const artifacts = writeNewsClaimArtifacts({
653
678
  result,
@@ -1295,6 +1320,86 @@ function noActiveRunError() {
1295
1320
  return error;
1296
1321
  }
1297
1322
 
1323
+ function requireChatSafetyNote(value) {
1324
+ const note = String(value || "").trim();
1325
+ if (!note) {
1326
+ throw cliValidationError(
1327
+ "Say why with --note <text>; it is kept in the safety audit trail.",
1328
+ );
1329
+ }
1330
+ if (note.length > 2000) {
1331
+ throw cliValidationError("--note must be at most 2000 characters.");
1332
+ }
1333
+ return note;
1334
+ }
1335
+
1336
+ function parseIdList(value, label) {
1337
+ const ids = String(value || "")
1338
+ .split(",")
1339
+ .map((part) => part.trim())
1340
+ .filter(Boolean)
1341
+ .map((part) => parseRequiredInteger(part, label, 1));
1342
+ if (!ids.length) throw cliValidationError(`${label} needs at least one id.`);
1343
+ return Array.from(new Set(ids));
1344
+ }
1345
+
1346
+ // Writes a child-safety evidence package into a new or empty private
1347
+ // directory and checks every written file against the server's SHA-256
1348
+ // manifest, so what lands on disk is exactly what the server hashed.
1349
+ export function writeEvidencePackage({ directory, files }) {
1350
+ const list = Array.isArray(files) ? files : [];
1351
+ const manifestFile = list.find((file) => file?.path === "manifest.json");
1352
+ if (!manifestFile) {
1353
+ throw cliValidationError("The evidence package has no manifest.json.");
1354
+ }
1355
+ let manifest;
1356
+ try {
1357
+ manifest = JSON.parse(String(manifestFile.content || ""));
1358
+ } catch {
1359
+ throw cliValidationError("The evidence manifest is not valid JSON.");
1360
+ }
1361
+ let written;
1362
+ try {
1363
+ written = writeRewardReviewSnapshot({ directory, files: list });
1364
+ } catch (error) {
1365
+ error.message = String(error.message || "").replaceAll("--dir", "--out");
1366
+ throw error;
1367
+ }
1368
+ const sha = (text) => createHash("sha256").update(text).digest("hex");
1369
+ const mismatches = [];
1370
+ for (const entry of manifest.files || []) {
1371
+ const target = path.join(written.directory, entry.path);
1372
+ const actual = existsSync(target) ? sha(readFileSync(target)) : "missing";
1373
+ if (actual !== entry.sha256) mismatches.push(entry.path);
1374
+ }
1375
+ const listed = new Set((manifest.files || []).map((entry) => entry.path));
1376
+ for (const file of list) {
1377
+ if (file.path !== "manifest.json" && !listed.has(file.path)) {
1378
+ mismatches.push(file.path);
1379
+ }
1380
+ }
1381
+ if (mismatches.length) {
1382
+ const error = new Error(
1383
+ `The evidence package in ${written.directory} does not match its manifest (${mismatches.join(", ")}). Do not hand it over; export again into a new directory.`,
1384
+ );
1385
+ error.code = "LUMINE_ADMIN_EVIDENCE_HASH_MISMATCH";
1386
+ throw error;
1387
+ }
1388
+ return {
1389
+ directory: written.directory,
1390
+ manifestSha256: sha(readFileSync(path.join(written.directory, "manifest.json"))),
1391
+ files: written.files.map((file) => ({
1392
+ path: file.path.replace(/^\//, ""),
1393
+ bytes: file.bytes,
1394
+ sha256:
1395
+ file.path === "/manifest.json"
1396
+ ? null
1397
+ : (manifest.files || []).find((entry) => `/${entry.path}` === file.path)
1398
+ ?.sha256 || null,
1399
+ })),
1400
+ };
1401
+ }
1402
+
1298
1403
  export function parseAdminOperation(options) {
1299
1404
  const [namespace = "", action = "", target = "", extra = ""] =
1300
1405
  options.positional;
@@ -1594,6 +1699,159 @@ export function parseAdminOperation(options) {
1594
1699
  );
1595
1700
  }
1596
1701
 
1702
+ if (namespace === "chat-reports" || namespace === "chat-report") {
1703
+ // Members' in-chat message reports. Like reward reviews they are handled
1704
+ // any time, never inside a daily-run lease; recording an outcome only
1705
+ // annotates the report and never acts against a member.
1706
+ if (!action || action === "list") {
1707
+ return readOperation(
1708
+ "chat-reports.list",
1709
+ withQuery("/cli/admin/chat-reports", {
1710
+ status: parseChoice(
1711
+ options.adminStatus || "pending",
1712
+ "--status",
1713
+ CHAT_REPORT_LIST_STATUSES,
1714
+ ),
1715
+ beforeId: options.adminCursor
1716
+ ? parseRequiredInteger(options.adminCursor, "--cursor", 1)
1717
+ : "",
1718
+ limit: options.limit,
1719
+ }),
1720
+ { requiresRun: false },
1721
+ );
1722
+ }
1723
+ if (action === "show" || action === "get") {
1724
+ const reportId = parseRequiredInteger(target, "Chat report ID", 1);
1725
+ return readOperation(
1726
+ "chat-reports.show",
1727
+ `/cli/admin/chat-reports/${reportId}`,
1728
+ { requiresRun: false },
1729
+ );
1730
+ }
1731
+ if (action === "set") {
1732
+ const reportId = parseRequiredInteger(target, "Chat report ID", 1);
1733
+ const status = parseChoice(
1734
+ options.adminStatus,
1735
+ "--status",
1736
+ CHAT_REPORT_STATUSES,
1737
+ );
1738
+ const note = String(options.note || "").trim();
1739
+ if (!note) {
1740
+ throw cliValidationError(
1741
+ "Record what was decided or done with --note <text>.",
1742
+ );
1743
+ }
1744
+ if (note.length > 2000) {
1745
+ throw cliValidationError("--note must be at most 2000 characters.");
1746
+ }
1747
+ return writeOperation(
1748
+ "chat-reports.set",
1749
+ "PUT",
1750
+ `/cli/admin/chat-reports/${reportId}`,
1751
+ { status, note },
1752
+ { requiresRun: false, reportId },
1753
+ );
1754
+ }
1755
+ // Child-safety holds, evidence export and suspension (owner only; the
1756
+ // API admits only the owner's login). A hold keeps a protected copy of
1757
+ // anything the held conversation or members delete or edit.
1758
+ if (action === "hold") {
1759
+ const note = requireChatSafetyNote(options.note);
1760
+ const body = { note };
1761
+ if (target) body.reportId = parseRequiredInteger(target, "Chat report ID", 1);
1762
+ if (options.adminUser) {
1763
+ body.userIds = parseIdList(options.adminUser, "--user");
1764
+ }
1765
+ if (options.adminChannel) {
1766
+ body.channelId = parseRequiredInteger(options.adminChannel, "--channel", 1);
1767
+ }
1768
+ if (!body.reportId && !body.userIds && !body.channelId) {
1769
+ throw cliValidationError(
1770
+ "Usage: lumine admin chat-reports hold <report-id> | --user <id>[,<id>] [--channel <id>] | --channel <id> --note <why>.",
1771
+ );
1772
+ }
1773
+ return writeOperation(
1774
+ "chat-reports.hold",
1775
+ "POST",
1776
+ "/cli/admin/chat-reports/holds",
1777
+ body,
1778
+ { requiresRun: false },
1779
+ );
1780
+ }
1781
+ if (action === "release") {
1782
+ const holdId = parseRequiredInteger(
1783
+ target || options.adminHold,
1784
+ "Safety hold ID",
1785
+ 1,
1786
+ );
1787
+ return writeOperation(
1788
+ "chat-reports.release",
1789
+ "PUT",
1790
+ `/cli/admin/chat-reports/holds/${holdId}/release`,
1791
+ { note: requireChatSafetyNote(options.note) },
1792
+ { requiresRun: false, holdId },
1793
+ );
1794
+ }
1795
+ if (action === "list-holds" || action === "holds") {
1796
+ return readOperation(
1797
+ "chat-reports.list-holds",
1798
+ withQuery("/cli/admin/chat-reports/holds", {
1799
+ status: parseChoice(
1800
+ options.adminStatus || "active",
1801
+ "--status",
1802
+ ["active", "released", "all"],
1803
+ ),
1804
+ }),
1805
+ { requiresRun: false },
1806
+ );
1807
+ }
1808
+ if (action === "export") {
1809
+ const out = String(options.out || "").trim();
1810
+ if (!out) {
1811
+ throw cliValidationError(
1812
+ "Pass a new or empty directory with --out <dir> for the evidence package.",
1813
+ );
1814
+ }
1815
+ if (target && options.adminHold) {
1816
+ throw cliValidationError("Export either one report ID or one --hold <id>, not both.");
1817
+ }
1818
+ const body = target
1819
+ ? { reportId: parseRequiredInteger(target, "Chat report ID", 1) }
1820
+ : options.adminHold
1821
+ ? { holdId: parseRequiredInteger(options.adminHold, "--hold", 1) }
1822
+ : null;
1823
+ if (!body) {
1824
+ throw cliValidationError(
1825
+ "Usage: lumine admin chat-reports export <report-id> | --hold <hold-id> --out <dir>.",
1826
+ );
1827
+ }
1828
+ // A POST because the export is audited server-side (who, when, the
1829
+ // manifest hash); it changes no member data.
1830
+ return writeOperation(
1831
+ "chat-reports.export",
1832
+ "POST",
1833
+ "/cli/admin/chat-reports/export",
1834
+ body,
1835
+ { requiresRun: false, evidenceDir: out },
1836
+ );
1837
+ }
1838
+ if (action === "suspend") {
1839
+ return writeOperation(
1840
+ "chat-reports.suspend",
1841
+ "POST",
1842
+ "/cli/admin/chat-reports/suspend",
1843
+ {
1844
+ userId: parseRequiredInteger(options.adminUser || target, "--user", 1),
1845
+ note: requireChatSafetyNote(options.note),
1846
+ },
1847
+ { requiresRun: false },
1848
+ );
1849
+ }
1850
+ throw cliValidationError(
1851
+ "Usage: lumine admin chat-reports list [--status pending|open|reviewing|resolved|dismissed|all] [--cursor <id>] | show <id> | set <id> --status reviewing|resolved|dismissed|open --note <decision> | hold <report-id>|--user <id>|--channel <id> --note <why> | release <hold-id> --note <why> | list-holds [--status active|released|all] | export <report-id>|--hold <id> --out <dir> | suspend --user <id> --note <why>.",
1852
+ );
1853
+ }
1854
+
1597
1855
  if (namespace === "reward-activity" || namespace === "reward-telemetry") {
1598
1856
  // Read-only claim telemetry for the daily run: no run lease, no mutation.
1599
1857
  const date = options.adminDate
@@ -4037,8 +4295,53 @@ function printRewardActivity(data) {
4037
4295
  );
4038
4296
  }
4039
4297
 
4298
+ function describeSafetyHold(hold) {
4299
+ const covers = [
4300
+ ...(hold.channelIds || []).map((id) => `channel ${id}`),
4301
+ ...(hold.userIds || []).map((id) => `user ${id}`),
4302
+ ].join(", ");
4303
+ const placed = hold.placedAt
4304
+ ? new Date(hold.placedAt * 1000).toISOString()
4305
+ : "unknown";
4306
+ return `Hold #${hold.id} ${hold.status} (${hold.source}, placed ${placed}${hold.placedByUserId ? ` by user ${hold.placedByUserId}` : " automatically"}): ${covers || "nothing"}; reports ${(hold.reportIds || []).map((id) => `#${id}`).join(", ") || "none"}; ${hold.preservedRecords || 0} preserved cop${hold.preservedRecords === 1 ? "y" : "ies"}. ${hold.reason || ""}`;
4307
+ }
4308
+
4309
+ function printChatSafetyResult({ operation, data }) {
4310
+ if (operation.name === "chat-reports.hold" || operation.name === "chat-reports.release") {
4311
+ if (data.hold) console.log(describeSafetyHold(data.hold));
4312
+ return true;
4313
+ }
4314
+ if (operation.name === "chat-reports.list-holds") {
4315
+ const holds = data.holds || [];
4316
+ console.log(`${holds.length} ${data.filter || ""} safety hold(s).`);
4317
+ for (const hold of holds) console.log(` ${describeSafetyHold(hold)}`);
4318
+ return true;
4319
+ }
4320
+ if (operation.name === "chat-reports.export") {
4321
+ console.log(
4322
+ `Evidence package written to ${data.evidenceDirectory} (${(data.files || []).length} files, hashes verified).`,
4323
+ );
4324
+ console.log(`manifest.json SHA-256: ${data.manifestSha256}`);
4325
+ for (const file of data.files || []) {
4326
+ console.log(` ${file.path} ${file.bytes} bytes${file.sha256 ? ` ${file.sha256}` : ""}`);
4327
+ }
4328
+ console.log(
4329
+ "Keep this folder private. Give it only to police or child-protection staff handling the case, and write the manifest hash in your records.",
4330
+ );
4331
+ return true;
4332
+ }
4333
+ if (operation.name === "chat-reports.suspend") {
4334
+ console.log(
4335
+ `${data.user?.username || "user"} (user ${data.user?.id}) ${data.alreadySuspended ? "was already" : "is now"} suspended (full ban: signed out, cannot use the site).${(data.holdIds || []).length ? ` Safety hold(s): #${data.holdIds.join(", #")}.` : " No safety hold covers this account yet; place one with chat-reports hold."}`,
4336
+ );
4337
+ return true;
4338
+ }
4339
+ return false;
4340
+ }
4341
+
4040
4342
  function printAdminResult({ operation, result }) {
4041
4343
  const data = result?.data || {};
4344
+ if (printChatSafetyResult({ operation, data })) return;
4042
4345
  if (operation.name === "reward-activity.report") {
4043
4346
  printRewardActivity(data);
4044
4347
  return;
package/lib/chatlog.js ADDED
@@ -0,0 +1,108 @@
1
+ // lumine chatlog: what players said in a Build app's realtime world, for the
2
+ // app's owner (or an admin). Logging is off until switched on per app:
3
+ //
4
+ // lumine chatlog [build] [--since 2h] [--limit 100] [--instance <id>] [--json]
5
+ // lumine chatlog enable [build] (players see a notice while it is on)
6
+ // lumine chatlog disable [build]
7
+ import { requestJson } from "./http.js";
8
+ import { ensureAuth, assertAuthScope } from "./auth.js";
9
+ import { resolveRequiredBuildId } from "./util.js";
10
+ import { resolveRequiredBuildIdOrSelected } from "./commands.js";
11
+
12
+ const ACTIONS = new Set(["enable", "disable", "show"]);
13
+
14
+ async function resolveTarget(options, auth, rawTarget) {
15
+ if (rawTarget) {
16
+ const buildId = resolveRequiredBuildId(rawTarget);
17
+ if (!Number.isSafeInteger(buildId) || buildId <= 0) {
18
+ throw new Error("Pass a Twinkle build URL or positive integer build id.");
19
+ }
20
+ return buildId;
21
+ }
22
+ return await resolveRequiredBuildIdOrSelected(options, auth);
23
+ }
24
+
25
+ function formatTime(unixSeconds) {
26
+ const d = new Date(Number(unixSeconds) * 1000);
27
+ const pad = (n) => String(n).padStart(2, "0");
28
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
29
+ }
30
+
31
+ export async function chatlogCommand(options) {
32
+ const positional = options.positional || [];
33
+ const action = ACTIONS.has(String(positional[0] || ""))
34
+ ? String(positional[0])
35
+ : "show";
36
+ const rawTarget = String(
37
+ options.target && !ACTIONS.has(options.target)
38
+ ? options.target
39
+ : action === "show"
40
+ ? positional[0] || ""
41
+ : positional[1] || "",
42
+ ).trim();
43
+ const auth = await ensureAuth(options);
44
+ await assertAuthScope({
45
+ options,
46
+ auth,
47
+ scope: action === "show" ? "build:read" : "build:write",
48
+ });
49
+ const buildId = await resolveTarget(options, auth, rawTarget);
50
+
51
+ if (action !== "show") {
52
+ const enabled = action === "enable";
53
+ const result = await requestJson({
54
+ method: "PUT",
55
+ url: `${options.apiUrl}/build/${buildId}/chat-log-setting`,
56
+ authToken: auth.token,
57
+ body: { enabled },
58
+ timeoutMs: options.timeoutMs,
59
+ });
60
+ if (options.json) {
61
+ console.log(JSON.stringify(result, null, 2));
62
+ return;
63
+ }
64
+ console.log(
65
+ enabled
66
+ ? `Chat logging is ON for ${result.title} (#${result.buildId}). Lines are kept ${result.retentionDays} days, and players see a notice while it is on.`
67
+ : `Chat logging is OFF for ${result.title} (#${result.buildId}). Nothing new is stored; saved lines expire after ${result.retentionDays} days.`,
68
+ );
69
+ return;
70
+ }
71
+
72
+ const query = new URLSearchParams();
73
+ query.set("since", String(options.chatlogSince || "24h"));
74
+ const limit = Math.floor(Number(options.chatlogLimit || 100));
75
+ query.set("limit", String(Number.isFinite(limit) && limit > 0 ? limit : 100));
76
+ if (options.chatlogInstance) query.set("instance", options.chatlogInstance);
77
+ if (options.cursor) query.set("cursor", String(options.cursor));
78
+ const result = await requestJson({
79
+ method: "GET",
80
+ url: `${options.apiUrl}/build/${buildId}/chat-log?${query.toString()}`,
81
+ authToken: auth.token,
82
+ timeoutMs: options.timeoutMs,
83
+ });
84
+ if (options.json) {
85
+ console.log(JSON.stringify(result, null, 2));
86
+ return;
87
+ }
88
+ const messages = Array.isArray(result.messages) ? result.messages : [];
89
+ console.log(
90
+ `Chat log for ${result.title} (#${result.buildId}): logging ${result.enabled ? "ON" : "OFF"}, kept ${result.retentionDays} days.`,
91
+ );
92
+ if (!messages.length) {
93
+ console.log(
94
+ result.enabled
95
+ ? `No chat since ${query.get("since")}.`
96
+ : "Nothing logged. Turn it on with: lumine chatlog enable",
97
+ );
98
+ return;
99
+ }
100
+ // oldest first, like reading a conversation
101
+ for (const m of [...messages].reverse()) {
102
+ const who = m.username || (m.guest ? "Guest" : `user ${m.userId}`);
103
+ console.log(`${formatTime(m.createdAt)} [${m.instanceId || "-"}] ${who}: ${m.text}`);
104
+ }
105
+ if (result.nextCursor) {
106
+ console.log(`Older lines: lumine chatlog ${result.buildId} --since ${query.get("since")} --cursor ${result.nextCursor}`);
107
+ }
108
+ }
package/lib/commands.js CHANGED
@@ -58,6 +58,7 @@ import {
58
58
  } from "./assets.js";
59
59
  import { thumbnailCommand } from "./thumbnail.js";
60
60
  import { rewardsCommand, reportRewardDeclaration } from "./rewards.js";
61
+ import { chatlogCommand } from "./chatlog.js";
61
62
  import {
62
63
  assertAuthScope,
63
64
  ensureAuth,
@@ -275,6 +276,10 @@ export async function main() {
275
276
  await assetsCommand(options);
276
277
  return;
277
278
  }
279
+ if (options.command === "chatlog") {
280
+ await chatlogCommand(options);
281
+ return;
282
+ }
278
283
  if (options.command === "rewards") {
279
284
  await rewardsCommand(options);
280
285
  return;
@@ -2472,6 +2477,9 @@ export function parseArgs(args) {
2472
2477
  : ""),
2473
2478
  ).trim() || "",
2474
2479
  cursor: Math.max(0, Math.floor(Number(raw.cursor) || 0)),
2480
+ chatlogSince: raw.since ? String(raw.since) : "",
2481
+ chatlogLimit: raw.limit ? String(raw.limit) : "",
2482
+ chatlogInstance: raw.instance ? String(raw.instance) : "",
2475
2483
  adminCursor: raw.cursor ? String(raw.cursor) : "",
2476
2484
  adminAfter: raw.after ? String(raw.after) : "",
2477
2485
  adminPostedAfter: raw.postedAfter ? String(raw.postedAfter) : "",
@@ -2570,6 +2578,9 @@ export function parseArgs(args) {
2570
2578
  adminBucketId: raw.bucketId ? String(raw.bucketId) : "",
2571
2579
  adminLabel: raw.label ? String(raw.label) : "",
2572
2580
  adminUserIds: raw.userIds ? String(raw.userIds) : "",
2581
+ adminUser: raw.user ? String(raw.user) : "",
2582
+ adminChannel: raw.channel ? String(raw.channel) : "",
2583
+ adminHold: raw.hold ? String(raw.hold) : "",
2573
2584
  adminEmail: raw.email ? String(raw.email) : "",
2574
2585
  adminMode: raw.mode ? String(raw.mode) : "",
2575
2586
  adminType: raw.type ? String(raw.type) : "",
@@ -2939,9 +2950,12 @@ export function printHelp() {
2939
2950
  lumine assets generate "<prompt>" --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana>
2940
2951
  lumine assets delete <assetId>
2941
2952
  lumine assets prune [--yes]
2953
+ lumine chatlog [build] [--since 2h] [--limit 100] [--instance <id>] [--json]
2954
+ lumine chatlog enable|disable [build]
2942
2955
  lumine rewards check
2943
2956
  lumine rewards sheet <file.json>
2944
2957
  lumine rewards sheet --show
2958
+ lumine rewards review
2945
2959
  lumine thumbnail set <file>
2946
2960
  lumine thumbnail capture [--out <file>]
2947
2961
  lumine thumbnail generate ["<prompt>"] --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana>
@@ -2973,6 +2987,14 @@ export function printHelp() {
2973
2987
  lumine admin sponsor integrity review <case-id> --decision clear|hold|flag|disqualify [--note <evidence>] [--json]
2974
2988
  lumine admin reward-review list [--status pending|approved|all] [--cursor <id>] [--json]
2975
2989
  lumine admin reward-activity [--date YYYY-MM-DD] [--days <1..31>] [--build <id>] [--json]
2990
+ lumine admin chat-reports list [--status pending|open|reviewing|resolved|dismissed|all] [--cursor <id>] [--json]
2991
+ lumine admin chat-reports show <report-id> [--json] (reported message snapshot + surrounding context)
2992
+ lumine admin chat-reports set <report-id> --status reviewing|resolved|dismissed|open --note <what was decided> [--json]
2993
+ lumine admin chat-reports hold <report-id> | --user <id>[,<id>] [--channel <id>] | --channel <id> --note <why> [--json] (child-safety evidence hold)
2994
+ lumine admin chat-reports release <hold-id> --note <why> [--json]
2995
+ lumine admin chat-reports list-holds [--status active|released|all] [--json]
2996
+ lumine admin chat-reports export <report-id> | --hold <hold-id> --out <new-dir> [--json] (evidence package + SHA-256 manifest)
2997
+ lumine admin chat-reports suspend --user <id> --note <why> [--json] (full ban: signs them out)
2976
2998
  lumine admin reward-review show <review-id> [--dir <path>] [--json]
2977
2999
  lumine admin reward-review approve <review-id> [--config <rules.json>] [--reason <text>] [--json] (approval publishes the approved version)
2978
3000
  lumine admin reward-review propose <review-id> --dir <edited-snapshot> --config <rules.json> [--reason <text>] [--json]
package/lib/constants.js CHANGED
@@ -447,6 +447,7 @@ export const COMMANDS = new Set([
447
447
  "sdk",
448
448
  "assets",
449
449
  "rewards",
450
+ "chatlog",
450
451
  "thumbnail",
451
452
  "doctor",
452
453
  "help",
package/lib/rewards.js CHANGED
@@ -203,6 +203,28 @@ export async function rewardsCommand(options) {
203
203
  if (!result.ok) process.exitCode = 1;
204
204
  return;
205
205
  }
206
+ if (action === "review") {
207
+ await assertAuthScope({ options, auth, scope: "build:write" });
208
+ const result = await requestJson({
209
+ url: `${options.apiUrl}/build/${buildId}/rewards/reviews`,
210
+ method: "POST",
211
+ authToken: auth.token,
212
+ timeoutMs: options.timeoutMs,
213
+ body: {},
214
+ });
215
+ if (options.json) {
216
+ console.log(JSON.stringify(result, null, 2));
217
+ return;
218
+ }
219
+ const reviewId = result?.policy?.latestReviewId;
220
+ console.log(
221
+ `Sent Build ${buildId} for reward review${reviewId ? ` (request #${reviewId})` : ""}. State: ${result?.state || "unknown"}.`,
222
+ );
223
+ console.log(
224
+ "The saved version and the question sheet on file are frozen for the reviewer. Saving again closes this request; send it again after the save.",
225
+ );
226
+ return;
227
+ }
206
228
  printRewardsHelp();
207
229
  throw new Error(`Unknown rewards action: ${action}`);
208
230
  }
@@ -212,10 +234,11 @@ function printRewardsHelp() {
212
234
  lumine rewards check Validate rewards.json (workspace or saved) against the question sheet on file
213
235
  lumine rewards sheet <file.json> Upload the private question sheet ({ rules: { <ruleId>: { questions?, sets? } } })
214
236
  lumine rewards sheet --show Summarize the sheet on file (never prints answer keys)
237
+ lumine rewards review Send the saved version (with the sheet on file) for XP/Coin review, like "Send for review" on the website
215
238
 
216
239
  rewards.json (project root) declares the economy the reviewer approves:
217
240
  { "userDailyXP", "userDailyCoins", "userDailyClaims"?,
218
- "rules": [{ "id", "title", "xp", "coins", "verifier": "numeric-quiz" | "completion",
241
+ "rules": [{ "id", "title", "howTo"? (how to earn it, in the player's words), "xp", "coins", "verifier": "numeric-quiz" | "completion",
219
242
  "maxAttempts"?, "retry"?: { "xpPercent", "coinsPercent", "paidAttempts"? }, "minSeconds"? (completion), "progression"?: "dated" | "until-earned" (quiz) }] }
220
243
  Questions and answer keys never go in project files; they belong in the sheet.`);
221
244
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.2.89",
3
+ "version": "0.2.91",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,8 +1,8 @@
1
1
  # Build SDK Index
2
2
 
3
- Version: 1.55.0
4
- Updated: 2026-09-24
5
- Generated: 2026-09-24T11:41:42.326Z
3
+ Version: 1.60.0
4
+ Updated: 2026-09-27
5
+ Generated: 2026-09-27T07:17:40.033Z
6
6
 
7
7
  ## Notes
8
8
  - This SDK is injected into Build iframes via the Build preview/runtime.
@@ -36,7 +36,7 @@ Generated: 2026-09-24T11:41:42.326Z
36
36
  - The creator's agent designs the rewards. Declare the economy in a project file `rewards.json` at the root: budgets (userDailyXP, userDailyCoins, optional userDailyClaims; there is no app-wide daily or lifetime budget, only what one learner can earn per day) and rules [{ id, title, xp, coins, verifier: 'numeric-quiz' | 'completion', maxAttempts?, retry?: { xpPercent, coinsPercent, paidAttempts? }, minSeconds? (completion), progression?: 'dated' | 'until-earned' (quiz) }]. Wire the matching Twinkle.rewards calls with those literal rule ids. Questions and answer keys NEVER go in project files (published source is readable by every player): quiz rules get them from the private question sheet uploaded with `lumine rewards sheet <file.json>` ({ rules: { <ruleId>: { questions?, sets? } } }); `lumine rewards check` validates both together. A review request freezes the code and proposes rewards.json merged with the sheet; the administrator reads the code, checks the amounts and whether the app is exploitable, may change any amount, and approves. Creators are kids and teens: show approval status and one Send for review action; do not ask them to fill in technical forms. Every code update that retains rewards needs a new approval before publishing. Removing the SDK automatically clears its gate. Apps read amounts, tries and sets from getStatus, never from their own file.
37
37
  - Verifiers: 'numeric-quiz' pays for server-checked numeric answers (retry share, attempt limits, dated sets or until-earned sets that stay up until somebody earns them, after-answer guides). 'completion' pays when the app reports an activity finished — a cleared stage, a finished round — at least minSeconds after start({ ruleId }); the server checks only the elapsed time, once per learner per site day (UTC midnight), and the budgets. Call start when the activity begins and claim({ challengeId }) with no answers when it ends; keep completion amounts and userDailyXP small enough that a player scripting the calls would not matter, because nothing else is verified.
38
38
  - Numeric quiz answers are verified on the server; client scores, privateDb state, timers and completion booleans are not verified reward evidence. Limits reset at UTC midnight. Rules are earned once per viewer per UTC day; attempt limits and retry payouts come from the approved rule. Challenges expire at the UTC day boundary. Budgets apply across release changes.
39
- - Optional reward rule controls: maxLifetimeClaims caps one learner’s receipts for that rule across every day and release; completionProof: classic-tower-v1 requires a server-simulated Classic Tower finish in addition to minSeconds. These are server-enforced controls. Existing completion rules without completionProof still verify elapsed time only. Registered proof profiles also include breadface-v1, breadface-v2 and breadface-v3 (server-simulated Breadface inputs; each is one reviewed release's exact physics, chosen by the reviewer) study-record-v1 (a private study record reviewed by JEV, billed to the learner’s AI Energy), and Groove Lab's groove-lab-song-v1 (a song the learner published today, checked on the server for length, notes and originality) and groove-lab-heard-v1 (three established accounts finished the learner's songs today). Profiles are platform-owned; an app cannot invent a verifier or authorize its own reward.
39
+ - Optional reward rule controls: maxLifetimeClaims caps one learner’s receipts for that rule across every day and release; completionProof: classic-tower-v1 requires a server-simulated Classic Tower finish in addition to minSeconds. These are server-enforced controls. Existing completion rules without completionProof still verify elapsed time only. Registered proof profiles also include breadface-v1, breadface-v2 and breadface-v3 (server-simulated Breadface inputs; each is one reviewed release's exact physics, chosen by the reviewer) study-record-v1 (a private study record reviewed by JEV, billed to the learner’s AI Energy), and Groove Lab's groove-lab-song-v1 (a song the learner published today, checked on the server for length, notes and originality) and groove-lab-heard-v1 (three established accounts finished the learner's songs today), Ashen Vigil's vigil-guest-coplay-v1 (a new guest spent 10 minutes with the learner in a private world room, measured by the world relay; each guest pays once) and minecraft-first-link-v1 (the learner is the first Twinkle account ever linked to that Minecraft player; each player pays once). Profiles are platform-owned; an app cannot invent a verifier or authorize its own reward.
40
40
 
41
41
  ## AI decision design
42
42
  - When planning a new app or an improvement, consider whether model-based judgments would materially improve the requested experience. Twinkle.ai.decide runs JEV, a model for narrow decisions over supplied text or structured state. Potential uses include interpreting a player's request to an NPC, choosing among legal game actions, classifying user content, ranking supplied candidates, and adapting an activity to evidence about the learner. These are examples to reason from, not a keyword checklist or a requirement to add AI to every app.
@@ -488,9 +488,10 @@ const result = await Twinkle.ai.chat({ message, history: chatHistory, systemProm
488
488
  - Errors include invalid_ai_decision (bad input), ai_decision_unavailable, ai_decision_timeout, ai_decision_rate_limited, ai_decision_invalid_response, and ai_usage_unavailable, plus the existing auth, access, Energy, and rate-limit errors. Invalid answers are rejected. An answer that fails validation is retried once on the server within the same deadline; there is no other provider retry, fabricated answer, or paid LLM fallback. Do not automatically retry a rejected request.
489
489
  - Call on meaningful app events with bounded frequency, batch questions, and keep rendering and deterministic game rules in local code. Ignore a result if the app state or turn changed while it was pending. Test uncertain inputs and choose a fallback appropriate to the experience; confidence is not a correctness guarantee.
490
490
  - Example: const { answers } = await Twinkle.ai.decide({ state: { playerRequest, availableActions }, questions: { action: { type: 'choice', instructions: 'Which available companion action best fits playerRequest? Use wait when unclear.', criteria: { follow: 'Follow the player', guard: 'Stay and keep watch', wait: 'Do nothing until clarified' } }, needsClarification: { type: 'noul', instructions: 'Is playerRequest too ambiguous to act on?' } } }); const action = answers.needsClarification.noul > 0.5 ? 'wait' : answers.action.choice;
491
- - async generateObject({ prompt, expectedStructure, thinkingMode, mode, model, instructions, systemPrompt, webSearch, requestId, onText, onStatus, onReasoning } = {}) | scopes: none
491
+ - async generateObject({ prompt, expectedStructure, images, thinkingMode, mode, model, instructions, systemPrompt, webSearch, requestId, onText, onStatus, onReasoning } = {}) | scopes: none
492
492
  - Returns: { object, result, model, provider, thinkingMode, requestedThinkingMode, requestedModel, webSearch, aiUsagePolicy }
493
493
  - Generate a validated structured JSON object for app decisions, routing, grading, and game-state logic, with optional live output/status callbacks and web search.
494
+ - images: up to 3 reference image URLs the model looks at along with the prompt (a sketch, a photo, a screenshot). They must be Twinkle-hosted uploads (Twinkle.files.pickAndUpload / uploadGenerated asset URLs; PNG, JPEG, WebP or GIF); anything else is refused with 400 before any model call. Image input counts toward the AI Energy of the call like any other input.
494
495
  - Signed-in viewers only.
495
496
  - Use this instead of asking Twinkle.ai.chat to return JSON.
496
497
  - expectedStructure must be a JSON object that describes the exact returned object shape.
@@ -532,6 +533,20 @@ const result = await Twinkle.ai.chat({ message, history: chatHistory, systemProm
532
533
  - GPT Image 2.5 battery spending uses actual image-model input and output token usage. The confirmation shows an image-output estimate; prompts and reference images use additional energy.
533
534
  - responseId and imageId are opaque continuation handles. Pass them back unchanged to edit a prior result; do not assume an OpenAI ID format. Existing GPT Image 2 continuations remain usable.
534
535
  - Example: const result = await Twinkle.ai.generateImage({ prompt: 'Create a fashion guide portrait for this face with flattering colors and outfit ideas', referenceImageB64, quality: 'high', onStatus: (status) => console.log(status.stage) });
536
+ - async generateMusic({ prompt, length, instrumental, requestId, timeoutMs } = {}) | scopes: none
537
+ - Returns: { success, asset, url, mimeType, structure, length, instrumental, model, requestId, replayed?, aiUsagePolicy }
538
+ - Generate a finished piece of music (rendered audio, not notes) from a text description with Google Lyria. The audio is saved to the viewer's own Twinkle.files and returned as an asset URL.
539
+ - Signed-in viewers only. Call it directly from an explicit viewer action such as a button click; calls from page load, timers or programmatic retries are rejected (USER_ACTIVATION_REQUIRED).
540
+ - Twinkle shows a host-owned confirmation with the battery cost for every generation. One approval authorizes exactly one request.
541
+ - length: 'full' (default) is a complete song of about two to three minutes (Lyria 3.5); 'clip' is a 30-second piece (Lyria 3 Clip) at half the cost.
542
+ - Describe genre, mood, instruments, tempo and structure in the prompt; ask for vocals or pass instrumental: true for no vocals. Duration and vocals have no other controls.
543
+ - Prompts naming artists, bands, songs or copyrighted lyrics are refused with code music_prompt_blocked and cost nothing: show the viewer the error message so they can reword it.
544
+ - The result is a normal Twinkle.files asset (asset.id, asset.url, audio/mpeg) owned by the viewer and counted against their file storage; it also appears in Twinkle.files lists. Store asset.url (e.g. in privateDb/sharedDb) to play it later.
545
+ - structure is the model's song-structure text (section markers, and lyrics when there are vocals).
546
+ - AI Energy is charged only after the music is saved. Failures, timeouts and refusals are not charged.
547
+ - Only one music generation per viewer may run at a time (code ai_music_generation_in_progress). A retry with the same requestId returns the finished result without paying again (replayed: true), or code music_in_progress while it is still being made.
548
+ - Generation usually takes one to three minutes; the SDK timeout defaults to 600000ms. Show progress UI while waiting.
549
+ - Example: const song = await Twinkle.ai.generateMusic({ prompt: 'Warm lo-fi hip hop with dusty drums, a mellow Rhodes and rain in the background', instrumental: true }); audio.src = song.url;
535
550
  - onImageGenerationStatus(listener) | scopes: none
536
551
  - Returns: unsubscribe function
537
552
  - Subscribe to real-time image generation status events forwarded into the build iframe.
@@ -593,9 +608,10 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
593
608
  - Returns subject ids plus rootType/rootId metadata for picker UIs. Empty queries return an empty result set.
594
609
  - Example: const { subjects } = await Twinkle.subjects.search({ query: searchText, limit: 12 });
595
610
  - async getSubject(subjectId) | scopes: content:read
596
- - Returns: { subject: { id, title, description, filePath, fileName, fileSize, thumbUrl, secretAnswer, secretAttachment, timeStamp, userId, username, profilePicUrl, rootType, rootId, rewardLevel } }
611
+ - Returns: { subject: { id, title, description, filePath, fileName, fileSize, thumbUrl, secretAnswer, secretAttachment, hasSecretAnswer, hasSecretAttachment, secretShown, timeStamp, userId, username, profilePicUrl, rootType, rootId, rewardLevel } }
597
612
  - Returns full detail for a single subject, including uploader info and attachments.
598
613
  - Any subject can be fetched (not limited to viewer's own).
614
+ - secretAnswer and secretAttachment follow the site's rule: they are filled only when the viewer posted the subject or has responded to it (secretShown true). Otherwise they are null and hasSecretAnswer / hasSecretAttachment say a secret exists.
599
615
  - async getSubjectComments(subjectId, { limit, cursor } = {}) | scopes: content:read
600
616
  - Returns: { comments: [{ id, content, filePath, fileName, fileSize, thumbUrl, timeStamp }], cursor? }
601
617
  - Returns only the current viewer's own comments on the given subject.
@@ -646,6 +662,7 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
646
662
  - Lists completed existing AI Stories newest first by default; order:'oldest' is allowed only with difficulty, type, and topicKey for chronological book pages.
647
663
  - Use difficulty with type and topicKey to load one exact AI Story book without scanning the full corpus in the iframe.
648
664
  - Filter with hasImage or hasQuestions when building visual galleries or quiz apps.
665
+ - A story the signed-in viewer is still playing (an AI Story attempt they have not submitted) comes back without answerIndex on its questions; the site reveals that key only after the viewer submits.
649
666
  - Example: const { stories } = await Twinkle.aiStories.list({ difficulty: 1, type: 'science', topicKey: 'Astronomy', order: 'oldest', limit: 20 });
650
667
  - async chapters({ limit, cursor, groupBy, difficulty, type, topicKey, storyBy, isListening, userId, hasImage, hasQuestions } = {}) | scopes: content:read
651
668
  - Returns: Default (groupBy:'topicKey'): { chapters: [{ difficulty, type, topicKey, title, sampleTopic, storyCount, readingCount, listeningCount, imageCount, questionCount, latestStoryId, latestTimeStamp }], cursor?, pagination, filters }. groupBy:'type': { books: [{ difficulty, type, title, sampleTopic, chapterCount, storyCount, readingCount, listeningCount, imageCount, questionCount, latestStoryId, latestTimeStamp }], ... } — one row per (level, topic) book. groupBy:'author': { authors: [{ storyBy, title, bookCount, chapterCount, storyCount, minDifficulty, maxDifficulty, latestStoryId }], ... } — one row per generating model (the story's author); an index-only landing, so it omits media counts (use a scoped books/chapters call for those).
@@ -661,29 +678,41 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
661
678
  - Searches completed existing AI Stories by topic/story text.
662
679
  - Use difficulty, type, and topicKey to search within one book of the AI Story corpus; order:'oldest' is rejected without all three filters.
663
680
  - Returned questions are normalized to an array even when stored as JSON text.
681
+ - A story the signed-in viewer is still playing (an AI Story attempt they have not submitted) comes back without answerIndex on its questions; the site reveals that key only after the viewer submits.
664
682
  - Example: const { stories } = await Twinkle.aiStories.search({ query: searchText, difficulty: 2, type: 'history', topicKey: 'Ancient Rome', order: 'oldest', limit: 12 });
665
683
  - async get(storyId) | scopes: content:read
666
684
  - Returns: { story: { id, contentType, contentId, topic, topicKey, type, story, explanation, difficulty, isListening, imagePath, imageUrl, audioPath, audioUrl, questions, questionsBy, hasImage, hasQuestions, userId, username, profilePicUrl, timeStamp } }
667
685
  - Fetch one completed existing AI Story by id, including story text for passage typing, media URLs, and normalized questions when available.
668
686
  - Fetches one completed existing AI Story by id.
687
+ - A story the signed-in viewer is still playing (an AI Story attempt they have not submitted) comes back without answerIndex on its questions; the site reveals that key only after the viewer submits.
669
688
  - This namespace is read-only and does not generate new AI Stories.
670
689
  - Example: const { story } = await Twinkle.aiStories.get(storyId);
671
690
 
672
691
  ### Twinkle.grammarbles
673
692
  - async listQuestions({ level, limit, cursor } = {}) | scopes: content:read
674
- - Returns: { questions: [{ id, level, rating, question, choices, answerIndex, correctChoice, correctChoiceKey, isChecked, explanation }], cursor?, pagination: { level, limit, hasMore, nextCursor } }
675
- - Read public Grammarbles questions and answers by level with rating/id cursor pagination.
676
- - Questions are public Grammarbles training data and include the canonical answer.
693
+ - Returns: { questions: [{ id, level, rating, question, choices, isChecked }], cursor?, pagination: { level, limit, hasMore, nextCursor } }
694
+ - Read Grammarbles questions and choices by level with rating/id cursor pagination; answers stay on the server.
695
+ - Questions do not include the answer, answerIndex or explanation: Grammarbles is a rewarded game, so its key stays on the server. Check a pick with Twinkle.grammarbles.checkAnswer.
696
+ - Each question's choices come in a fixed order; pass the index of the picked choice in that order to checkAnswer.
677
697
  - level is clamped from 1 through 5.
678
698
  - Pagination is stable by rating then id. Pass cursor from the previous response to load more questions in the same level.
679
699
  - This method does not expose daily attempt state, XP, coins, or daily-task progression.
680
700
  - Example: const page = await Twinkle.grammarbles.listQuestions({ level: 3, limit: 100 }); const question = page.questions[Math.floor(Math.random() * page.questions.length)];
701
+ - async checkAnswer({ questionId, choiceIndex }) | scopes: content:read
702
+ - Returns: { questionId, choiceIndex, isCorrect, explanation }
703
+ - Check one Grammarbles pick on the server: right or wrong, plus the review explanation on a right pick.
704
+ - choiceIndex is 0-3 in the order listQuestions returned the choices.
705
+ - The right position is never returned. Let the learner try again until the pick is right, as Grammarbles itself does.
706
+ - explanation is null unless the pick is right and the question has a reviewed explanation.
707
+ - Each viewer has a daily allowance of answer checks (500 per UTC day); past it the call fails with code grammarbles_check_limit.
708
+ - Checking pays nothing and does not touch daily Grammarbles attempts, XP, coins, or daily tasks.
709
+ - Example: const { isCorrect, explanation } = await Twinkle.grammarbles.checkAnswer({ questionId: question.id, choiceIndex: pickedIndex }); if (!isCorrect) showTryAgain(); else showCorrect(explanation);
681
710
  - async getMyQuestionHistory({ level, limit, cursor } = {}) | scopes: content:read
682
711
  - Returns: { attempts: [{ id, questionId, level, grade, gradeRank, isCorrect, attemptNumber, timeStamp }], cursor?, pagination: { level, limit, hasMore, nextCursor } }
683
712
  - Read the signed-in viewer's real Grammarbles attempt rows for trainer filtering.
684
713
  - Returns real Grammarbles attempt outcome rows for the signed-in viewer, newest first.
685
714
  - History rows intentionally omit choice indexes because real Grammarbles choices are shuffled per run and the per-run shuffle order is not persisted.
686
- - Use Twinkle.grammarbles.listQuestions for canonical question text, choices, and answers.
715
+ - Use Twinkle.grammarbles.listQuestions for question text and choices, and Twinkle.grammarbles.checkAnswer to check a pick.
687
716
  - Use app-private history in Twinkle.privateDb for trainer-only results, and combine it with this method only when the viewer chooses to include real Grammarbles history.
688
717
  - This method is read-only and does not submit, cancel, or mutate daily Grammarbles attempts.
689
718
  - Example: const history = await Twinkle.grammarbles.getMyQuestionHistory({ level: selectedLevel, limit: 500 }); const answeredIds = new Set(history.attempts.map((attempt) => attempt.questionId));
@@ -787,19 +816,20 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
787
816
  - limit is 1-50 (default 10). kind filters to helper or workshop builds.
788
817
  - Example: const { builds } = await Twinkle.minecraft.getZeroBuilds({ limit: 10, kind: 'workshop' });
789
818
  - async getPeople() | scopes: content:read
790
- - Returns: { canManage, people: [{ uuid, name, role: 'visitor'|'member'|'builder'|'moderator', op, online, isZero, twinkle: { userId, username } | null, ...owner: groups, banned, protected, firstSeenAt, lastSeenAt | ...others: seen: 'online'|'today'|'week'|'month'|'older'|null }], roles }
819
+ - Returns: { canManage, people: [{ uuid, name, role: 'visitor'|'member'|'builder'|'moderator', op, online, isZero, twinkle: { userId, username } | null, ...owner: groups, banned, protected, firstSeenAt, lastSeenAt, endorsements: [{ voucherName, voucherUuid, endorsedAt }] | ...others: seen: 'online'|'today'|'week'|'month'|'older'|null }], roles }
791
820
  - Everyone who has joined the server with their in-game rank, for a player directory; the server owner also gets the full records for role management.
792
821
  - Every viewer gets the list. Only the server owner, in an app they own, gets canManage: true with full records (groups, bans, exact first/last seen); everyone else gets canManage: false and the public view: banned players left out, and seen is a rough bucket instead of exact times. Show role controls only when canManage is true.
793
822
  - Roles: visitor (play and chat), member (/tpa, /home, /back, may ask Zero to build), builder (member + /fly and creative/survival), moderator (builder + teleport others, CoreProtect rollback, /kick). op: true players are server operators and have every power regardless of role.
794
823
  - protected: true players (the owner and Zero's account) can't be changed from the app. Sorted online first, then most recently seen.
795
824
  - Not cached; call on screen open or after a change, not on a timer.
796
825
  - twinkle is the Twinkle account linked to that player with /link, or null.
826
+ - endorsements (owner view only) lists who backed this player's Builder application: a moderator or anyone above typed /vouch for a player already on Twinkle. voucherUuid is the voucher's Minecraft uuid (null when endorsed from the app or console); endorsedAt is an ISO time. Empty when there are none, or when the server's vouch list could not be read.
797
827
  - Example: const { canManage, people } = await Twinkle.minecraft.getPeople(); renderDirectory(people, { editable: canManage });
798
828
  - async setPlayerRole({ uuid, role }) | scopes: content:write
799
829
  - Returns: { player: { uuid, name, role, op } }
800
830
  - Change a player's in-game role; it applies immediately, even while they are online.
801
831
  - Server owner only, in an app they own; others get 403 with code minecraft_roles_forbidden.
802
- - role is visitor, member, builder or moderator. 400 codes: minecraft_bad_uuid, minecraft_bad_role, minecraft_protected_player, minecraft_unknown_player. 503 minecraft_unavailable while the server restarts.
832
+ - role is visitor, member, builder, moderator, or a Partner rank above moderator: associate, senior_associate, partner, senior_partner (each keeps every moderator power). A promotion also sends the player's linked Twinkle account a DM, and the player gets a title and perks list in game. 400 codes: minecraft_bad_uuid, minecraft_bad_role, minecraft_protected_player, minecraft_unknown_player. 503 minecraft_unavailable while the server restarts.
803
833
  - Every change is logged on the server and emailed to the owner; the player is told in game.
804
834
  - Example: await Twinkle.minecraft.setPlayerRole({ uuid: person.uuid, role: 'builder' });
805
835
  - async getChat({ since } = {}) | scopes: content:read
@@ -846,7 +876,7 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
846
876
  - Only the viewer's own links; removed is false when there was nothing to remove.
847
877
  - Example: await Twinkle.minecraft.unlinkMinecraft({ uuid: link.uuid });
848
878
  - async getDesigns({ query } = {}) | scopes: content:read
849
- - Returns: { designs: [{ id, version, name, category, summary, size: { width, height, depth }, blocks, status, author, authorUserId, visibility: 'private'|'public', previewUrl, createdAt }], access: { rank, linked, canOrder, canModerate, isOwner } | null }
879
+ - Returns: { designs: [{ id, version, name, category, summary, size: { width, height, depth }, blocks, status, author, authorUserId, visibility: 'private'|'public', previewUrl, createdAt }], access: { rank, linked, canOrder, canModerate, isOwner, canKeepPrivate } | null }
850
880
  - Zero's design library: published designs plus the viewer's own private ones.
851
881
  - Built-in designs (redstone devices and so on) have no author and are public.
852
882
  - previewUrl is an image set with updateDesign, or null.
@@ -863,13 +893,13 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
863
893
  - Save a design (or a new version of your design) from blueprint parts.
864
894
  - parts use Zero's blueprint shapes: box, hollow_box, walls, floor, line, block, cylinder, sphere, gable_roof, pyramid_roof; offsets x = east, y = up, z = south; later parts override earlier ones (carve doors/windows with "air"). Up to 60,000 blocks and 96 blocks in each direction.
865
895
  - category is building, decor, farm or path. visibility private (default) or public. Saving the same id again adds a version; only its designer can do that. Rejections come back as 400 minecraft_studio_rejected with a readable message.
866
- - Who may: the server owner, and any signed-in viewer with a linked Minecraft account (Twinkle.minecraft.createLinkCode + /link); others get 403 minecraft_not_linked. Designs are private unless visibility is 'public'. Players have a limit on how many designs they keep.
896
+ - Who may: any signed-in viewer; they own what they save (they can change or delete it). Only builders and above (access.canKeepPrivate from getDesigns) may keep a design private; for anyone else visibility must be 'public' (omitted means public) or the call fails with 403 minecraft_private_needs_builder. Builders and above save private designs when visibility is omitted. Players have a limit on how many designs they keep.
867
897
  - Example: await Twinkle.minecraft.saveDesign({ id: 'harbor_house', name: 'Harbor house', category: 'building', visibility: 'private', parts: [{ shape: 'floor', from: [0, 0, 0], to: [8, 0, 6], block: 'stone_bricks' }, { shape: 'walls', from: [0, 1, 0], to: [8, 4, 6], block: 'spruce_planks' }] });
868
898
  - async updateDesign({ id, visibility, previewUrl, name, summary }) | scopes: content:write
869
899
  - Returns: { design: { id, version, name, category, summary, size: { width, height, depth }, blocks, status, author, authorUserId, visibility: 'private'|'public', previewUrl, createdAt } }
870
900
  - Publish or unpublish a design, set its preview image, or rename it.
871
901
  - previewUrl is typically a Twinkle.files upload of a rendered preview.
872
- - Who may: a design's own designer; moderators (by linked account) and the server owner may change or delete anyone's.
902
+ - Who may: a design's own designer; moderators (by linked account) and the server owner may change or delete anyone's. Only builders and above may set visibility 'private' (403 minecraft_private_needs_builder otherwise).
873
903
  - Example: await Twinkle.minecraft.updateDesign({ id: 'harbor_house', visibility: 'public' });
874
904
  - async deleteDesign({ id }) | scopes: content:write
875
905
  - Returns: { deleted }
@@ -923,6 +953,23 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
923
953
  - Delete a snap from the Gallery.
924
954
  - Who may: the player who took it (by a linked Minecraft account), moderators, and the server owner. Others get 403 minecraft_snap_forbidden.
925
955
  - Example: await Twinkle.minecraft.deleteSnap({ id });
956
+ - async getRecruitTours() | scopes: content:read
957
+ - Returns: { enabled, title, landmarks: [{ id, name, world, x, z, radius }], requirements: { playHours, days }, tours: [{ uuid, name, introducerUuid, introducerName, startedAt, completedAt, visited: [landmarkId], playSeconds, days }] }
958
+ - Recruit tours: each player someone brought to the server, who brought them, and their progress (landmarks visited, hours and days played). A completed tour is one recruiter point for the introducer.
959
+ - Everyone can read it; one shared copy refreshes every 15 seconds.
960
+ - The landmark list and requirements come from the server's config, so show them from here rather than hard-coding them.
961
+ - Example: const { landmarks, tours } = await Twinkle.minecraft.getRecruitTours();
962
+ - async startRecruitTour({ uuid, introducerUuid }) | scopes: content:write
963
+ - Returns: { uuid, introducerUuid }
964
+ - Server owner only: record that introducerUuid brought player uuid to the server and start that player's landmark tour (a quest list in game).
965
+ - 403 minecraft_roles_forbidden for anyone but the server owner.
966
+ - 400 minecraft_already_credited when someone else was already credited; minecraft_self_introduced for the same player; minecraft_unknown_player when either never joined.
967
+ - Example: await Twinkle.minecraft.startRecruitTour({ uuid: recruit.uuid, introducerUuid: friend.uuid });
968
+ - async cancelRecruitTour({ uuid }) | scopes: content:write
969
+ - Returns: { removed }
970
+ - Server owner only: remove a player's recruit tour (a mistaken credit).
971
+ - 403 minecraft_roles_forbidden for anyone but the server owner.
972
+ - Example: await Twinkle.minecraft.cancelRecruitTour({ uuid });
926
973
  - async getArrivalPoints() | scopes: content:read
927
974
  - Returns: { accounts: [{ uuid, name, online, world, worldLabel, points: [{ world, label, x, y, z, savedAt }] }] }
928
975
  - The viewer's private arrival points: where the Twinkle Gate portals drop their linked Minecraft account in each world.
@@ -940,12 +987,80 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
940
987
  - Remove the viewer's arrival point in a world (back to the shared arrival).
941
988
  - world is a world id such as world, world1, world3, world_nether.
942
989
  - Example: await Twinkle.minecraft.clearArrivalPoint({ world: 'world3' });
990
+ - async getPlayAreas() | scopes: content:read
991
+ - Returns: { levels: [{ id, name, world, box: [x0,y0,z0,x1,y1,z1], spawn: [x,y,z], by, createdAt, status: 'capturing'|'ready'|'failed', chunkCount, done, blocks }] }
992
+ - Play areas: captured Minecraft builds (a castle, a cathedral) that apps turn into first-person games, newest first.
993
+ - Anyone can read. box is inclusive world coordinates; status 'capturing' areas are still being saved (done of chunkCount).
994
+ - Example: const { levels } = await Twinkle.minecraft.getPlayAreas();
995
+ - async getPlayArea({ id }) | scopes: content:read
996
+ - Returns: { level: { ...getPlayAreas fields, chunks: [[cx, cz, visibleBlocks]] } }
997
+ - One play area with its list of captured chunk columns, to stream with getPlayAreaChunk.
998
+ - Chunk columns are 16x16 blocks (cx = floor(x / 16)); load the ones near the player first.
999
+ - Example: const { level } = await Twinkle.minecraft.getPlayArea({ id: 'twinkle-keep' });
1000
+ - async getPlayAreaChunks({ id, chunks: [[cx, cz], ...] }) | scopes: content:read
1001
+ - Returns: { scenes: [{ cx, cz, scene: { origin: [x,y,z], size: [w,h,d], palette, blocks, light, biomes, count, ... } | null }] }
1002
+ - Up to 9 chunk columns of a play area in the /snap scene format (packed visible blocks with per-face light), cropped to the area's box. Stream the ones around the player.
1003
+ - Same scene format as getSnap; only blocks with a visible face inside the area are included. scene is null for a chunk that is not part of the area.
1004
+ - At most 9 chunks per call; reads share the 180-per-minute build read limit, so cache chunks and load the nearest first.
1005
+ - Example: const { scenes } = await Twinkle.minecraft.getPlayAreaChunks({ id: level.id, chunks: [[64, 29], [65, 29]] });
1006
+ - async createPlayArea({ name, world, box, spawn, id }) | scopes: content:write
1007
+ - Returns: { level }
1008
+ - Capture a box of a Minecraft world as a new play area (or re-capture an existing id). Runs in the background: poll getPlayArea until status is 'ready'.
1009
+ - Moderators and the server owner only; others get 403 minecraft_level_forbidden.
1010
+ - At most 320 blocks across each way and 255 tall. spawn [x,y,z] is where players start (default: top centre of the box).
1011
+ - Example: await Twinkle.minecraft.createPlayArea({ name: 'Sand Castle', world: 'world', box: [900, 60, 400, 980, 130, 470] });
1012
+ - async getBuildQueue() | scopes: content:read
1013
+ - Returns: { active: job | null, queue: [{ id, title, kind, status, requester, world, bounds: { min, max }, designId, position, priority, mine, placed, total }], campaign: { id, title, active, steps, done, skipped, percent, current, resumeAt, world, steps: [{ index, title, status, designId, anchor, bounds, blocks }] } | null, noBuildWorlds, access: { rank, linked, canOrder, canModerate, isOwner } | null }
1014
+ - Zero's build queue: the build he is on, the orders waiting (in the order he will take them) and the campaign steps he works through when no order waits. For an RTS-style build panel and for drawing queued footprints on a map.
1015
+ - Everyone can read it; mine marks the viewer's own orders (linked account). Orders always run before campaign steps.
1016
+ - noBuildWorlds lists worlds Zero never builds in (World 3, World 4): don't offer placement there.
1017
+ - Example: const { active, queue, campaign, access } = await Twinkle.minecraft.getBuildQueue();
1018
+ - async moveQueuedBuild({ jobId, to }) | scopes: content:write
1019
+ - Returns: same as getBuildQueue
1020
+ - Server owner: move a waiting order to position `to` (0 = next).
1021
+ - Owner only (403 minecraft_queue_owner_only).
1022
+ - Example: await Twinkle.minecraft.moveQueuedBuild({ jobId: 142, to: 0 });
1023
+ - async removeQueuedBuild({ jobId }) | scopes: content:write
1024
+ - Returns: same as getBuildQueue
1025
+ - Take an order out of the queue (stops it if Zero is already building it). Your own orders; moderators also lower ranks'; the owner any.
1026
+ - Builders and up with a linked account; the rank rule is the same as stopZero.
1027
+ - Example: await Twinkle.minecraft.removeQueuedBuild({ jobId: 142 });
1028
+ - async moveCampaignStep({ index, before }) | scopes: content:write
1029
+ - Returns: same as getBuildQueue
1030
+ - Server owner: move a campaign step that has not started to just before the step at index `before` (null = last). Indexes are the step.index values from getBuildQueue.
1031
+ - Owner only.
1032
+ - Example: await Twinkle.minecraft.moveCampaignStep({ index: 17, before: 15 });
1033
+ - async skipCampaignStep({ index, skip = true }) | scopes: content:write
1034
+ - Returns: same as getBuildQueue
1035
+ - Server owner: skip a campaign step, or bring a skipped or failed one back (skip: false).
1036
+ - Owner only. A step Zero is building has to be stopped first.
1037
+ - Example: await Twinkle.minecraft.skipCampaignStep({ index: 18 });
1038
+ - async setCampaignActive({ active }) | scopes: content:write
1039
+ - Returns: same as getBuildQueue
1040
+ - Server owner: pause or resume Zero's campaign (a paused campaign waits; orders still run).
1041
+ - Owner only.
1042
+ - Example: await Twinkle.minecraft.setCampaignActive({ active: false });
943
1043
  - async getServerLogs({ days, kinds, query, limit } = {}) | scopes: content:read
944
1044
  - Returns: { total, lines: [{ at, kind: 'join'|'leave'|'kick'|'command'|'warn'|'error'|'zero'|'server', level, text }] }
945
1045
  - The Minecraft server log for the server owner: joins, kicks, commands, warnings, errors and Zero's lines, newest first, IP addresses removed.
946
1046
  - Server owner only, in an app they own; everyone else gets 403 minecraft_roles_forbidden.
947
1047
  - days 1-7 (default 1); kinds any of join, leave, kick, command, warn, error, zero, server (default all); query filters by text; limit 1-1000 (default 300). Chat is not included (getChat and getChatHistory have it).
948
1048
  - Example: const { lines } = await Twinkle.minecraft.getServerLogs({ days: 2, kinds: ['kick', 'error'] });
1049
+ - async getNews({ limit } = {}) | scopes: content:read
1050
+ - Returns: { posts: [{ id, title, body, author, at }], canPost }
1051
+ - Server news: short posts about what's new on Twinkle Minecraft, newest first (players see the newest on join and all of it with /news in game).
1052
+ - Every viewer reads the news. at is the post time in ms; limit 1-50 (default 20). canPost is true for moderators and the server owner (they can call addNews and deleteNews).
1053
+ - Example: const { posts, canPost } = await Twinkle.minecraft.getNews({ limit: 10 });
1054
+ - async addNews({ title, body }) | scopes: content:write
1055
+ - Returns: { post: { id, title, body, author, at } }
1056
+ - Post server news (moderators and the server owner). Players in game see the headline right away.
1057
+ - title up to 120 characters (required), body up to 2000. Others get 403 minecraft_news_forbidden. The author is the signed-in viewer's Twinkle username.
1058
+ - Example: await Twinkle.minecraft.addNews({ title: 'World 3 reopens', body: 'Come build!' });
1059
+ - async deleteNews({ id }) | scopes: content:write
1060
+ - Returns: { deleted }
1061
+ - Remove a news post (moderators and the server owner).
1062
+ - Others get 403 minecraft_news_forbidden.
1063
+ - Example: await Twinkle.minecraft.deleteNews({ id: post.id });
949
1064
 
950
1065
  ### Twinkle.leaderboards
951
1066
  - async get({ boardKey = 'default', limit, cursor } = {}) | scopes: none
@@ -1220,12 +1335,13 @@ world.updatePresence({ x, y, z, facing });
1220
1335
 
1221
1336
  ### Twinkle.rewards
1222
1337
  - await Twinkle.rewards.getStatus() | scopes: rewards:claim
1223
- - Returns: { mode: "live", dayKey, userDailyClaims, claimsToday, budgets: { userDailyXP, userDailyCoins }, rules: [{ id, title, xp, coins, verifier: "numeric-quiz" | "completion", minSeconds?, progression?, retryReward: { xp, coins }, maxAttempts, available, setKey, questionCount }], challenges: [{ challengeId, ruleId, attempts, attemptsRemaining, state: "open" | "finished" | "earned", setKey, questions: [{ prompt, hint?, guide? }] }], history: [{ ruleId, xp, coins, attempt, createdAt }], balances: { xp, coins } } | { mode: "preview", dayKey?, rules, challenges: [], history: [], balances?, problems?: string[], message }
1338
+ - Returns: { mode: "live", dayKey, userDailyClaims, claimsToday, budgets: { userDailyXP, userDailyCoins }, rules: [{ id, title, howTo?, xp, coins, verifier: "numeric-quiz" | "completion", minSeconds?, progression?, retryReward: { xp, coins }, maxAttempts, available, setKey, questionCount }], challenges: [{ challengeId, ruleId, attempts, attemptsRemaining, state: "open" | "finished" | "earned", setKey, questions: [{ prompt, hint?, guide? }] }], history: [{ ruleId, xp, coins, attempt, createdAt }], balances: { xp, coins } } | { mode: "preview", dayKey?, rules, challenges: [], history: [], balances?, problems?: string[], message }
1224
1339
  - Read canonical earning rules (without answer keys), today’s started challenges, today’s receipts and balances. Drafts return preview mode: for the app's owner the rules come from the draft's own rewards.json and question sheet (problems lists what is still wrong with them); anyone else sees no rules. Unapproved or revoked published releases return an error.
1225
1340
  - rules[].available is false on a site day (UTC) the reviewer scheduled no questions for; show the rule as not available instead of starting it. xp/coins are the first-try amounts; retryReward is what a correct answer pays after a wrong one (equal to xp/coins unless the reviewer set a retry share). maxAttempts null means unlimited wrong answers until the site's daily reset (UTC midnight, 9:00 AM in Korea). retry.paidAttempts, when set, is the last attempt number a correct answer is still paid on: a later correct answer is recorded as solved (receipt xp 0, coins 0) and pays nothing — tell the learner before they pass it.
1226
1341
  - challenges lists challenges this viewer already started today with their questions, so an app can resume after a reload without calling start. A question's guide (reviewer-approved JSON teaching content: explanation, interactive-model configuration) is present only once the viewer has answered at least once, right or wrong; render it as the after-attempt lesson. claimsToday against userDailyClaims (null = uncapped) tells whether another bounty can still pay today.
1227
1342
  - Under progression 'until-earned' the same set stays up day after day until somebody earns it; setKey names the set currently up. Completion rules are always available and have questionCount 0.
1228
1343
  - Rules may set maxLifetimeClaims, a per-learner limit for that rule across days and releases. rules[].lifetime contains the server-confirmed limit and remaining claims. App storage never enforces this limit.
1344
+ - howTo is the rule's optional plain-words steps from rewards.json (up to 300 characters, e.g. "Save a new design of at least 64 blocks in Designs"). The Earn page shows it under the title; show it in the app's own rewards list too so players always know exactly what earns each reward.
1229
1345
  - await Twinkle.rewards.getReceipt({ challengeId }) | scopes: rewards:claim
1230
1346
  - Returns: { mode: "live", status: "awarded" | "pending" | "expired" | "not_found", receipt: { id, challengeId, ruleId, reviewId, artifactVersionId, dayKey, xp, coins, attempt, createdAt } | null, balances: { xp, coins } } | { mode: "preview", status: "not_found", receipt: null, message }
1231
1347
  - Read an existing receipt for this app and signed-in viewer by server-issued challengeId, including previous UTC days and previous approved versions. Requires the current approved published release and runtime grant; a stale frame must reload first. Never awards, retries a claim, returns answer keys, or restores removed rewards permission.
@@ -1236,7 +1352,7 @@ world.updatePresence({ x, y, z, facing });
1236
1352
  - Errors: build_reward_not_scheduled when the rule has no questions for today; build_reward_daily_claims_reached when the viewer already earned today’s cap. attemptsRemaining is null for unlimited rules.
1237
1353
  - For a completion rule call start when the activity begins (the moment the stage starts); the challenge's age is what the claim is measured against. In preview mode start also works for the owner (a stateless simulation).
1238
1354
  - For completionProof: classic-tower-v1, start also returns completion { profile, token, maxFrames, completed, failed }. A new start resets only the simulated climb to its canonical spawn; it cannot reset daily or lifetime rewards. Record inputs from the first physics frame. The completion token is bound to the viewer, challenge, rule and published release.
1239
- - For breadface-v1, breadface-v2 and breadface-v3, pass the zero-based canonical levelIndex. Record [dt, inputBits] from the first physics frame; start returns its server token and maxFrames. For study-record-v1, start returns completion { profile, usesAiEnergy: true }; there is no client-authored proof token. For groove-lab-song-v1 and groove-lab-heard-v1, start returns completion { profile }; there is no progress step.
1355
+ - For breadface-v1, breadface-v2 and breadface-v3, pass the zero-based canonical levelIndex. Record [dt, inputBits] from the first physics frame; start returns its server token and maxFrames. For study-record-v1, start returns completion { profile, usesAiEnergy: true }; there is no client-authored proof token. For groove-lab-song-v1, groove-lab-heard-v1, vigil-guest-coplay-v1 and minecraft-first-link-v1, start returns completion { profile }; there is no progress step.
1240
1356
  - await Twinkle.rewards.progress({ challengeId, completionToken?, frames?, record?, requestId? }) | scopes: rewards:claim
1241
1357
  - Returns: { mode: "live" | "preview", completion: { profile, token?, completed, failed?, decision?, message?, maxFrames? }, aiUsagePolicy? }
1242
1358
  - Verify a bounded batch of inputs for an approved server-simulated climb.
@@ -1252,7 +1368,7 @@ world.updatePresence({ x, y, z, facing });
1252
1368
  - A wrong answer within two seconds of the previous one is refused with build_reward_throttled (HTTP 429) and does not count; wait for the person to try again rather than retry-looping.
1253
1369
  - Completion rules take no answers: claim({ challengeId }) when the activity is finished. build_reward_too_fast (HTTP 409) means fewer than minSeconds passed since start; show nothing and let play continue. In preview mode the receipt carries preview: true and nothing is paid.
1254
1370
  - A completionProof rule also requires the signed completionToken from a successful rewards.progress response. The server simulates the registered game physics and must reach the goal. A timer, forged position, client win flag, altered inventory or token from another viewer, challenge or release cannot authorize payment. maxLifetimeClaims is enforced from receipts in the same award transaction. build_reward_lifetime_claims_reached means all rewards for this rule have been collected; do not retry it.
1255
- - For study-record-v1, the server verifies the settled private review row and its viewer, app, exact release, challenge and day binding. It never trusts the client’s decision, requested award amount or AI answer. Each successful daily study record is claimable once; display only canonical receipt/balances. For groove-lab-song-v1, pass completionToken as the published song's sharedDb entry id; for groove-lab-heard-v1, pass none. The server reads the app's own groove-lab-songs, groove-lab-song-parts and groove-lab-listens rows and refuses with build_reward_completion_proof_required and a plain reason when the song or listeners do not qualify.
1371
+ - For study-record-v1, the server verifies the settled private review row and its viewer, app, exact release, challenge and day binding. It never trusts the client’s decision, requested award amount or AI answer. Each successful daily study record is claimable once; display only canonical receipt/balances. For groove-lab-song-v1, pass completionToken as the published song's sharedDb entry id; for groove-lab-heard-v1, pass none; for vigil-guest-coplay-v1, pass the guest's world guestId (from world events) or the co-play record id; for minecraft-first-link-v1, pass nothing or the Minecraft UUID to use. The server reads the app's own groove-lab-songs, groove-lab-song-parts and groove-lab-listens rows and refuses with build_reward_completion_proof_required and a plain reason when the song or listeners do not qualify.
1256
1372
  - await Twinkle.rewards.getLeaderboard({ metric?: "xp" | "coins", period?: "day" | "week" | "all", limit? }) | scopes: rewards:claim
1257
1373
  - Returns: { mode: "live", metric, period, limit, dayKey, from, available: { xp, coins }, entries: [{ rank, userId, username, profilePicUrl, xp, coins, claims, lastAt }], me: { rank, xp, coins, claims } | null } | { mode: "preview", metric, period, available, entries: [], me: null, message }
1258
1374
  - Standings of who earned the most XP or Coins in THIS app, computed by Twinkle from its own receipts (never from anything the app submits). period 'day' is today (site day, UTC), 'week' the last 7 site days, 'all' (default) every day since approval. limit defaults to 20, max 100.
@@ -1090,7 +1090,7 @@ app declares its economy in `rewards.json` at the project root (rule ids,
1090
1090
  titles, XP, Coins, tries, retry share, budgets); quiz rules get their questions
1091
1091
  and answer keys from a private question sheet the creator's Lumine uploads with
1092
1092
  `lumine rewards sheet <file.json>` (never a project file: published source is
1093
- readable by every player). **Send for review** freezes the code and proposes
1093
+ readable by every player). **Send for review** (website, or `lumine rewards review` from the workspace) freezes the code and proposes
1094
1094
  `rewards.json` merged with the sheet. Approval is Mikey's decision: read the
1095
1095
  frozen code, check that the amounts are right and that the app cannot be
1096
1096
  farmed, change anything that is wrong, approve. **Approval publishes** (since
@@ -1218,6 +1218,19 @@ submitted. The website equivalent is the Management panel's "Edit a copy to
1218
1218
  propose changes" (a private workspace copy owned by the reviewer) followed by
1219
1219
  "Offer my copy with these rules".
1220
1220
 
1221
+ **Try this version (since 2026-09-26, API 3c08af5d / vite 2.2.86).** While an
1222
+ offer is `changes_offered`, the creator and the reviewer (and nobody else) get
1223
+ a **Try this version** button on the chat card, in the creator's reward
1224
+ settings and in the Management approvals panel. It plays the offered
1225
+ `proposalSnapshot` as a normal app preview through
1226
+ `GET /build/preview/build/:buildId/reward-proposal/:reviewId`, with a token of
1227
+ its own scope (`reward-proposal:preview`, bound to viewer, build, review and
1228
+ revision; re-checked on every file request). XP and Coins inside it are
1229
+ simulated against the offered rules and never pay. The app's own SDK data
1230
+ calls (privateDb, sharedDb…) still reach the build's real data as the viewer,
1231
+ like a draft preview, and the modal says so. A newer revision or an answered
1232
+ offer closes the preview (409).
1233
+
1221
1234
  Each offer has a server-owned revision. Changing the files, rules or note
1222
1235
  creates a new revision; a creator looking at an older comparison or decline
1223
1236
  confirmation cannot answer the replacement offer. The creator sees its reward
@@ -1286,6 +1299,109 @@ ids (`reward-review show <id>` lists its events). An `accept` without a
1286
1299
  the comparison; mention it, it is not a fault. Refusals are the concurrency
1287
1300
  guards working; report them, and escalate only a repeated pattern on one app.
1288
1301
 
1302
+ ### Members' chat reports (any time; also a full-daily-review duty, added 2026-09-27)
1303
+
1304
+ Any member can report another member's chat message from the message's "…"
1305
+ menu (reasons: bullying or harassment, sexual content, asking for personal
1306
+ info, spam or scam, something else; optional note). The API snapshots the
1307
+ message and a few messages around it, so a later edit or delete does not
1308
+ erase the evidence, and Zero DMs Mikey on the first report of each message.
1309
+ The reporter only sees "Thanks. Our team will look at this."; the reported
1310
+ member is never told. The full-run intake and report carry `chatReports`
1311
+ (pending = `open` + `reviewing`), like `rewardReviews`. These commands need no
1312
+ daily run:
1313
+
1314
+ ```bash
1315
+ lumine admin chat-reports list --json # pending (default)
1316
+ lumine admin chat-reports list --status all --cursor 40 --json
1317
+ lumine admin chat-reports show 12 --json # snapshot + context
1318
+ lumine admin chat-reports set 12 --status resolved --note "What was decided or done" --json
1319
+ ```
1320
+
1321
+ Read every pending report's snapshot and context in each full run. A report
1322
+ about a child's safety or wellbeing (sexual content, requests for personal
1323
+ information or photos, threats, targeted bullying) belongs at the top of the
1324
+ escalation list for Mikey with the report id, both usernames and a one-line
1325
+ summary; set it to `reviewing` with a note saying so. Spam, obvious
1326
+ misclicks and ordinary disagreements can be `dismissed` with a note.
1327
+ Recording a status only annotates the report: never message, ban or
1328
+ otherwise act against either member without Mikey's explicit go-ahead.
1329
+
1330
+ ### Child-safety holds, evidence export and incident runbook (Mikey only, added 2026-09-27)
1331
+
1332
+ > Not legal advice. Have a Korean lawyer review this procedure.
1333
+
1334
+ Mikey, 2026-09-27: "if someone did something wrong - especially if for
1335
+ example a teacher was being inappropriate to a kid - shouldnt we be able to
1336
+ help police investigate?"
1337
+
1338
+ **Automatic holds.** A report for sexual content or asking for personal
1339
+ info, or any report between a minor and an adult where both ages are known
1340
+ (an admin-approved birthdate, otherwise the birthdate on the profile), places
1341
+ a safety hold on the whole conversation and on both accounts. A second such
1342
+ report in the same conversation joins the existing hold. Zero, Ciel and the
1343
+ site-wide General channel are never held (a General report holds the two
1344
+ accounts). Zero's alert DM to Mikey says when a hold was placed, and
1345
+ `chat-reports show <id>` lists `safetyHoldIds`.
1346
+
1347
+ **What a hold does**, silently, until it is released: a member's delete or
1348
+ edit still works for everyone, but the message row stays and the text before
1349
+ an edit (or the row at deletion) is copied to the hold; topic edits, removed
1350
+ archived profile pictures and removed previous usernames are copied too.
1351
+ Permanent deletion of a held message or of a held member's post or comment is
1352
+ refused with a neutral "can't be permanently deleted right now". Twinkle has
1353
+ no account deletion, so accounts are never erased. Nothing about a hold is
1354
+ shown to members.
1355
+
1356
+ These commands are for Mikey. A daily run may read `list-holds` and report
1357
+ it, but never places, releases or exports a hold or suspends anyone without
1358
+ Mikey's explicit instruction for that case:
1359
+
1360
+ ```bash
1361
+ lumine admin chat-reports hold 12 --note "Why this case is held" --json
1362
+ lumine admin chat-reports hold --user 7,9 --channel 40 --note "Parent phoned" --json
1363
+ lumine admin chat-reports list-holds [--status active|released|all] --json
1364
+ lumine admin chat-reports suspend --user 7 --note "Adult under investigation" --json
1365
+ lumine admin chat-reports export 12 --out ~/twinkle-case-12 --json # or --hold <id>
1366
+ lumine admin chat-reports release 3 --note "Police closed the case" --json
1367
+ ```
1368
+
1369
+ Every hold, release, export and suspension is recorded with who and when
1370
+ (`chat_safety_actions`), and released holds keep their copies.
1371
+
1372
+ **Incident runbook** (for example, an adult or a teacher being inappropriate
1373
+ to a child):
1374
+
1375
+ 1. **Suspend the adult's access**: `chat-reports suspend --user <id> --note
1376
+ <why>`. This is the website's full ban: it signs them out and blocks the
1377
+ site. Lift it later from the website's Management ban editor.
1378
+ 2. **Place or confirm the hold**: `chat-reports hold <report-id> --note <why>`
1379
+ (or `--user`/`--channel` when there is no report yet, for example after a
1380
+ parent's call), then `list-holds` to check that it covers the conversation
1381
+ and both accounts.
1382
+ 3. **Export the evidence**: `chat-reports export <report-id> --out <new
1383
+ folder>`. The folder holds the whole conversation with UTC and KST times,
1384
+ deletions and held edits; both accounts (username, id, join date, verified
1385
+ email, birthdates, username history, linked Minecraft players, and the IP
1386
+ addresses the site stores for logouts, searches and page visits); the
1387
+ report(s); the actions taken; and `manifest.json` with each file's
1388
+ SHA-256. Write down the printed manifest hash, keep the folder private,
1389
+ and never edit it. Attachments are listed by storage key, not included.
1390
+ 4. **Contact the police**: 112 for an emergency or ongoing danger, or the
1391
+ cyber crime report system at https://ecrm.police.go.kr. Give them the
1392
+ export when they ask for it.
1393
+ 5. **Contact child protection**: report suspected child abuse to 112 (the
1394
+ national child-abuse report line), which connects to the local
1395
+ 아동보호전문기관 (child protection agency).
1396
+ 6. **Teachers and academy staff are mandatory reporters**: under Korea's Act
1397
+ on Special Cases Concerning the Punishment of Child Abuse Crimes, teachers
1398
+ and academy (hagwon) staff must report suspected child abuse. If the adult
1399
+ is a teacher, their employer may also need to be told; ask the lawyer.
1400
+ 7. **Do not** confront or warn the adult, delete anything, or share the
1401
+ evidence with anyone but the police or child-protection staff.
1402
+ 8. **Release the hold** only after the case is closed: `chat-reports release
1403
+ <hold-id> --note <why>`.
1404
+
1289
1405
  ## Private carry-over todos
1290
1406
 
1291
1407
  ```bash
@@ -3290,6 +3406,16 @@ acting, `off` disables):
3290
3406
  Separately from JEV, a run that reaches its round cap with nothing saved while
3291
3407
  Energy remains gets one extra apply-only round (`applyOnlyRound`).
3292
3408
 
3409
+ Before any JEV question (since 2026-09-26, API ea257c0a): when the remaining
3410
+ Energy, after the model's hand-off reserve, cannot cover two typical rounds
3411
+ (one read and one edit, `LUMINE_MIN_ROUNDS_FOR_PROJECT_EDIT`) on the chosen
3412
+ model but can on a lighter one, the run does not start and shows the
3413
+ switch-model card; nothing is spent. Lineage metadata records
3414
+ `tooTightForAnEdit`, `roundsOnThisModel` and `lighterModelRounds`. The
3415
+ composer checks the same arithmetic up front (model options carry
3416
+ `handoffReserveEnergyUnits`): it offers a one-tap switch, and it disables
3417
+ sending with an explanation when no model can afford even one step.
3418
+
3293
3419
  Every UTC day in `lumine admin energy-budget --json` now carries `pacing`.
3294
3420
  Report in **"Insights for Mikey"** in every full run, for the last completed
3295
3421
  day and the in-progress day while the feature is new: