@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.79",
3
+ "version": "0.3.81",
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.78.0
4
- Updated: 2026-11-01
5
- Generated: 2026-11-02T01:19:13.071Z
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, and hands it back the next time the app starts.
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
- - 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.
136
- - One handler per app; registering again replaces it. Long-term saves (collections, completed progress) still belong in Twinkle.privateDb.
137
- - 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.
138
- - Example: Twinkle.app.lifecycle.onSuspend(() => ({ level, hp, x: player.x, y: player.y, inventory }));
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 snapshot this viewer's last session of the app left behind (after an unload, a tab reload or crash), or null.
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
- - Check the snapshot's shape before using it: it can come from an older version of the app. Ignore what you do not recognise.
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 }
@@ -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.