@stage5/lumine 0.3.79 → 0.3.81
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 +67 -1
- package/lib/commands.js +2 -0
- package/lib/platformRules.generated.js +1 -1
- package/package.json +1 -1
- package/sdk/BUILD_SDK_INDEX.md +31 -17
- package/sdk/LUMINE_ADMIN.md +38 -0
package/lib/admin-arena.js
CHANGED
|
@@ -13,13 +13,16 @@ import path from "node:path";
|
|
|
13
13
|
// at once; a dry run (validation only) unless --confirm;
|
|
14
14
|
// re-importing a folder retires that paper's earlier items
|
|
15
15
|
// status --item <id> --to live|retired|checked
|
|
16
|
+
// today-withdraw --user <id> (--keys <k,...> | --file <withdraw.json>) --reason <text>
|
|
17
|
+
// withdraw that member's unserved queued Today's
|
|
18
|
+
// Challenge questions (preview unless --confirm; audited)
|
|
16
19
|
// picture-audit [--items <ids> | --cursor <id>] [--limit 1-20] [--renders <dir>]
|
|
17
20
|
// advisory JEV image review list of live picture
|
|
18
21
|
// items; never retires or edits. --renders holds
|
|
19
22
|
// <itemId>.png renders of picture-option items
|
|
20
23
|
// (panels are drawn by the web, not stored)
|
|
21
24
|
export const ARENA_USAGE =
|
|
22
|
-
"Usage: lumine admin arena queue | paper-queue [--cursor <id>] [--limit <1-100>] | marking [--cursor <id>] [--limit <1-200>] | import (--dir <paper folder> | --additive --file <reviewed-items.json>) [--audio audio/<exam>/<paper>] [--live] [--confirm] | status --item <id> --to live|retired|checked [--confirm] | today-queue [--cursor <id>] [--limit <1-100>] | today-member --user <id> | today-import --file <reviewed-questions.json> [--confirm] | picture-audit [--items <id,id,...> | --cursor <id>] [--limit <1-20>] [--renders <dir of <itemId>.png>] [--output <review-list.json>] [--json]";
|
|
25
|
+
"Usage: lumine admin arena queue | paper-queue [--cursor <id>] [--limit <1-100>] | marking [--cursor <id>] [--limit <1-200>] | import (--dir <paper folder> | --additive --file <reviewed-items.json>) [--audio audio/<exam>/<paper>] [--live] [--confirm] | status --item <id> --to live|retired|checked [--confirm] | today-queue [--cursor <id>] [--limit <1-100>] | today-member --user <id> | today-import --file <reviewed-questions.json> [--confirm] | today-withdraw --user <id> (--keys <key,...> | --file <withdraw.json>) --reason <text> [--confirm] | picture-audit [--items <id,id,...> | --cursor <id>] [--limit <1-20>] [--renders <dir of <itemId>.png>] [--output <review-list.json>] [--json]";
|
|
23
26
|
const MAX_ITEMS = 300;
|
|
24
27
|
|
|
25
28
|
function validationError(message) {
|
|
@@ -95,6 +98,58 @@ function readRenders(dir, itemIds) {
|
|
|
95
98
|
return renders;
|
|
96
99
|
}
|
|
97
100
|
|
|
101
|
+
const TODAY_WITHDRAW_USAGE =
|
|
102
|
+
'Use today-withdraw --user <member id> (--keys <key,key,...> | --file <withdraw.json>) --reason "<why>" [--confirm].';
|
|
103
|
+
const TODAY_WITHDRAW_MAX_KEYS = 100;
|
|
104
|
+
|
|
105
|
+
// A withdrawal file is an array of keys, or an object with `keys` (or
|
|
106
|
+
// `authorKeys`, or `items` of {key|authorKey}), an optional `userId` that must
|
|
107
|
+
// match --user, and an optional `reason` used when --reason is absent.
|
|
108
|
+
function readWithdrawFile(file, userId) {
|
|
109
|
+
const resolved = path.resolve(file);
|
|
110
|
+
let parsed;
|
|
111
|
+
try {
|
|
112
|
+
if (fs.statSync(resolved).size > 200_000) throw validationError("The withdrawal file must be under 200 KB.");
|
|
113
|
+
parsed = JSON.parse(fs.readFileSync(resolved, "utf8"));
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (error.code === "CLI_ADMIN_CLI_VALIDATION") throw error;
|
|
116
|
+
throw validationError(`Cannot read the withdrawal file ${resolved}: ${error.message}`);
|
|
117
|
+
}
|
|
118
|
+
const object = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
119
|
+
if (object?.userId !== undefined && Number(object.userId) !== userId) {
|
|
120
|
+
throw validationError(`The withdrawal file is for member ${object.userId}, not --user ${userId}.`);
|
|
121
|
+
}
|
|
122
|
+
const list = Array.isArray(parsed) ? parsed : object?.keys || object?.authorKeys || object?.items;
|
|
123
|
+
if (!Array.isArray(list)) throw validationError("The withdrawal file needs a list of question keys (an array, or {keys:[...]}).");
|
|
124
|
+
const keys = list.map((entry) => (typeof entry === "string" ? entry : entry?.authorKey ?? entry?.key));
|
|
125
|
+
return { keys, reason: typeof object?.reason === "string" ? object.reason : "" };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function buildTodayWithdraw(options) {
|
|
129
|
+
const userId = Number(options.adminUser);
|
|
130
|
+
if (!Number.isSafeInteger(userId) || userId <= 0) throw validationError(TODAY_WITHDRAW_USAGE);
|
|
131
|
+
const keyList = String(options.adminArenaKeys || "");
|
|
132
|
+
if (Boolean(keyList) === Boolean(options.adminFile)) throw validationError(`Pass exactly one of --keys or --file. ${TODAY_WITHDRAW_USAGE}`);
|
|
133
|
+
const fromFile = options.adminFile ? readWithdrawFile(options.adminFile, userId) : null;
|
|
134
|
+
const keys = fromFile ? fromFile.keys : keyList.split(",").map((key) => key.trim()).filter(Boolean);
|
|
135
|
+
if (!keys.length || keys.length > TODAY_WITHDRAW_MAX_KEYS ||
|
|
136
|
+
keys.some((key) => typeof key !== "string" || !/^[a-zA-Z0-9_-]{1,100}$/.test(key))) {
|
|
137
|
+
throw validationError(`Withdraw 1-${TODAY_WITHDRAW_MAX_KEYS} question keys (letters, digits, _ and -).`);
|
|
138
|
+
}
|
|
139
|
+
if (new Set(keys).size !== keys.length) throw validationError("Each question key may appear once.");
|
|
140
|
+
const reason = String(options.adminReason || fromFile?.reason || "").trim();
|
|
141
|
+
if (reason.length < 10 || reason.length > 500) throw validationError(`Give a 10-500 character --reason. ${TODAY_WITHDRAW_USAGE}`);
|
|
142
|
+
const confirm = confirmed(options);
|
|
143
|
+
return {
|
|
144
|
+
name: confirm ? "arena.today.withdraw" : "arena.today.withdraw.preview",
|
|
145
|
+
method: "POST",
|
|
146
|
+
path: "/cli/admin/arena/today/withdraw",
|
|
147
|
+
body: { userId, keys, reason, dryRun: !confirm },
|
|
148
|
+
mutates: confirm,
|
|
149
|
+
requiresRun: false,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
98
153
|
export function buildArenaOperation({ action, options }) {
|
|
99
154
|
if (action === "picture-audit") {
|
|
100
155
|
const limit = Number(options.adminLimit ?? 10);
|
|
@@ -146,6 +201,7 @@ export function buildArenaOperation({ action, options }) {
|
|
|
146
201
|
const confirm = confirmed(options);
|
|
147
202
|
return { name: confirm ? "arena.today.import" : "arena.today.preview", method: "POST", path: "/cli/admin/arena/today/import", body: { ...payload, dryRun: !confirm }, mutates: confirm, requiresRun: false };
|
|
148
203
|
}
|
|
204
|
+
if (action === "today-withdraw") return buildTodayWithdraw(options);
|
|
149
205
|
if (action === "marking") {
|
|
150
206
|
const cursor = Number(options.adminCursor || 0);
|
|
151
207
|
const limit = Number(options.adminLimit ?? 100);
|
|
@@ -245,6 +301,16 @@ export function formatArenaResult({ operation, data }) {
|
|
|
245
301
|
if (operation.name === "arena.today.preview" || operation.name === "arena.today.import") return [
|
|
246
302
|
`${data?.dryRun ? "Dry run" : "Saved"}: ${data?.questions?.length || 0} reviewed personal questions.${data?.dryRun ? " Add --confirm to make new questions available." : ""}`,
|
|
247
303
|
];
|
|
304
|
+
if (operation.name === "arena.today.withdraw.preview" || operation.name === "arena.today.withdraw") {
|
|
305
|
+
const withdrawn = data?.withdrawn || [];
|
|
306
|
+
const skipped = data?.skipped || [];
|
|
307
|
+
return [
|
|
308
|
+
`${data?.dryRun ? "Dry run: would withdraw" : "Withdrew"} ${withdrawn.length} queued question(s) for member ${data?.userId}${withdrawn.length ? `: ${withdrawn.map((q) => q.key).join(", ")}` : ""}.`,
|
|
309
|
+
...skipped.map((s) => `Skipped ${s.key}: ${s.reason} (${s.detail})`),
|
|
310
|
+
`Ready at preferences v${data?.revision ?? "-"}: ${data?.readyBefore ?? 0} before, ${data?.readyAfter ?? 0} after${data?.refillDue ? `; refill to ${data?.target}` : ""}.`,
|
|
311
|
+
...(data?.dryRun && withdrawn.length ? ["Add --confirm to withdraw them."] : []),
|
|
312
|
+
];
|
|
313
|
+
}
|
|
248
314
|
if (operation.name === "arena.marking") {
|
|
249
315
|
const entries = data?.entries || [];
|
|
250
316
|
return [
|
package/lib/commands.js
CHANGED
|
@@ -2726,6 +2726,7 @@ export function parseArgs(args) {
|
|
|
2726
2726
|
adminArenaTo: raw.to ? String(raw.to) : "",
|
|
2727
2727
|
adminArenaRenders: raw.renders ? String(raw.renders) : "",
|
|
2728
2728
|
adminArenaItems: raw.items ? String(raw.items) : "",
|
|
2729
|
+
adminArenaKeys: raw.keys ? String(raw.keys) : "",
|
|
2729
2730
|
// lumine admin jev-images (admin-jev-images.js)
|
|
2730
2731
|
adminJevArea: raw.area ? String(raw.area) : "",
|
|
2731
2732
|
adminJevKey: raw.key ? String(raw.key) : "",
|
|
@@ -3351,6 +3352,7 @@ export function printHelp() {
|
|
|
3351
3352
|
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)
|
|
3352
3353
|
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)
|
|
3353
3354
|
lumine admin arena queue | paper-queue [--cursor <id>] [--limit <1-100>] | import --dir <paper folder> [--audio audio/<exam>/<paper>] [--live] [--confirm] | status --item <id> --to live|retired|checked --confirm | picture-audit [--items <id,id,...> | --cursor <id>] [--limit 1-20] [--renders <dir of <itemId>.png>] [--output <review-list.json>] (Arena's mock-exam bank; picture-audit is an advisory JEV image review list that never retires or edits items; import is a validation-only dry run without --confirm; upload audio first with twinkle-api scripts/arena-upload-media.ts)
|
|
3355
|
+
lumine admin arena today-queue [--cursor <id>] | today-member --user <id> | today-import --file <reviewed-questions.json> [--confirm] | today-withdraw --user <id> (--keys <key,...> | --file <withdraw.json>) --reason <text> [--confirm] (Today's Challenge personal queues; import and withdraw are dry runs without --confirm; withdraw removes only unserved queued questions and is audited)
|
|
3354
3356
|
lumine admin nonogram status | add --file <pictures.json> | retire <key> | restore <key> [--confirm] (private reviewed pictures; changes are dry runs without --confirm)
|
|
3355
3357
|
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)
|
|
3356
3358
|
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>]
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// Generated by twinkle-api/scripts/generate-build-sdk-index.mjs from
|
|
2
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.";
|
|
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 or tab, a long page the viewer scrolls. Use: Register Twinkle.app.lifecycle.onSuspend(() => smallState, { work: [...] }) and, at startup, restore from await Twinkle.app.lifecycle.getResumeState(). The snapshot holds where the viewer is looking (scroll, open tab or view, selected item) and unfinished work (a game or level in play, a half-written answer, an unsaved drawing, a quiz in progress); list the work keys in { work }. A return (the phone unloaded the app, a reload, a quick switch away) gets everything back; a new visit (the next day, a new tab) starts at the app's start and gets only the work keys. 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-11-
|
|
5
|
-
Generated: 2026-11-02T01:
|
|
3
|
+
Version: 1.80.0
|
|
4
|
+
Updated: 2026-11-02
|
|
5
|
+
Generated: 2026-11-02T01:36:03.543Z
|
|
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.
|
|
@@ -23,7 +23,7 @@ Each rule below is a way an app breaks once it runs inside Twinkle's sandboxed f
|
|
|
23
23
|
## Essential SDK features
|
|
24
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
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.
|
|
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 or tab, a long page the viewer scrolls. Use: Register Twinkle.app.lifecycle.onSuspend(() => smallState, { work: [...] }) and, at startup, restore from await Twinkle.app.lifecycle.getResumeState(). The snapshot holds where the viewer is looking (scroll, open tab or view, selected item) and unfinished work (a game or level in play, a half-written answer, an unsaved drawing, a quiz in progress); list the work keys in { work }. A return (the phone unloaded the app, a reload, a quick switch away) gets everything back; a new visit (the next day, a new tab) starts at the app's start and gets only the work keys. 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
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
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
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.
|
|
@@ -128,21 +128,32 @@ files:read, media:read, media:write, live:read, live:write, user:read, users:rea
|
|
|
128
128
|
- Returns: Canonical shareable deep-link URL string, or null when app info is unavailable
|
|
129
129
|
- Builds a canonical shareable deep link into this app, e.g. https://www.twin-kle.com/app/884/432-the-great-gatsby.
|
|
130
130
|
- Example: await Twinkle.app.getShareUrl('432-the-great-gatsby');
|
|
131
|
-
- lifecycle.onSuspend(getState) | scopes: none
|
|
131
|
+
- lifecycle.onSuspend(getState, { work }) | scopes: none
|
|
132
132
|
- Returns: off function
|
|
133
|
-
- 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
|
|
133
|
+
- 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. A return gets all of it back; a new visit gets only the keys listed in work.
|
|
134
134
|
- 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.
|
|
135
|
-
-
|
|
136
|
-
-
|
|
137
|
-
-
|
|
138
|
-
-
|
|
135
|
+
- Return or new visit: a start is a RETURN when the snapshot was taken in the same browser tab less than 30 minutes before (the phone unloaded the app, a reload, a quick switch away) and gets the whole snapshot back. Anything else is a NEW VISIT (the next day, after a long gap, a new tab): the app opens at its start, at the top, and gets back only the keys listed in work.
|
|
136
|
+
- work: the top-level snapshot keys that are unfinished work the viewer would be sorry to lose: a game or level in play, a half-written answer or draft, an unsaved drawing, a quiz in progress. Use work: true when the whole snapshot is that. Leave out where the viewer was looking (scroll position, open tab or view, selected article): it only comes back on a return. With no work option a new visit starts fresh.
|
|
137
|
+
- When the work is done (level won, answer sent, drawing saved) return that key as null: the next visit then starts fresh.
|
|
138
|
+
- 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 ids and small values, not whole data sets.
|
|
139
|
+
- One handler per app; registering again replaces it (with its work option). Long-term saves (collections, completed progress, settings) still belong in Twinkle.privateDb.
|
|
140
|
+
- After a restart with a saved place, Twinkle keeps that place until the viewer does something in the app (a tap, key or wheel scroll): 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.
|
|
141
|
+
- Example: Twinkle.app.lifecycle.onSuspend(() => ({ scrollY: Math.round(window.scrollY), tab, game: inPlay ? { level, hp, x: player.x, y: player.y } : null }), { work: ['game'] });
|
|
139
142
|
- async lifecycle.getResumeState() | scopes: none
|
|
140
|
-
- Returns: { state, savedAt, reason } or null
|
|
141
|
-
- At startup, the
|
|
143
|
+
- Returns: { kind: 'return' | 'new_visit', state, savedAt, ageMs, reason } or null
|
|
144
|
+
- At startup, what the viewer's last session of the app left: on a return the whole snapshot, on a new visit only its work keys, or null to start fresh.
|
|
142
145
|
- Resolves once, early; await it before drawing the first screen so the viewer does not see a fresh start flash by.
|
|
143
|
-
-
|
|
146
|
+
- kind 'return': the same browser tab within 30 minutes (unloaded, reloaded, quick switch): state is the whole snapshot, so put the viewer back exactly, scroll included. kind 'new_visit': state holds only the work keys; open at the start (top of the page, first tab) and bring the unfinished work back. null: nothing to restore, start fresh.
|
|
147
|
+
- Restore from whatever saved.state contains: Twinkle has already left the scroll and view out on a new visit, so the usual if (saved) restore(saved.state) is right for both kinds. Check kind only to show something different, such as a 'Continue your game?' card on a new visit.
|
|
148
|
+
- Check the snapshot's shape before using it: it can come from an older version of the app, and on a new visit only work keys are there. Ignore what you do not recognise.
|
|
144
149
|
- Closing the app with its Close button starts it fresh next time (no snapshot). The Build editor preview always starts fresh.
|
|
145
|
-
- Example: const saved = await Twinkle.app.lifecycle.getResumeState(); if (saved) restore(saved.state);
|
|
150
|
+
- Example: const saved = await Twinkle.app.lifecycle.getResumeState(); if (saved) restore(saved.state); // scroll and view are only in state on a return
|
|
151
|
+
- lifecycle.onResume(handler) | scopes: none
|
|
152
|
+
- Returns: off function
|
|
153
|
+
- Hear when the app comes back on screen without restarting (Twinkle kept it loaded, or the browser froze the page and thawed it): handler({ kind: 'return' | 'new_visit', awayMs }).
|
|
154
|
+
- Same rule as a restart: away less than 30 minutes is a return (leave everything as it is); longer is a new visit, so do what a fresh start would: back to the start and the top, unfinished work kept.
|
|
155
|
+
- Called each time the app is back on screen after being hidden or in the background; not at startup (use getResumeState() there).
|
|
156
|
+
- Example: Twinkle.app.lifecycle.onResume(({ kind }) => { if (kind === 'new_visit') { openFrontPage(); window.scrollTo(0, 0); } });
|
|
146
157
|
- async lifecycle.saveNow() | scopes: none
|
|
147
158
|
- Returns: true once Twinkle has the snapshot, false otherwise
|
|
148
159
|
- Take a snapshot right now, e.g. at a checkpoint or after an important step.
|
|
@@ -1006,26 +1017,29 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
|
|
|
1006
1017
|
- Every change is logged on the server and emailed to the owner; the player is told in game.
|
|
1007
1018
|
- Example: await Twinkle.minecraft.setPlayerRole({ uuid: person.uuid, role: 'builder' });
|
|
1008
1019
|
- async getChat({ since } = {}) | scopes: content:read
|
|
1009
|
-
- Returns: { events: [{ seq, at, kind: 'chat'|'zero'|'web'|'join'|'leave'|'death'|'visit'|'unvisit', name, text, userId, archived }], latestSeq, online, visitorCount, archiveReady, viewer: { canSend, muted, canModerate }, mutedUserIds }
|
|
1020
|
+
- Returns: { events: [{ seq, at, kind: 'chat'|'zero'|'web'|'join'|'leave'|'death'|'visit'|'unvisit', name, text, userId, tag, archived }], latestSeq, online, visitorCount, archiveReady, viewer: { canSend, muted, canModerate }, mutedUserIds }
|
|
1010
1021
|
- Live server chat after a sequence number: player chat, Zero, messages sent from the web, joins, leaves and deaths.
|
|
1011
1022
|
- Without since (or 0) it returns the newest 50 events, oldest first. Pass since = latestSeq from the previous response to get only new events. Poll every 2-3 seconds while the chat is visible; stop when hidden.
|
|
1012
1023
|
- kind: chat (a player), zero (Zero, the AI builder), web (someone chatting from the Twinkle app; userId is their Twinkle user id), join, leave, death, visit (a website visitor arrived in the world; userId is their Twinkle user id), unvisit (they left). Private messages are never included.
|
|
1013
1024
|
- name is a Minecraft username for chat/join/leave/death, and a Twinkle username for web.
|
|
1014
1025
|
- viewer.canModerate is true only for the server owner; mutedUserIds is filled only for them.
|
|
1015
1026
|
- online counts players on the server; visitorCount counts website visitors walking the world (Twinkle MC Explorers).
|
|
1027
|
+
- tag is 'explorer' on web chat from someone walking Twinkle World right now (Twinkle MC Explorers): show them as Explorer instead of Web (in game: [Explorer] name). Otherwise null.
|
|
1016
1028
|
- Example: let since = 0; async function poll() { const { events, latestSeq } = await Twinkle.minecraft.getChat({ since }); since = latestSeq; render(events); } setInterval(poll, 2500);
|
|
1017
1029
|
- async getChatHistory({ before, limit, query, includeArchived } = {}) | scopes: content:read
|
|
1018
|
-
- Returns: { events: [{ seq, at, kind: 'chat'|'zero'|'web'|'join'|'leave'|'death'|'visit'|'unvisit', name, text, userId, archived }], hasMore, nextBefore: { at, seq } | null, includesArchived }
|
|
1030
|
+
- Returns: { events: [{ seq, at, kind: 'chat'|'zero'|'web'|'join'|'leave'|'death'|'visit'|'unvisit', name, text, userId, tag, archived }], hasMore, nextBefore: { at, seq } | null, includesArchived }
|
|
1019
1031
|
- Older server chat, newest first, for scrolling back or searching past days.
|
|
1020
1032
|
- before is the nextBefore cursor from the previous page; omit it to start from the newest. limit 1-100 (default 50). query searches message text and names (60 chars).
|
|
1021
1033
|
- Chat from before the web chat bridge existed (imported from server logs) is archived: only the server owner can include it with includeArchived: true; others never receive it.
|
|
1034
|
+
- tag is 'explorer' on web chat from someone walking Twinkle World right now (Twinkle MC Explorers): show them as Explorer instead of Web (in game: [Explorer] name). Otherwise null.
|
|
1022
1035
|
- Example: const page = await Twinkle.minecraft.getChatHistory({ limit: 50 }); const older = await Twinkle.minecraft.getChatHistory({ before: page.nextBefore });
|
|
1023
1036
|
- async sendChat({ text }) | scopes: content:write
|
|
1024
|
-
- Returns: { event: { seq, at, kind: 'chat'|'zero'|'web'|'join'|'leave'|'death'|'visit'|'unvisit', name, text, userId, archived } }
|
|
1037
|
+
- Returns: { event: { seq, at, kind: 'chat'|'zero'|'web'|'join'|'leave'|'death'|'visit'|'unvisit', name, text, userId, tag, archived } }
|
|
1025
1038
|
- Send a chat message from the app into the Minecraft server; players see it as [Web] <your Twinkle username>.
|
|
1026
1039
|
- Signed-in viewers only. text is trimmed to 200 characters. About one message per 2.5 seconds and 10 per minute per person: 429 with code minecraft_chat_rate_limited and retryAfterMs.
|
|
1027
1040
|
- 403 minecraft_chat_forbidden for accounts banned from chat; 400 minecraft_muted when the server owner muted them from web chat.
|
|
1028
1041
|
- Send only on a user action (Enter or Send); never automatically.
|
|
1042
|
+
- tag is 'explorer' on web chat from someone walking Twinkle World right now (Twinkle MC Explorers): show them as Explorer instead of Web (in game: [Explorer] name). Otherwise null.
|
|
1029
1043
|
- Example: await Twinkle.minecraft.sendChat({ text: 'Hi from the website!' });
|
|
1030
1044
|
- async muteChatUser({ userId, muted }) | scopes: content:write
|
|
1031
1045
|
- Returns: { userId, muted }
|
package/sdk/LUMINE_ADMIN.md
CHANGED
|
@@ -5695,6 +5695,44 @@ Bridge Builder qualifies from QR check-ins: 7 different people, or one meetup a
|
|
|
5695
5695
|
Every decision requires Mikey's authorization for that crew and action. Mutations are audited and idempotent; `--output <path>` is an explicit request to retain the canonical JSON. General write receipts omit private crew details.
|
|
5696
5696
|
|
|
5697
5697
|
|
|
5698
|
+
## Arena Today's Challenge personal queues
|
|
5699
|
+
|
|
5700
|
+
Each member who chose Today's Challenge preferences is served the oldest
|
|
5701
|
+
`ready`, never-shown question written for their current preference revision.
|
|
5702
|
+
Questions are prepared editorially (see the repository's
|
|
5703
|
+
`agent-guides/lumine-admin.md`, Today's Challenge personal refills, for the
|
|
5704
|
+
content rules, including the 11-03 no-answer-revealing-options and real-test
|
|
5705
|
+
coverage rules).
|
|
5706
|
+
|
|
5707
|
+
```
|
|
5708
|
+
lumine admin arena today-queue [--cursor <id>] [--limit <1-100>] --json
|
|
5709
|
+
lumine admin arena today-member --user <id> --json
|
|
5710
|
+
lumine admin arena today-import --file <reviewed-questions.json> [--confirm] --json
|
|
5711
|
+
lumine admin arena today-withdraw --user <id> (--keys <key,key,...> | --file <withdraw.json>) --reason "<why>" [--confirm] --json
|
|
5712
|
+
```
|
|
5713
|
+
|
|
5714
|
+
`today-import` validates without publishing; `--confirm` publishes. Keys and
|
|
5715
|
+
content are immutable and duplicate questions are rejected.
|
|
5716
|
+
|
|
5717
|
+
`today-withdraw` removes that member's unserved queued questions that failed
|
|
5718
|
+
review, so corrected replacements are not served behind them. Without
|
|
5719
|
+
`--confirm` it is a preview. It withdraws only `ready` questions never assigned
|
|
5720
|
+
to the member; a question shown today or on an earlier day is skipped as
|
|
5721
|
+
`served`, and keys are looked up only in that member's queue (another member's
|
|
5722
|
+
key reads as `not_found`). Already-withdrawn keys are skipped, so a repeat is a
|
|
5723
|
+
no-op, and a retried request replays its original receipt. Each confirmed run is
|
|
5724
|
+
recorded in the administrator audit log (operator, time, member, keys, reason),
|
|
5725
|
+
and each withdrawn question keeps that record in its review evidence. The result
|
|
5726
|
+
lists `withdrawn` and `skipped` keys (`not_found`, `already_withdrawn`, `served`,
|
|
5727
|
+
`not_ready`) with `readyBefore`/`readyAfter` at the current preference revision.
|
|
5728
|
+
Withdrawn questions are never served or counted as ready. Their keys stay
|
|
5729
|
+
reserved and the identical question cannot be imported again; import the
|
|
5730
|
+
corrected version under a new key (it may keep the same passage and prompt).
|
|
5731
|
+
A withdrawal file is a JSON array of keys, or `{ "userId": <id>, "keys": [...],
|
|
5732
|
+
"reason": "..." }` (`userId` must match `--user`; `--reason` overrides the
|
|
5733
|
+
file's reason). Re-read `today-member` afterwards and refill if the member is
|
|
5734
|
+
due.
|
|
5735
|
+
|
|
5698
5736
|
## Nonogram picture collection
|
|
5699
5737
|
|
|
5700
5738
|
Nonogram's `nonogram-picture-v1` proof uses a private, reviewed picture bank.
|