@stage5/lumine 0.3.63 → 0.3.64
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-arena.js +126 -0
- package/lib/admin-receipts.js +7 -0
- package/lib/admin.js +93 -11
- package/lib/commands.js +10 -1
- package/lib/constants.js +15 -3
- package/lib/platformRules.generated.js +2 -1
- package/package.json +1 -1
- package/sdk/BUILD_SDK_INDEX.md +44 -5
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
// `lumine admin arena queue|import|status`: Arena's mock-exam item bank
|
|
5
|
+
// (twinkle-api controllers/cli/admin/arena.ts). Papers are written, blind-
|
|
6
|
+
// solved and style-checked by Claude Code agents before they arrive here.
|
|
7
|
+
// queue per exam, mode and item type: live items, the
|
|
8
|
+
// fewest unseen left for an active member, refills due
|
|
9
|
+
// import --dir <paper> a paper folder of <number>.json items; --audio
|
|
10
|
+
// audio/<exam>/<paper> attaches its recordings
|
|
11
|
+
// (uploaded first with twinkle-api
|
|
12
|
+
// scripts/arena-upload-media.ts); --live serves it
|
|
13
|
+
// at once; a dry run (validation only) unless --confirm;
|
|
14
|
+
// re-importing a folder retires that paper's earlier items
|
|
15
|
+
// status --item <id> --to live|retired|checked
|
|
16
|
+
export const ARENA_USAGE =
|
|
17
|
+
"Usage: lumine admin arena queue | import --dir <paper folder> [--audio audio/<exam>/<paper>] [--live] [--confirm] | status --item <id> --to live|retired|checked [--confirm] [--json]";
|
|
18
|
+
const MAX_ITEMS = 300;
|
|
19
|
+
|
|
20
|
+
function validationError(message) {
|
|
21
|
+
const error = new Error(message);
|
|
22
|
+
error.code = "CLI_ADMIN_CLI_VALIDATION";
|
|
23
|
+
return error;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function confirmed(options) {
|
|
27
|
+
const confirm = options.adminConfirm;
|
|
28
|
+
// absent is "", a bare --confirm is "true"
|
|
29
|
+
if (confirm !== "" && confirm !== "true") {
|
|
30
|
+
throw validationError("Pass --confirm on its own, without a value.");
|
|
31
|
+
}
|
|
32
|
+
return confirm === "true";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readPaper(dir) {
|
|
36
|
+
if (!dir) throw validationError(ARENA_USAGE);
|
|
37
|
+
const folder = path.resolve(dir);
|
|
38
|
+
let names;
|
|
39
|
+
try {
|
|
40
|
+
names = fs.readdirSync(folder).filter((name) => /^\d+\.json$/.test(name));
|
|
41
|
+
} catch {
|
|
42
|
+
throw validationError(`Cannot read the folder ${folder}.`);
|
|
43
|
+
}
|
|
44
|
+
if (!names.length || names.length > MAX_ITEMS) {
|
|
45
|
+
throw validationError(`A paper folder holds 1 to ${MAX_ITEMS} <number>.json items.`);
|
|
46
|
+
}
|
|
47
|
+
return names
|
|
48
|
+
.map((name) => JSON.parse(fs.readFileSync(path.join(folder, name), "utf8")))
|
|
49
|
+
.sort((a, b) => a.number - b.number);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function buildArenaOperation({ action, options }) {
|
|
53
|
+
if (!action || action === "queue") {
|
|
54
|
+
return {
|
|
55
|
+
name: "arena.queue",
|
|
56
|
+
method: "GET",
|
|
57
|
+
path: "/cli/admin/arena/queue",
|
|
58
|
+
body: undefined,
|
|
59
|
+
mutates: false,
|
|
60
|
+
requiresRun: false,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
if (action === "import") {
|
|
64
|
+
const items = readPaper(options.adminDir);
|
|
65
|
+
const confirm = confirmed(options);
|
|
66
|
+
return {
|
|
67
|
+
name: confirm ? "arena.import" : "arena.import.preview",
|
|
68
|
+
method: "POST",
|
|
69
|
+
path: "/cli/admin/arena/items/import",
|
|
70
|
+
body: {
|
|
71
|
+
items,
|
|
72
|
+
live: Boolean(options.adminArenaLive),
|
|
73
|
+
dryRun: !confirm,
|
|
74
|
+
...(options.adminArenaAudio ? { audioPrefix: options.adminArenaAudio } : {}),
|
|
75
|
+
// the folder name is the paper; re-importing it retires its earlier copy
|
|
76
|
+
paper: path.basename(path.resolve(options.adminDir)),
|
|
77
|
+
},
|
|
78
|
+
mutates: confirm,
|
|
79
|
+
requiresRun: false,
|
|
80
|
+
timeoutMs: 120000,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (action === "status") {
|
|
84
|
+
const id = Number(options.adminArenaItem);
|
|
85
|
+
const to = String(options.adminArenaTo || "");
|
|
86
|
+
if (!Number.isInteger(id) || id <= 0 || !["live", "retired", "checked"].includes(to)) {
|
|
87
|
+
throw validationError(ARENA_USAGE);
|
|
88
|
+
}
|
|
89
|
+
if (!confirmed(options)) {
|
|
90
|
+
throw validationError(`This changes what members are served: add --confirm to set item ${id} ${to}.`);
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
name: "arena.status",
|
|
94
|
+
method: "POST",
|
|
95
|
+
path: `/cli/admin/arena/items/${id}/status`,
|
|
96
|
+
body: { status: to },
|
|
97
|
+
mutates: true,
|
|
98
|
+
requiresRun: false,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
throw validationError(ARENA_USAGE);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function formatArenaResult({ operation, data }) {
|
|
105
|
+
if (operation.name === "arena.queue") {
|
|
106
|
+
const types = data?.types || [];
|
|
107
|
+
if (!types.length) return ["No live Arena items yet."];
|
|
108
|
+
const due = types.filter((t) => t.refillDue);
|
|
109
|
+
return [
|
|
110
|
+
...types.map(
|
|
111
|
+
(t) =>
|
|
112
|
+
`${t.examFamily} ${t.difficulty || "normal"} ${t.itemType}: ${t.live} live, ${t.minFresh} unseen for the least-supplied member${
|
|
113
|
+
t.refillDue ? ` -> write ${t.toWrite}` : ""
|
|
114
|
+
}`,
|
|
115
|
+
),
|
|
116
|
+
due.length ? `${due.length} type(s) due for a refill.` : "No refill due.",
|
|
117
|
+
];
|
|
118
|
+
}
|
|
119
|
+
if (operation.name === "arena.import.preview") {
|
|
120
|
+
return [`Dry run: ${data?.wouldImport ?? 0} item(s) pass validation. Add --confirm to import.`];
|
|
121
|
+
}
|
|
122
|
+
if (operation.name === "arena.import") {
|
|
123
|
+
return [`Imported ${data?.imported ?? 0} item(s).`];
|
|
124
|
+
}
|
|
125
|
+
return [`Item ${data?.id} is now ${data?.status}.`];
|
|
126
|
+
}
|
package/lib/admin-receipts.js
CHANGED
|
@@ -68,6 +68,13 @@ export function receiptToKeep(operation, result) {
|
|
|
68
68
|
verified: data.verified === true,
|
|
69
69
|
});
|
|
70
70
|
}
|
|
71
|
+
// an Arena paper's blurred page: the signed link to the private file is not kept
|
|
72
|
+
case "approvals.redact":
|
|
73
|
+
return keep({
|
|
74
|
+
submissionId: data.submissionId ?? null,
|
|
75
|
+
page: data.page ?? null,
|
|
76
|
+
kind: data.kind ?? null,
|
|
77
|
+
});
|
|
71
78
|
default:
|
|
72
79
|
// Bridge Builder writes answer with the whole crew view: members' who-
|
|
73
80
|
// are-you answers, parents' addresses and questions, held parent emails.
|
package/lib/admin.js
CHANGED
|
@@ -47,6 +47,7 @@ import { buildRolesOperation, printRoles } from "./admin-roles.js";
|
|
|
47
47
|
import { buildSpendingOperation, completeSpendingReport, formatSpending } from "./admin-spending.js";
|
|
48
48
|
import { buildCraftOperation, formatCraftResult } from "./admin-craft.js";
|
|
49
49
|
import { buildNonogramOperation, formatNonogramResult } from './admin-nonogram.js';
|
|
50
|
+
import { buildArenaOperation, formatArenaResult } from "./admin-arena.js";
|
|
50
51
|
import {
|
|
51
52
|
buildSlidingBlockOperation,
|
|
52
53
|
formatSlidingBlockResult,
|
|
@@ -331,9 +332,12 @@ function buildNotableScoringOperation({ action, target, options }) {
|
|
|
331
332
|
);
|
|
332
333
|
}
|
|
333
334
|
|
|
334
|
-
// Management approvals (dob/mentor/meetup
|
|
335
|
+
// Management approvals (dob/mentor/meetup, Arena score reports and papers)
|
|
336
|
+
// and the teacher audit.
|
|
335
337
|
const APPROVAL_USAGE =
|
|
336
|
-
"Use: lumine admin approvals list [--status pending|approved|rejected|settled|all] [--type dob|mentor|meetup] | show <id> | approve <id> [--reason <text>] | reject <id> [--reason <text>] | supermod <id> [--reason <text>]";
|
|
338
|
+
"Use: lumine admin approvals list [--status pending|approved|rejected|settled|all] [--type dob|mentor|meetup|arenaScore|arenaPaper] | show <id> | approve <id> [--reason <text>] | reject <id> [--reason <text>] | supermod <id> [--reason <text>] | redact <id> --page <n> --file <blurred.jpg|png|pdf>";
|
|
339
|
+
// the API's limit for one blurred page (helpers/arena/reportMedia.ts)
|
|
340
|
+
const ARENA_REDACTION_MAX_BYTES = 5 * 1024 * 1024;
|
|
337
341
|
const TEACHER_USAGE =
|
|
338
342
|
"Use: lumine admin teachers audit [--limit <1-100>] [--cursor <c>] [--all] | branches suggest|networks|confirm|unconfirm|set|clear|apply|list (`lumine admin teachers branches usage` prints each one) | revoke <userId> --reason <text> | review <userId> --decision legit|revoke --note <text> [--fingerprint <fp>] (revoke needs it) | review --all-flagged --expect <id[:fp],...> --note <text>";
|
|
339
343
|
|
|
@@ -343,6 +347,12 @@ function parseApprovalId(target) {
|
|
|
343
347
|
return Number(raw);
|
|
344
348
|
}
|
|
345
349
|
|
|
350
|
+
// parseChoice lowercases; the API's Arena types are camelCase
|
|
351
|
+
function parseApprovalType(value) {
|
|
352
|
+
const type = parseChoice(String(value), "--type", ["dob", "mentor", "meetup", "arenascore", "arenapaper"]);
|
|
353
|
+
return { arenascore: "arenaScore", arenapaper: "arenaPaper" }[type] || type;
|
|
354
|
+
}
|
|
355
|
+
|
|
346
356
|
function buildApprovalOperation({ action, target, options }) {
|
|
347
357
|
const reason = String(options.adminReason || "").trim();
|
|
348
358
|
if (!action || action === "list") {
|
|
@@ -357,13 +367,7 @@ function buildApprovalOperation({ action, target, options }) {
|
|
|
357
367
|
"settled",
|
|
358
368
|
"all",
|
|
359
369
|
]),
|
|
360
|
-
type: options.adminType
|
|
361
|
-
? parseChoice(String(options.adminType), "--type", [
|
|
362
|
-
"dob",
|
|
363
|
-
"mentor",
|
|
364
|
-
"meetup",
|
|
365
|
-
])
|
|
366
|
-
: "",
|
|
370
|
+
type: options.adminType ? parseApprovalType(options.adminType) : "",
|
|
367
371
|
}),
|
|
368
372
|
{ requiresRun: false },
|
|
369
373
|
);
|
|
@@ -374,6 +378,29 @@ function buildApprovalOperation({ action, target, options }) {
|
|
|
374
378
|
requiresRun: false,
|
|
375
379
|
});
|
|
376
380
|
}
|
|
381
|
+
// An Arena paper's page replaced by the reviewer's blurred copy (names,
|
|
382
|
+
// student numbers): the image or PDF travels as base64.
|
|
383
|
+
if (action === "redact") {
|
|
384
|
+
const page = parseRequiredInteger(options.adminPage, "--page", 1, 99);
|
|
385
|
+
if (!options.adminFile) throw cliValidationError(APPROVAL_USAGE);
|
|
386
|
+
const filePath = path.resolve(options.adminFile);
|
|
387
|
+
let buffer;
|
|
388
|
+
try {
|
|
389
|
+
buffer = readFileSync(filePath);
|
|
390
|
+
} catch {
|
|
391
|
+
throw cliValidationError(`Could not read ${filePath}.`);
|
|
392
|
+
}
|
|
393
|
+
if (!buffer.length || buffer.length > ARENA_REDACTION_MAX_BYTES) {
|
|
394
|
+
throw cliValidationError("The blurred page must be an image or PDF of at most 5 MB.");
|
|
395
|
+
}
|
|
396
|
+
return writeOperation(
|
|
397
|
+
"approvals.redact",
|
|
398
|
+
"POST",
|
|
399
|
+
`/cli/admin/approvals/${id}/redact`,
|
|
400
|
+
{ page, data: buffer.toString("base64") },
|
|
401
|
+
{ requiresRun: false, timeoutMs: 120000 },
|
|
402
|
+
);
|
|
403
|
+
}
|
|
377
404
|
// supermod: a teacher signup from academy staff becomes a supermod account
|
|
378
405
|
// (the account type staff get) instead of a teacher (Mikey 2026-10-05).
|
|
379
406
|
if (action === "approve" || action === "reject" || action === "supermod") {
|
|
@@ -2661,6 +2688,8 @@ export function parseAdminOperation(options) {
|
|
|
2661
2688
|
return buildSlidingBlockOperation({ action, options });
|
|
2662
2689
|
}
|
|
2663
2690
|
if (namespace === 'nonogram') return buildNonogramOperation({ action, target, options });
|
|
2691
|
+
// Arena's mock-exam item bank (admin-arena.js); no run.
|
|
2692
|
+
if (namespace === "arena") return buildArenaOperation({ action, options });
|
|
2664
2693
|
// Stuck Lumine Build plans and their free recheck (admin-build-plans.js).
|
|
2665
2694
|
if (namespace === "build-plans") {
|
|
2666
2695
|
return buildBuildPlansOperation({ action, options });
|
|
@@ -6777,7 +6806,45 @@ function printReviewEvents(events) {
|
|
|
6777
6806
|
);
|
|
6778
6807
|
}
|
|
6779
6808
|
|
|
6780
|
-
const APPROVAL_LABELS = {
|
|
6809
|
+
const APPROVAL_LABELS = {
|
|
6810
|
+
dob: "birthdate",
|
|
6811
|
+
mentor: "teacher",
|
|
6812
|
+
meetup: "meetup",
|
|
6813
|
+
arenaScore: "Arena score report",
|
|
6814
|
+
arenaPaper: "Arena paper",
|
|
6815
|
+
};
|
|
6816
|
+
|
|
6817
|
+
// One line describing an Arena item from its queue content.
|
|
6818
|
+
function arenaItemSummary(item) {
|
|
6819
|
+
const c = item.content || {};
|
|
6820
|
+
if (item.type === "arenaScore") {
|
|
6821
|
+
const entry = [c.gradeLabel, c.score != null ? `${c.score}/${c.maxScore ?? "?"}` : null].filter(Boolean).join(" · ");
|
|
6822
|
+
return `${c.sitting || "?"} ${c.exam || c.examKey || ""} · ${entry || "-"}${c.proof ? " · proof photo" : " · no proof"}`;
|
|
6823
|
+
}
|
|
6824
|
+
if (item.type === "arenaPaper") {
|
|
6825
|
+
return `${[c.school, c.gradeLevel, c.year, c.term, c.subject].filter(Boolean).join(" ") || c.examKind || "paper"} · ${c.pages ?? "?"} file${c.pages === 1 ? "" : "s"} (${c.examKind || "?"})`;
|
|
6826
|
+
}
|
|
6827
|
+
return "";
|
|
6828
|
+
}
|
|
6829
|
+
|
|
6830
|
+
function printArenaEvidence(arena) {
|
|
6831
|
+
if (!arena) return;
|
|
6832
|
+
if (arena.kind === "scoreReport") {
|
|
6833
|
+
console.log(`Report #${arena.reportId}: ${arena.exam?.name || arena.exam?.key} · ${arena.sitting} · grade ${arena.gradeLabel || "-"} · score ${arena.score ?? "-"}${arena.exam?.maxScore ? `/${arena.exam.maxScore}` : ""} · ${arena.status}`);
|
|
6834
|
+
console.log(arena.proofUrl ? `Proof (1-hour link): ${arena.proofUrl}` : "Proof: none sent");
|
|
6835
|
+
console.log(`Reward on approval: ${arena.reward?.xp?.toLocaleString?.("en-US") ?? arena.reward?.xp} XP${arena.reward?.paid ? " (paid)" : ""}`);
|
|
6836
|
+
if (arena.reviewNote) console.log(`Review note: ${arena.reviewNote}`);
|
|
6837
|
+
for (const other of arena.otherReports || []) console.log(` other report #${other.id}: ${other.title} · ${other.entry || "-"} · ${other.status}`);
|
|
6838
|
+
} else if (arena.kind === "paper") {
|
|
6839
|
+
const t = arena.tags || {};
|
|
6840
|
+
console.log(`Paper #${arena.submissionId}: ${[t.school, t.gradeLevel, t.year, t.term, t.subject].filter(Boolean).join(" ")} (${t.examKind}) · ${arena.status}`);
|
|
6841
|
+
for (const file of arena.files || []) console.log(` page ${file.page} (${file.kind}${file.redacted ? ", blurred" : ""}, 1-hour link): ${file.url}`);
|
|
6842
|
+
console.log(arena.duplicate ? `Duplicate: ${arena.duplicate.reason} of paper #${arena.duplicate.duplicateOf}` : "Duplicate: none found");
|
|
6843
|
+
console.log(`Rarity: ${arena.rarity || "-"} · reward ${arena.reward?.xp?.toLocaleString?.("en-US") ?? 0} XP + ${arena.reward?.coins ?? 0} coins${arena.reward?.onApprove ? " on approval" : arena.reward?.paid ? " (paid)" : ""}`);
|
|
6844
|
+
if (arena.reviewNote) console.log(`Review note: ${arena.reviewNote}`);
|
|
6845
|
+
}
|
|
6846
|
+
if (arena.check) console.log(`Check: ${arena.check}`);
|
|
6847
|
+
}
|
|
6781
6848
|
const approvalDate = (seconds) =>
|
|
6782
6849
|
seconds ? new Date(Number(seconds) * 1000).toISOString().slice(0, 10) : "?";
|
|
6783
6850
|
|
|
@@ -6964,7 +7031,7 @@ function printApprovalResult({ operation, data }) {
|
|
|
6964
7031
|
if (!items.length) return console.log("No approval requests.");
|
|
6965
7032
|
for (const item of items) {
|
|
6966
7033
|
console.log(
|
|
6967
|
-
`${item.ref} · ${APPROVAL_LABELS[item.type] || item.type} · ${item.status} · ${item.requesterUsername} (#${item.requesterId}) · ${approvalDate(item.requestedAt)}${item.flags ? ` · flags: ${item.flags.length ? item.flags.join(", ") : "none"}` : ""}`,
|
|
7034
|
+
`${item.ref} · ${APPROVAL_LABELS[item.type] || item.type} · ${item.status} · ${item.requesterUsername} (#${item.requesterId}) · ${approvalDate(item.requestedAt)}${item.flags ? ` · flags: ${item.flags.length ? item.flags.join(", ") : "none"}` : ""}${arenaItemSummary(item) ? ` · ${arenaItemSummary(item)}` : ""}`,
|
|
6968
7035
|
);
|
|
6969
7036
|
}
|
|
6970
7037
|
return;
|
|
@@ -6975,6 +7042,7 @@ function printApprovalResult({ operation, data }) {
|
|
|
6975
7042
|
`${item.ref} · ${APPROVAL_LABELS[item.type] || item.type} · ${item.status} · ${item.requesterUsername} (#${item.requesterId})`,
|
|
6976
7043
|
);
|
|
6977
7044
|
printEvidence(data.evidence);
|
|
7045
|
+
printArenaEvidence(data.arena);
|
|
6978
7046
|
if (data.meetupEvidence) console.log(JSON.stringify(data.meetupEvidence, null, 2));
|
|
6979
7047
|
return;
|
|
6980
7048
|
}
|
|
@@ -6982,6 +7050,16 @@ function printApprovalResult({ operation, data }) {
|
|
|
6982
7050
|
console.log(
|
|
6983
7051
|
`${data.ref} ${data.status}${data.grantedAs ? ` as ${data.grantedAs}` : ""}: ${APPROVAL_LABELS[data.type] || data.type} request from ${data.requesterUsername} (#${data.requesterId}).`,
|
|
6984
7052
|
);
|
|
7053
|
+
if (data.reward) {
|
|
7054
|
+
console.log(
|
|
7055
|
+
`Reward: ${data.reward.xp} XP${data.reward.coins ? ` + ${data.reward.coins} coins` : ""}${data.rarity ? ` (${data.rarity})` : ""}${data.duplicateOf ? ` · duplicate of paper #${data.duplicateOf}` : ""} · paid now ${data.paidNow?.xp || 0} XP${data.paidNow?.coins ? ` + ${data.paidNow.coins} coins` : ""}${data.paymentPending ? " · payment still owed: approve again to retry" : ""}`,
|
|
7056
|
+
);
|
|
7057
|
+
}
|
|
7058
|
+
if (data.reviewNote) console.log(`Note for the member: ${data.reviewNote}`);
|
|
7059
|
+
return;
|
|
7060
|
+
}
|
|
7061
|
+
if (operation.name === "approvals.redact") {
|
|
7062
|
+
console.log(`${data.ref}: page ${data.page} replaced with the blurred ${data.kind} (1-hour link: ${data.url}).`);
|
|
6985
7063
|
return;
|
|
6986
7064
|
}
|
|
6987
7065
|
if (operation.name === "teachers.audit") {
|
|
@@ -7507,6 +7585,10 @@ function printAdminResult({ operation, result }) {
|
|
|
7507
7585
|
printRoles(data);
|
|
7508
7586
|
return;
|
|
7509
7587
|
}
|
|
7588
|
+
if (operation.name.startsWith("arena.")) {
|
|
7589
|
+
for (const line of formatArenaResult({ operation, data })) console.log(line);
|
|
7590
|
+
return;
|
|
7591
|
+
}
|
|
7510
7592
|
if (operation.name.startsWith("sliding-block.")) {
|
|
7511
7593
|
for (const line of formatSlidingBlockResult({ operation, data }))
|
|
7512
7594
|
console.log(line);
|
package/lib/commands.js
CHANGED
|
@@ -2472,6 +2472,8 @@ export function parseArgs(args) {
|
|
|
2472
2472
|
const raw = {};
|
|
2473
2473
|
const positional = [];
|
|
2474
2474
|
const booleanFlags = new Set([
|
|
2475
|
+
// lumine admin arena import --live
|
|
2476
|
+
"live",
|
|
2475
2477
|
"remove",
|
|
2476
2478
|
"fault",
|
|
2477
2479
|
"noAnnounce",
|
|
@@ -2696,6 +2698,11 @@ export function parseArgs(args) {
|
|
|
2696
2698
|
adminOutput: raw.output ? String(raw.output) : "",
|
|
2697
2699
|
adminOutputDir: raw.outputDir ? String(raw.outputDir) : "",
|
|
2698
2700
|
adminDir: raw.dir ? String(raw.dir) : "",
|
|
2701
|
+
// lumine admin arena (admin-arena.js)
|
|
2702
|
+
adminArenaAudio: raw.audio ? String(raw.audio) : "",
|
|
2703
|
+
adminArenaLive: parseBoolean(raw.live, false),
|
|
2704
|
+
adminArenaItem: raw.item ? String(raw.item) : "",
|
|
2705
|
+
adminArenaTo: raw.to ? String(raw.to) : "",
|
|
2699
2706
|
adminClaimFile: raw.claim ? String(raw.claim) : "",
|
|
2700
2707
|
adminScaffoldFile: raw.scaffold ? String(raw.scaffold) : "",
|
|
2701
2708
|
adminTargetFile: raw.targetFile ? String(raw.targetFile) : "",
|
|
@@ -2854,6 +2861,7 @@ export function parseArgs(args) {
|
|
|
2854
2861
|
adminEditionId: raw.editionId ? String(raw.editionId) : "",
|
|
2855
2862
|
adminLeaseToken: raw.leaseToken ? String(raw.leaseToken) : "",
|
|
2856
2863
|
adminFile: raw.file ? String(raw.file) : "",
|
|
2864
|
+
adminPage: raw.page ? String(raw.page) : "",
|
|
2857
2865
|
adminQuestionId: raw.questionId ? String(raw.questionId) : "",
|
|
2858
2866
|
rewardPreflight: Boolean(raw.preflight),
|
|
2859
2867
|
rewardApprovalFile: raw.approvalFile ? String(raw.approvalFile) : "",
|
|
@@ -3298,7 +3306,7 @@ export function printHelp() {
|
|
|
3298
3306
|
lumine admin sponsor integrity get <case-id> [--json]
|
|
3299
3307
|
lumine admin sponsor integrity review <case-id> --decision clear|hold|flag|disqualify [--note <evidence>] [--json]
|
|
3300
3308
|
lumine admin review list [--type rewards|project|storage|cardcraft] [--status queue|pending|approved|rejected|all] [--cursor <c>] | show <type:id> [--dir <path>] | approve <type:id> [--config <rules.json>] [--size <500MB|1GB|2GB|n MB>] [--reason <text>] | reject|revoke <type:id> [--reason <text>] | propose rewards:<id> --dir <edited-snapshot> --config <rules.json>
|
|
3301
|
-
lumine admin approvals list [--status pending|approved|rejected|all] [--type dob|mentor|meetup] | show <id> | approve <id> [--reason <text>] | reject <id> [--reason <text>] | supermod <id> [--reason <text>] (management approvals with evidence: birthdates, teacher signups, meetups)
|
|
3309
|
+
lumine admin approvals list [--status pending|approved|rejected|all] [--type dob|mentor|meetup|arenaScore|arenaPaper] | show <id> | approve <id> [--reason <text>] | reject <id> [--reason <text>] | supermod <id> [--reason <text>] | redact <id> --page <n> --file <blurred image|pdf> (management approvals with evidence: birthdates, teacher signups, meetups, Arena score reports and papers with 1-hour file links)
|
|
3302
3310
|
lumine admin teachers audit [--limit <1-100>] [--cursor <c>] [--all] | revoke <userId> --reason <text> (approved teachers, a page at a time or --all: new flags first, then changed since your review; revoke removes the status cleanly)
|
|
3303
3311
|
lumine admin teachers branches suggest | networks | confirm <networkId> --branch <name> [--branch <name>] [--note <text>] | unconfirm <networkId> | set <userId|username> --branch <name> [--note <text>] | clear <userId|username> | apply | list [--branch <name>|none] (which branch each teacher works at, confirmed by you through the academy networks they use: suggest finds the networks, confirm names their branch (repeat --branch for a building branches share), apply gives teachers the branch most of their actions there point at and lists the unclear ones; set wins over networks)
|
|
3304
3312
|
lumine admin teachers review <userId> --decision legit|revoke --note <text> [--fingerprint <fp>, required for revoke] | review --all-flagged --expect <id[:fp],...> --note <text> (record your review of the facts shown; --all-flagged records only the --expect teachers; nothing is recorded if they changed since. A reviewed teacher comes back when its flags or linked accounts change; accounts sharing a network count only when they change a network flag)
|
|
@@ -3307,6 +3315,7 @@ export function printHelp() {
|
|
|
3307
3315
|
lumine admin cardcraft-review list [--status pending|approved|all] [--cursor <id>] | show <id> | approve|reject|revoke <id> [--reason <text>]
|
|
3308
3316
|
lumine admin craft seed --build <id> --file <pairs.json> [--confirm] | stats --build <id> [--limit <1-100>] | revise --build <id> --item <id|name> [--why <text>] [--emoji <emoji>] [--category <field>] [--confirm] | revise --build <id> --pair <pairId|a+b> --why <text> [--confirm] | retire --build <id> --item <id|name> --reason <text> [--confirm] | unfizzle --build <id> [--since <iso>] [--pair <a+b>] [--confirm] (Twinkle.craft curation: dry run without --confirm)
|
|
3309
3317
|
lumine admin sliding-block status | add --file <levels.json> [--confirm] (sliding-block-v1 campaign refills, RushHour: dry run without --confirm; the server solves each board and appends it)
|
|
3318
|
+
lumine admin arena queue | import --dir <paper folder> [--audio audio/<exam>/<paper>] [--live] [--confirm] | status --item <id> --to live|retired|checked --confirm (Arena's mock-exam bank: import is a validation-only dry run without --confirm; upload audio first with twinkle-api scripts/arena-upload-media.ts)
|
|
3310
3319
|
lumine admin nonogram status | add --file <pictures.json> | retire <key> | restore <key> [--confirm] (private reviewed pictures; changes are dry runs without --confirm)
|
|
3311
3320
|
lumine admin build-plans stuck [--days <1-90>] [--all] | recheck <buildId> [<buildId> ...] [--confirm] (Lumine Build plans stuck on one milestone: runs, Energy vs estimate, gradings, loop-guard state; recheck re-grades the current milestone with today's evaluator, free for the creator, dry run without --confirm)
|
|
3312
3321
|
lumine admin storage list [--status pending|approved|rejected|all] [--cursor <id>] | show <user-id-or-username> | approve <request-id> [--size <500MB|1GB|2GB|n MB>] [--reason <text>] | reject <request-id> [--reason <text>] | grant <user-id-or-username> --size <500MB|1GB|2GB|n MB> [--reason <text>]
|
package/lib/constants.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import os from "os";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { LUMINE_BUILDER_STANCE } from "./builderStance.generated.js";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
LUMINE_ESSENTIAL_SDK,
|
|
6
|
+
LUMINE_PLATFORM_RULES
|
|
7
|
+
} from "./platformRules.generated.js";
|
|
5
8
|
|
|
6
9
|
export const DEFAULT_API_URL = "https://api.twinkle.network";
|
|
7
10
|
export const DEFAULT_SITE_URL = "https://www.twin-kle.com";
|
|
@@ -160,6 +163,10 @@ ${LUMINE_MOBILE_SELECTION_GUIDANCE}
|
|
|
160
163
|
|
|
161
164
|
${LUMINE_PLATFORM_RULES}
|
|
162
165
|
|
|
166
|
+
## Essential SDK Features (add without being asked)
|
|
167
|
+
|
|
168
|
+
${LUMINE_ESSENTIAL_SDK}
|
|
169
|
+
|
|
163
170
|
${LUMINE_WORLD_UPDATE_GUIDANCE}
|
|
164
171
|
- Prefer existing documented Twinkle.* methods over guessing names from old code.
|
|
165
172
|
`;
|
|
@@ -187,9 +194,14 @@ ${LUMINE_BUILDER_STANCE}
|
|
|
187
194
|
|
|
188
195
|
${LUMINE_PLATFORM_RULES}
|
|
189
196
|
|
|
197
|
+
## Essential SDK Features (add without being asked)
|
|
198
|
+
|
|
199
|
+
${LUMINE_ESSENTIAL_SDK}
|
|
200
|
+
|
|
190
201
|
\`lumine check\` fails on forms, imports, SDK names, game layout and long-press
|
|
191
|
-
selection, and lists canvas page kind, dialogs
|
|
192
|
-
|
|
202
|
+
selection, and lists canvas page kind, dialogs, new windows and games that do
|
|
203
|
+
not keep the player's place as notices (they do not block); a pass does not
|
|
204
|
+
prove the other rules.
|
|
193
205
|
|
|
194
206
|
## Source Of Truth
|
|
195
207
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
// Generated by twinkle-api/scripts/generate-build-sdk-index.mjs from
|
|
2
|
-
// constants/buildSdkManifest.json (platformRules). Do not edit by hand.
|
|
2
|
+
// constants/buildSdkManifest.json (platformRules, essentialSdk). Do not edit by hand.
|
|
3
3
|
export const LUMINE_PLATFORM_RULES = "Each rule below is a way an app breaks once it runs inside Twinkle's sandboxed frame, even when the code itself is correct. They outrank every other guideline: follow them in every change, including small ones, and fix any break of them you see in code you touch.\n\n1. Every page that draws on a <canvas> (2D, WebGL or Three.js) says what kind of page it is on <html>: data-twinkle-game for a game that fills the screen, data-twinkle-document for a normal scrolling page with a drawing pad, chart, visualizer or background effect. Without it, unless the page lays itself out with Twinkle.preview, Twinkle guesses from the canvas, and any canvas larger than 160 px, even one hidden in a tab, locks the page to one screen so it no longer scrolls. Add the attribute together with the first canvas.\n2. Games (data-twinkle-game) size themselves from Twinkle.preview: subscribe with Twinkle.preview.subscribe(listener, { immediate: true }), size canvases and renderers from layout.stage or layout.viewport, and report the play area with Twinkle.preview.setPlayfield(). Never size a game from 100vh/100vw, window.innerWidth/innerHeight or visualViewport, and never draw a fake phone or browser frame unless the creator asks for a device mockup: those fight the host and leave the game blank, cropped or scrolling on phones. Pages marked data-twinkle-document use ordinary CSS layout instead.\n3. alert(), confirm() and prompt() are silently blocked: alert shows nothing, confirm() always returns false (so the action it guards never happens) and prompt() returns null. Use in-page UI instead: a message element, an in-page confirm step, an <input>.\n4. Apps cannot open new windows or tabs: window.open() and target=\"_blank\" links do nothing, and neither does a download link to an uploaded file. Open Twinkle pages with Twinkle.app.openContent(url) from a tap, save files with Twinkle.files.saveAs({ url, fileName }) (or { blob, fileName } for a file the app made), and show other web addresses as plain text.\n5. Do not use <form> elements: native form submission is blocked, so a submit button inside a form does nothing. Handle inputs and buttons in JavaScript.\n6. Code loads only from project files and Twinkle's vendor paths (/build/vendor/...). Package imports, CDN scripts, external network calls and app-local /api/* routes fail at runtime; use the Twinkle SDK for data, AI and storage.\n7. Call only Twinkle.* methods documented in the SDK reference. An invented or guessed method name throws and breaks the feature.\n8. Twinkle.privateDb and Twinkle.sharedDb save on a user action or as an occasional snapshot, never per frame, tick or keystroke: fast repeated writes are refused with 429 and the data is lost. A privateDb value is at most 16 KB. Keep live state in memory, and use Twinkle.world for realtime multiplayer.\n9. Start sound and image generation inside the viewer's tap or click handler. A Web Audio AudioContext stays silent until it is created or resumed in a tap (iPhones always enforce this), and Twinkle.ai.generateImage called from load or a timer fails with USER_ACTIVATION_REQUIRED.\n10. Call setPointerCapture only inside a pointerdown handler with that event's pointerId, wrapped in try/catch, and release only when hasPointerCapture says the element holds it. From click, mouse, timer or synthetic handlers it throws and the control stops working.\n11. Keyboard games call preventDefault for their keys (Space, arrows, WASD) from a window or document keydown listener added with { passive: false }, skipping inputs, textareas, selects and contenteditable, and make the playfield focusable; otherwise those keys scroll or jump the page during play.\n12. On phones a long press on game controls selects text or opens the Copy/Look Up menu unless the whole game area (controls, HUD, menus, empty play space) is marked data-twinkle-no-select and styled user-select: none; -webkit-user-select: none; -webkit-touch-callout: none. A game drawn without a canvas sets data-twinkle-game on <html> too, so Twinkle's phone touch protections apply. Keep inputs and copyable text selectable with data-twinkle-selectable.";
|
|
4
|
+
export const LUMINE_ESSENTIAL_SDK = "Features every app needs whenever they apply. When you build a new app or make a substantial change, add every one that applies as part of that change, without being asked. On a small, targeted request, add a missing one only when the request touches what it covers (a change to levels, scoring or saving: keep-place; a new screen: back-button); otherwise name it in one line so the creator can ask. Say in one line what you added. Never add one that does not apply.\n\n1. When: The app has a place worth keeping: a game, a level or score in play, an open document, drawing or editor, a quiz in progress, a chosen view. Use: Register Twinkle.app.lifecycle.onSuspend(() => smallState) and, at startup, restore from await Twinkle.app.lifecycle.getResumeState(). Phones unload apps that are not on screen (3D apps right away) and browsers kill tabs; without this the player starts over every time.\n2. When: The app has more than one view: a list and its details, menus, chapters, pages, settings. Use: Twinkle.app.history.push on each confirmed view change and Twinkle.app.history.subscribe to restore views, so Back and Forward move inside the app instead of leaving it.\n3. When: Progress, collections, unlocks, high scores or settings the viewer expects to still have tomorrow. Use: Twinkle.privateDb (Twinkle.sharedDb for what everyone sees), written on a user action or as an occasional snapshot, never per frame.\n4. When: The app shares or links to something inside itself (a level, a post, a result). Use: Twinkle.app.getShareUrl(pathSegment) for the link, and read the segment back at startup, instead of building twin-kle.com URLs by hand.";
|
package/package.json
CHANGED
package/sdk/BUILD_SDK_INDEX.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Build SDK Index
|
|
2
2
|
|
|
3
|
-
Version: 1.
|
|
4
|
-
Updated: 2026-10-
|
|
5
|
-
Generated: 2026-10-
|
|
3
|
+
Version: 1.73.0
|
|
4
|
+
Updated: 2026-10-25
|
|
5
|
+
Generated: 2026-10-25T00:59:10.277Z
|
|
6
6
|
|
|
7
7
|
## Platform rules
|
|
8
8
|
Each rule below is a way an app breaks once it runs inside Twinkle's sandboxed frame, even when the code itself is correct. They outrank every other guideline: follow them in every change, including small ones, and fix any break of them you see in code you touch.
|
|
@@ -20,6 +20,14 @@ Each rule below is a way an app breaks once it runs inside Twinkle's sandboxed f
|
|
|
20
20
|
11. Keyboard games call preventDefault for their keys (Space, arrows, WASD) from a window or document keydown listener added with { passive: false }, skipping inputs, textareas, selects and contenteditable, and make the playfield focusable; otherwise those keys scroll or jump the page during play.
|
|
21
21
|
12. On phones a long press on game controls selects text or opens the Copy/Look Up menu unless the whole game area (controls, HUD, menus, empty play space) is marked data-twinkle-no-select and styled user-select: none; -webkit-user-select: none; -webkit-touch-callout: none. A game drawn without a canvas sets data-twinkle-game on <html> too, so Twinkle's phone touch protections apply. Keep inputs and copyable text selectable with data-twinkle-selectable.
|
|
22
22
|
|
|
23
|
+
## Essential SDK features
|
|
24
|
+
Features every app needs whenever they apply. When you build a new app or make a substantial change, add every one that applies as part of that change, without being asked. On a small, targeted request, add a missing one only when the request touches what it covers (a change to levels, scoring or saving: keep-place; a new screen: back-button); otherwise name it in one line so the creator can ask. Say in one line what you added. Never add one that does not apply.
|
|
25
|
+
|
|
26
|
+
1. When: The app has a place worth keeping: a game, a level or score in play, an open document, drawing or editor, a quiz in progress, a chosen view. Use: Register Twinkle.app.lifecycle.onSuspend(() => smallState) and, at startup, restore from await Twinkle.app.lifecycle.getResumeState(). Phones unload apps that are not on screen (3D apps right away) and browsers kill tabs; without this the player starts over every time.
|
|
27
|
+
2. When: The app has more than one view: a list and its details, menus, chapters, pages, settings. Use: Twinkle.app.history.push on each confirmed view change and Twinkle.app.history.subscribe to restore views, so Back and Forward move inside the app instead of leaving it.
|
|
28
|
+
3. When: Progress, collections, unlocks, high scores or settings the viewer expects to still have tomorrow. Use: Twinkle.privateDb (Twinkle.sharedDb for what everyone sees), written on a user action or as an occasional snapshot, never per frame.
|
|
29
|
+
4. When: The app shares or links to something inside itself (a level, a post, a result). Use: Twinkle.app.getShareUrl(pathSegment) for the link, and read the segment back at startup, instead of building twin-kle.com URLs by hand.
|
|
30
|
+
|
|
23
31
|
## Notes
|
|
24
32
|
- Reward is part of completion: a reward-paying run must stay pending until awarded:true and its canonical receipt (or getReceipt status awarded). Do not show final solved/completed status, advance rewarded progress, or discard finished proof before that. Preserve the exact challenge/proof and acknowledged progress in a durable per-viewer outbox; reconcile getReceipt after a lost reply and retry the same challenge, never inventing a payment. Explain explicit practice, already collected, exhausted approved limits, preview and disclosed zero-value retry outcomes. Use the start response as a conservative elapsed-time reference, and test delayed start, network failure, lost claim reply, reload, day/viewer change and duplicate recovery.
|
|
25
33
|
- This SDK is injected into Build iframes via the Build preview/runtime.
|
|
@@ -119,6 +127,26 @@ files:read, media:read, media:write, live:read, live:write, user:read, users:rea
|
|
|
119
127
|
- Returns: Canonical shareable deep-link URL string, or null when app info is unavailable
|
|
120
128
|
- Builds a canonical shareable deep link into this app, e.g. https://www.twin-kle.com/app/884/432-the-great-gatsby.
|
|
121
129
|
- Example: await Twinkle.app.getShareUrl('432-the-great-gatsby');
|
|
130
|
+
- lifecycle.onSuspend(getState) | scopes: none
|
|
131
|
+
- Returns: off function
|
|
132
|
+
- Keep the viewer's place: Twinkle calls getState() for a small snapshot whenever the app is hidden, goes to the background, or every so often while in use, and hands it back the next time the app starts.
|
|
133
|
+
- Phones keep few apps loaded: an app that is not on screen can be unloaded at any time (3D apps right away), and the browser can kill the whole tab. Without a snapshot the viewer starts over.
|
|
134
|
+
- getState() returns a plain JSON object of at most 1 MB and must be quick (a 1.5 s budget, sync or a promise). Save what is needed to put the viewer back: level, position, score in play, the open view, unsaved edits, a quiz in progress.
|
|
135
|
+
- One handler per app; registering again replaces it. Long-term saves (collections, completed progress) still belong in Twinkle.privateDb.
|
|
136
|
+
- After a restart with a saved place, Twinkle keeps that place until the viewer does something in the app (a tap or key): snapshots taken while the app is still loading or waiting on a CONTINUE screen are not sent. Call saveNow() if the app changes the place on its own before that.
|
|
137
|
+
- Example: Twinkle.app.lifecycle.onSuspend(() => ({ level, hp, x: player.x, y: player.y, inventory }));
|
|
138
|
+
- async lifecycle.getResumeState() | scopes: none
|
|
139
|
+
- Returns: { state, savedAt, reason } or null
|
|
140
|
+
- At startup, the snapshot this viewer's last session of the app left behind (after an unload, a tab reload or crash), or null.
|
|
141
|
+
- Resolves once, early; await it before drawing the first screen so the viewer does not see a fresh start flash by.
|
|
142
|
+
- Check the snapshot's shape before using it: it can come from an older version of the app. Ignore what you do not recognise.
|
|
143
|
+
- Closing the app with its Close button starts it fresh next time (no snapshot). The Build editor preview always starts fresh.
|
|
144
|
+
- Example: const saved = await Twinkle.app.lifecycle.getResumeState(); if (saved) restore(saved.state);
|
|
145
|
+
- async lifecycle.saveNow() | scopes: none
|
|
146
|
+
- Returns: true once Twinkle has the snapshot, false otherwise
|
|
147
|
+
- Take a snapshot right now, e.g. at a checkpoint or after an important step.
|
|
148
|
+
- Needs an onSuspend handler. Twinkle already snapshots on hide, background and every 45 s of use, so call this only at moments worth keeping exactly.
|
|
149
|
+
- Example: await Twinkle.app.lifecycle.saveNow();
|
|
122
150
|
- history.getState() | scopes: none
|
|
123
151
|
- Returns: The current app-owned history state object, or null
|
|
124
152
|
- Read the current Build app view state stored through Twinkle.app.history.
|
|
@@ -1015,10 +1043,11 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
|
|
|
1015
1043
|
- cells is a flat list of [x, y, z, paletteIndex] numbers; air is left out. palette entries are block states like "oak_stairs[facing=north,half=bottom]".
|
|
1016
1044
|
- Private designs are only returned to their designer.
|
|
1017
1045
|
- Example: const { size, palette, cells } = await Twinkle.minecraft.getDesign({ id: 'harbor_house' }); for (let i = 0; i < cells.length; i += 4) addCube(cells[i], cells[i + 1], cells[i + 2], palette[cells[i + 3]]);
|
|
1018
|
-
- async saveDesign({ id, name, summary, category, tags, visibility, parts }) | scopes: content:write
|
|
1046
|
+
- async saveDesign({ id, name, summary, category, tags, visibility, parts, cells }) | scopes: content:write
|
|
1019
1047
|
- Returns: { design: { id, version, name, category, summary, size: { width, height, depth }, blocks, status, author, authorUserId, visibility: 'private'|'public', previewUrl, createdAt } }
|
|
1020
|
-
- Save a design (or a new version of your design) from blueprint parts.
|
|
1048
|
+
- Save a design (or a new version of your design) from blueprint parts, or from exact blocks (cells).
|
|
1021
1049
|
- 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.
|
|
1050
|
+
- Exact blocks instead of parts (map art made from a picture, a block-by-block design): cells: { palette: ['white_wool', 'black_concrete', ...], cells: [x, y, z, paletteIndex, x, y, z, paletteIndex, ...] }, offsets as for parts. Up to 256 palette blocks and 60,000 blocks (Mikey 200,000), within 128 wide, 384 tall and 128 deep. Send parts or cells, not both.
|
|
1022
1051
|
- 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.
|
|
1023
1052
|
- 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.
|
|
1024
1053
|
- 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' }] });
|
|
@@ -1114,6 +1143,16 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
|
|
|
1114
1143
|
- Remove the viewer's arrival point in a world (back to the shared arrival).
|
|
1115
1144
|
- world is a world id such as world, world1, world3, world_nether.
|
|
1116
1145
|
- Example: await Twinkle.minecraft.clearArrivalPoint({ world: 'world3' });
|
|
1146
|
+
- async getMyNotices() | scopes: content:read
|
|
1147
|
+
- Returns: { notices: [{ id, text, from, at, uuid, name }] }
|
|
1148
|
+
- Personal news for the viewer's linked Minecraft accounts, for example that something they wanted and gave up on works now.
|
|
1149
|
+
- Only unread ones. A notice is read once seen anywhere: Zero whispers it when the player joins the server, or the app marks it with markNoticesSeen. Empty when the viewer has no linked Minecraft account. uuid/name say which linked account it is for.
|
|
1150
|
+
- Example: const { notices } = await Twinkle.minecraft.getMyNotices();
|
|
1151
|
+
- async markNoticesSeen({ uuid, ids }) | scopes: content:write
|
|
1152
|
+
- Returns: { marked }
|
|
1153
|
+
- Mark personal news as read after showing it (ids from getMyNotices, for that notice's uuid).
|
|
1154
|
+
- Only the viewer's own linked accounts; up to 50 ids at a time.
|
|
1155
|
+
- Example: await Twinkle.minecraft.markNoticesSeen({ uuid: notice.uuid, ids: [notice.id] });
|
|
1117
1156
|
- async getPlayAreas() | scopes: content:read
|
|
1118
1157
|
- 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 }] }
|
|
1119
1158
|
- Play areas: captured Minecraft builds (a castle, a cathedral) that apps turn into first-person games, newest first.
|