@stage5/lumine 0.3.59 → 0.3.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/admin.js CHANGED
@@ -6252,6 +6252,10 @@ export function printRewardReviewResult({ operation, data }) {
6252
6252
  console.log(
6253
6253
  `Budgets: per user/day ${config.userDailyXP} XP/${config.userDailyCoins} Coins${config.userDailyClaims ? ` · ${config.userDailyClaims} claim(s)` : ""}`,
6254
6254
  );
6255
+ if (config.missedRematch)
6256
+ console.log(
6257
+ `Missed-puzzle rematches: a variant ${config.missedRematch.afterDays} day(s) after a miss · ${config.missedRematch.perDay} a day · full first-try amounts, outside the daily claim count`,
6258
+ );
6255
6259
  for (const rule of config.rules)
6256
6260
  console.log(` ${formatRewardRuleLine(rule)}`);
6257
6261
  }
@@ -7397,8 +7401,64 @@ function printAiBucketRuleResult({ operation, data }) {
7397
7401
  return false;
7398
7402
  }
7399
7403
 
7404
+ // `admin reward-diagnostics`: one app's (or member's) reward audit trail.
7405
+ // The generic audit-event printer read runId/action/result, which these
7406
+ // events don't have, so every line printed "undefined".
7407
+ export function formatRewardDiagnostics(data, filters = {}) {
7408
+ if (data.coverage === "unavailable")
7409
+ return [`Reward diagnostics unavailable${data.reason ? `: ${data.reason}` : "."}`];
7410
+ const events = Array.isArray(data.events) ? data.events : [];
7411
+ const at = (seconds) =>
7412
+ Number.isFinite(Number(seconds)) && Number(seconds) > 0
7413
+ ? new Date(Number(seconds) * 1000).toISOString().slice(5, 16).replace("T", " ")
7414
+ : "?";
7415
+ const lines = [
7416
+ `Reward diagnostics: ${events.length} event(s), ${at(data.since)} → ${at(data.until)} UTC${data.coverage ? ` (${data.coverage})` : ""}`,
7417
+ ];
7418
+ const tally = new Map();
7419
+ for (const event of events) {
7420
+ const key = event.operation || "?";
7421
+ const outcomes = tally.get(key) || new Map();
7422
+ const outcome = event.outcome || "?";
7423
+ outcomes.set(outcome, (outcomes.get(outcome) || 0) + 1);
7424
+ tally.set(key, outcomes);
7425
+ }
7426
+ for (const [operation, outcomes] of tally)
7427
+ lines.push(` ${operation}: ${[...outcomes].map(([outcome, n]) => `${n} ${outcome}`).join(", ")}`);
7428
+ for (const event of events) {
7429
+ const details = event.details || {};
7430
+ const extras = [
7431
+ details.httpStatus ? String(details.httpStatus) : "",
7432
+ details.errorCode ? String(details.errorCode) : "",
7433
+ Number.isFinite(Number(event.durationMs)) ? `${event.durationMs} ms` : "",
7434
+ ].filter(Boolean);
7435
+ lines.push(
7436
+ `#${event.id} ${at(event.createdAt)} ${event.source || "?"} build ${event.buildId ?? "?"} user ${event.userId ?? "?"} ${event.operation || "?"} → ${event.outcome || "?"}${extras.length ? ` (${extras.join(", ")})` : ""}`,
7437
+ );
7438
+ }
7439
+ if (Array.isArray(data.challenges) || Array.isArray(data.receipts))
7440
+ lines.push(`Server records: ${data.challenges?.length ?? 0} challenge(s), ${data.receipts?.length ?? 0} receipt(s)${data.canonicalRecordsTruncated ? " (truncated)" : ""}.`);
7441
+ for (const limitation of data.limitations || []) lines.push(`Note: ${typeof limitation === "string" ? limitation : JSON.stringify(limitation)}`);
7442
+ if (data.nextCursor) {
7443
+ // the next page needs the same scope and window
7444
+ const again = [
7445
+ filters.buildId ? `--build ${filters.buildId}` : "",
7446
+ filters.userId ? `--user ${filters.userId}` : "",
7447
+ filters.days ? `--days ${filters.days}` : "",
7448
+ ].filter(Boolean).join(" ");
7449
+ lines.push(`More: lumine admin reward-diagnostics ${again ? `${again} ` : ""}--cursor ${data.nextCursor}`);
7450
+ }
7451
+ return lines;
7452
+ }
7453
+
7400
7454
  function printAdminResult({ operation, result }) {
7401
7455
  const data = result?.data || {};
7456
+ if (operation.name === "reward-diagnostics.read") {
7457
+ const query = new URLSearchParams(String(operation.path || "").split("?")[1] || "");
7458
+ const filters = { buildId: query.get("buildId"), userId: query.get("userId"), days: query.get("days") };
7459
+ for (const line of formatRewardDiagnostics(data, filters)) console.log(line);
7460
+ return;
7461
+ }
7402
7462
  if (operation.name === "spending.report" && data.spending) {
7403
7463
  console.log(formatSpending(data.spending));
7404
7464
  return;
@@ -7411,6 +7471,11 @@ function printAdminResult({ operation, result }) {
7411
7471
  : `${grade.ruleId}: ${grade.current || "exhausted"}${grade.clearedToday ? " (cleared today)" : ""}; ${grade.remaining} unearned of ${grade.sets}${grade.refillDue ? `; refill to ${data.refillTarget}` : ""}`
7412
7472
  : `${grade.ruleId}: queue unavailable`);
7413
7473
  if (!data.complete) console.log("Queue coverage is incomplete; unavailable grades are not empty queues.");
7474
+ // Rematch variants to write in the next refill: missed puzzles with no unmet variant left for someone.
7475
+ if (data.variantNeeds?.length) {
7476
+ console.log(`Rematch variants needed (write as new sets with variantOf):`);
7477
+ for (const need of data.variantNeeds) console.log(` ${need.ruleId} ${need.setKey}: ${need.waiting} learner(s) waiting · ${need.variants} variant(s) so far`);
7478
+ }
7414
7479
  return;
7415
7480
  }
7416
7481
  if (printChatSafetyResult({ operation, data })) return;
package/lib/agent.js CHANGED
@@ -116,6 +116,24 @@ export async function agentCommand(options, { saveWorkspace }) {
116
116
  let saveResult = null;
117
117
  let scopeRepairAttempts = 0;
118
118
  let validationRepairAttempts = 0;
119
+ // The last candidate whose only issues were platform-rule notices. Their
120
+ // repair is optional: if it breaks the code or fails, this one is saved.
121
+ let noticesOnlyFiles = null;
122
+ // The reply written with that candidate: a later repair's reply may
123
+ // describe fixes that are not in it.
124
+ let noticesOnlyFinalText = "";
125
+ async function restoreNoticesOnlyCandidate(fromFiles, reason) {
126
+ await replaceWorkspaceWithSnapshot({
127
+ dir: workspace.dir,
128
+ currentFiles: fromFiles,
129
+ snapshotFiles: noticesOnlyFiles,
130
+ });
131
+ currentFiles = noticesOnlyFiles;
132
+ finalText = `${noticesOnlyFinalText}\n\nOne small fix is still open: part of this may not work inside Twinkle yet (see the platform rule notices from lumine check).`.trim();
133
+ console.error(
134
+ `Lumine: ${reason}; saving the earlier version, which only had platform rule notices.`,
135
+ );
136
+ }
119
137
 
120
138
  console.log(
121
139
  `Running ${provider.label} through Lumine's workspace loop (Twinkle AI Energy: 0).`,
@@ -147,20 +165,30 @@ export async function agentCommand(options, { saveWorkspace }) {
147
165
  initialFiles: currentFiles,
148
166
  })
149
167
  : null;
150
- const providerResult = await provider.runPass({
151
- options,
152
- runtime,
153
- toolSession,
154
- isolationDir,
155
- mcp: {
156
- cliPath: CLI_PATH,
157
- dir: workspace.dir,
158
- runtimeFile: artifacts.runtimeFile,
159
- traceFile: artifacts.traceFile,
160
- buildId: workspace.buildId,
161
- baseFilesHash: workspace.baseFilesHash,
162
- },
163
- });
168
+ let providerResult;
169
+ try {
170
+ providerResult = await provider.runPass({
171
+ options,
172
+ runtime,
173
+ toolSession,
174
+ isolationDir,
175
+ mcp: {
176
+ cliPath: CLI_PATH,
177
+ dir: workspace.dir,
178
+ runtimeFile: artifacts.runtimeFile,
179
+ traceFile: artifacts.traceFile,
180
+ buildId: workspace.buildId,
181
+ baseFilesHash: workspace.baseFilesHash,
182
+ },
183
+ });
184
+ } catch (error) {
185
+ if (!noticesOnlyFiles || runtime.phase !== "validation_repair") throw error;
186
+ await restoreNoticesOnlyCandidate(
187
+ await collectAgentProjectFiles(workspace.dir),
188
+ `the notice repair failed (${error?.message || error})`,
189
+ );
190
+ break;
191
+ }
164
192
  finalText = String(providerResult?.finalText || "").trim() || finalText;
165
193
  providerRuns.push({
166
194
  phase: runtime.phase,
@@ -213,8 +241,19 @@ export async function agentCommand(options, { saveWorkspace }) {
213
241
  reason: String(validation.reason || "").slice(0, 1000),
214
242
  });
215
243
  console.error(`Lumine validation: ${validation.reason}`);
244
+ if (validation.platformRuleNoticesOnly) {
245
+ noticesOnlyFiles = candidateFiles;
246
+ noticesOnlyFinalText = finalText;
247
+ }
216
248
 
217
249
  if (validation.kind === "scope") {
250
+ if (runtime.phase === "validation_repair" && noticesOnlyFiles) {
251
+ await restoreNoticesOnlyCandidate(
252
+ candidateFiles,
253
+ "the notice repair went beyond its scope",
254
+ );
255
+ break;
256
+ }
218
257
  if (runtime.phase === "validation_repair") {
219
258
  // A validation-repair pass is intentionally allowed only a tiny
220
259
  // corrective diff. If it exceeds that scope, retire its local file
@@ -249,6 +288,13 @@ export async function agentCommand(options, { saveWorkspace }) {
249
288
  });
250
289
  currentFiles = scopeBaseFiles;
251
290
  } else {
291
+ if (validationRepairAttempts >= 2 && noticesOnlyFiles) {
292
+ await restoreNoticesOnlyCandidate(
293
+ candidateFiles,
294
+ "the notice repair did not pass validation",
295
+ );
296
+ break;
297
+ }
252
298
  if (validationRepairAttempts >= 2) {
253
299
  throw new Error(
254
300
  "The external agent could not pass Lumine validation after two repair attempts. Its local candidate remains unsaved for inspection.",
package/lib/commands.js CHANGED
@@ -1498,6 +1498,20 @@ export function printCheck(result) {
1498
1498
  for (const conflictPath of checks.conflictMarkers?.paths || []) {
1499
1499
  console.log(` conflict marker: ${conflictPath}`);
1500
1500
  }
1501
+ // Platform-rule notices never fail a check or a launch; they point at code
1502
+ // that will not work inside Twinkle's frame (see Platform Rules in AGENTS.md).
1503
+ const notices = checks.platformRuleNotices?.diagnostics || [];
1504
+ if (notices.length) {
1505
+ console.log(
1506
+ `- platform rule notices: ${checks.platformRuleNotices.count} (do not block)`,
1507
+ );
1508
+ for (const diagnostic of notices) {
1509
+ console.log(
1510
+ ` ${diagnostic.kind} ${diagnostic.filePath}` +
1511
+ `${diagnostic.line ? `:${diagnostic.line}` : ""} ${diagnostic.message}`,
1512
+ );
1513
+ }
1514
+ }
1501
1515
  }
1502
1516
 
1503
1517
  export async function loadTargetBuildMetadata({ options, auth }) {
@@ -3309,7 +3323,7 @@ export function printHelp() {
3309
3323
  lumine admin chat-reports export <report-id> | --hold <hold-id> --out <new-dir> [--json] (evidence package + SHA-256 manifest)
3310
3324
  lumine admin chat-reports suspend --user <id> --note <why> [--json] (full ban: signs them out)
3311
3325
  lumine admin reward-review show <review-id> [--dir <path>] [--json]
3312
- lumine admin math-lab queue [--json] (published sets remaining per grade, all-time claims, refill flags)
3326
+ lumine admin math-lab queue [--json] (published sets remaining per grade, all-time claims, refill flags, rematch variants needed)
3313
3327
  lumine admin reward-review approve <review-id> [--config <rules.json>] [--reason <text>] [--json] (approval publishes the approved version)
3314
3328
  lumine admin reward-review propose <review-id> --dir <edited-snapshot> --config <rules.json> [--reason <text>] [--title <new app name>] [--thumbnail <image|workspace>] [--json]
3315
3329
  lumine admin reward-review reject|revoke <review-id> --reason <text> [--json]
package/lib/constants.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import os from "os";
2
2
  import path from "path";
3
3
  import { LUMINE_BUILDER_STANCE } from "./builderStance.generated.js";
4
+ import { LUMINE_PLATFORM_RULES } from "./platformRules.generated.js";
4
5
 
5
6
  export const DEFAULT_API_URL = "https://api.twinkle.network";
6
7
  export const DEFAULT_SITE_URL = "https://www.twin-kle.com";
@@ -134,9 +135,8 @@ export const BUNDLED_SDK_REFERENCE_URL = new URL(
134
135
  import.meta.url,
135
136
  );
136
137
  export const PACKAGE_METADATA_URL = new URL("../package.json", import.meta.url);
137
- export const LUMINE_MOBILE_SELECTION_GUIDANCE = `- Mobile long-press must not select game UI or open the browser's Copy/Look Up/image menu. The SDK protects standard buttons, button-like ARIA controls, and canvases. Mark the entire gameplay wrapper data-twinkle-no-select (including HUD, labels, scores, menus, controls, and empty play space); also apply user-select: none, -webkit-user-select: none, and -webkit-touch-callout: none for local previews. Protecting only one button or the canvas is insufficient.
138
- - On a played game page (a play-sized canvas, or data-twinkle-game on <html>/<body>) the SDK also blocks text selection, the long-press menu and page pinch-zoom everywhere except inputs, contenteditable and data-twinkle-selectable text. A game that sizes itself with Twinkle.preview should set data-twinkle-game; data-twinkle-document keeps a canvas page (charts, drawing pad) an ordinary page; data-twinkle-allow-zoom keeps pinch-zoom on a game.
139
- - Keep inputs/contenteditable usable. Mark genuinely copyable story/chat/user-written text data-twinkle-selectable and style it with user-select: text, -webkit-user-select: text, and -webkit-touch-callout: default. Do not blanket-disable selection on document/readers or preventDefault on document-wide touch/pointer events. Test holding controls, the HUD, and empty play space in mobile Safari and Chromium, then verify release/cancel, scrolling, typing, and copying. lumine check only detects a missing no-selection rule; a pass does not prove selector coverage or mobile behavior.`;
138
+ export const LUMINE_MOBILE_SELECTION_GUIDANCE = `- Pages marked data-twinkle-game (or showing a play-sized canvas) block text selection, the long-press menu and page pinch-zoom outside inputs, contenteditable and data-twinkle-selectable text; data-twinkle-allow-zoom keeps pinch-zoom. Do not blanket-disable selection or preventDefault document-wide touch/pointer events on reading pages.
139
+ - After changing game input, check on mobile Safari and Chromium that holding controls, release/cancel, scrolling, typing and copying still work. The long-press part of lumine check only detects a missing no-selection rule; a pass does not prove selector coverage or mobile behavior.`;
140
140
  export const LUMINE_WORLD_UPDATE_GUIDANCE = `- For Twinkle.world realtime presence, keep render/input loops local. Queue an update only when relevant state changes, replace any queued snapshot with the newest one, and flush on a fixed 5-15 updates-per-second schedule with at most one updatePresence request in flight. Never call or await updatePresence every animation frame, resend unchanged snapshots, overlap requests, or build a backlog.
141
141
  - Send Twinkle.world actions only when the discrete action happens; do not poll or automatically retry them. On WORLD_EVENT_RATE_LIMITED or another recoverable non-session-ended error, drop that attempted presence/action update without an immediate retry and keep the session. Reconnect with backoff only after session.ended or Twinkle.world.isSessionEndedError(error).`;
142
142
  export const SDK_REFERENCE_FALLBACK = `${LUMINE_SDK_REFERENCE_MARKER}
@@ -154,8 +154,12 @@ Use these current source-of-truth rules:
154
154
  - Use Twinkle.aiCards.list/search/get for existing public AI Card words, exampleText sentence material, and word levels.
155
155
  - Use Twinkle.aiStories.list/search/get for existing AI Story passage text, story media, and questions.
156
156
  - Use Twinkle.ai.chat with history entries shaped as { role, content }, not { text }. Live web search is enabled by default; pass webSearch: false to disable it for the app.
157
- - Use Twinkle.preview for canvas, WebGL, Three.js, fullscreen, and game layout.
158
157
  ${LUMINE_MOBILE_SELECTION_GUIDANCE}
158
+
159
+ ## Platform Rules
160
+
161
+ ${LUMINE_PLATFORM_RULES}
162
+
159
163
  ${LUMINE_WORLD_UPDATE_GUIDANCE}
160
164
  - Prefer existing documented Twinkle.* methods over guessing names from old code.
161
165
  `;
@@ -179,6 +183,14 @@ Lumine CLI as the source of truth for saving this workspace back to Twinkle.
179
183
 
180
184
  ${LUMINE_BUILDER_STANCE}
181
185
 
186
+ ## Platform Rules
187
+
188
+ ${LUMINE_PLATFORM_RULES}
189
+
190
+ \`lumine check\` fails on forms, imports, SDK names, game layout and long-press
191
+ selection, and lists canvas page kind, dialogs and new windows as platform
192
+ rule notices (they do not block); a pass does not prove the other rules.
193
+
182
194
  ## Source Of Truth
183
195
 
184
196
  - Read .twinkle/lumine-project.json before changing files.
@@ -347,15 +359,12 @@ lumine save --summary "Describe the change"
347
359
 
348
360
  ## App Constraints
349
361
 
350
- - Use local project files with relative or root-local imports only. Do not add package imports, CDN scripts, external network calls, or app-local /api/* routes.
351
- - Build apps run in sandboxed iframes without allow-forms. Do not use <form> elements, native form submission, requestSubmit(), or browser form navigation. Build input flows with JavaScript-handled inputs and buttons instead.
362
+ The Platform Rules above come first; these add detail.
363
+
352
364
  ${LUMINE_MOBILE_SELECTION_GUIDANCE}
353
- - CAUTION: the preview runtime AUTO-DETECTS "game apps" — any <canvas> in the body except a small one on screen (both sides at most 160 CSS px: a visualizer, a sparkle, an icon) switches the app to viewport-app mode: html/body get overflow:hidden !important and body becomes a centering flexbox, so tall document-flow pages clip and stop scrolling. A hidden or not-yet-sized canvas counts too, and a page that sets data-twinkle-game keeps this layout with any canvas. Document-style apps with a larger canvas (a chart, a drawing pad, a background effect) set data-twinkle-document on <html> or <body>, or call Twinkle.preview.subscribe (or getLayout/reserveInsets) early at boot and then pad by layout.safeInsets and scroll within layout.viewport.height.
354
- - For canvas, WebGL, Three.js, fullscreen, or game builds, use Twinkle.preview for layout. Do not size roots from 100vh, 100vw, 100dvh, 100dvw, window.innerWidth, window.innerHeight, visualViewport, or document viewport dimensions.
355
365
  ${LUMINE_THREE_VENDOR_GUIDANCE}
356
366
  ${LUMINE_MATH_VENDOR_GUIDANCE}
357
367
  - Do not invent or guess Twinkle.* SDK method names. Use ${SDK_REFERENCE_FILE} as the local SDK reference and prefer Twinkle.capabilities checks for gated features.
358
- - Match storage to update frequency. Twinkle.privateDb and Twinkle.sharedDb are for LOW-frequency durable state only — things that change on a user action (settings, inventory checkpoints, completed quests, saved progress; comments, votes, room settings, submitted records). NEVER write high-frequency or per-frame/per-tick state to them (camera or cursor position, animation state, live movement, presence, autosave every frame/tick). Keep live state in client memory, broadcast realtime/presence via Twinkle.world, and for durable per-user state flush an occasional snapshot on an interval or on exit (never per frame) — e.g. the viewer/user DB or a single latest-snapshot key. The server rate-limits these writes per key and returns 429 on excess; never retry-loop a 429.
359
368
  ${LUMINE_WORLD_UPDATE_GUIDANCE}
360
369
 
361
370
  ## Local Testing (Playwright / browser probes)
@@ -0,0 +1,3 @@
1
+ // Generated by twinkle-api/scripts/generate-build-sdk-index.mjs from
2
+ // constants/buildSdkManifest.json (platformRules). Do not edit by hand.
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.";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.59",
3
+ "version": "0.3.61",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,8 +1,24 @@
1
1
  # Build SDK Index
2
2
 
3
- Version: 1.68.0
4
- Updated: 2026-10-16
5
- Generated: 2026-10-23T06:17:07.718Z
3
+ Version: 1.71.0
4
+ Updated: 2026-10-24
5
+ Generated: 2026-10-24T05:37:23.377Z
6
+
7
+ ## Platform rules
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.
9
+
10
+ 1. 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.
11
+ 2. 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.
12
+ 3. 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>.
13
+ 4. 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.
14
+ 5. 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.
15
+ 6. 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.
16
+ 7. Call only Twinkle.* methods documented in the SDK reference. An invented or guessed method name throws and breaks the feature.
17
+ 8. 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.
18
+ 9. 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.
19
+ 10. 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.
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
+ 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.
6
22
 
7
23
  ## Notes
8
24
  - 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.
@@ -312,7 +328,7 @@ console.log(analysis.bestMove, analysis.evaluation, analysis.mate);
312
328
  - Download a generated or remote file to the viewer's local device through the parent frame without opening a popup.
313
329
  - Local viewer download only; does not upload to Twinkle or consume AI Energy.
314
330
  - Use this for generated blobs/data/JSON/bytes or large/remote files that need parent-frame download handling.
315
- - For URL downloads, cross-origin URLs must be fetchable by browser CORS or Twinkle's CDN proxy so the parent frame can create a local Blob download.
331
+ - For URL downloads, the parent frame fetches the file and saves it as a local Blob. Files uploaded to Twinkle (images, audio, PDFs and other files) work as they are; URLs on other sites must allow cross-origin fetches (CORS).
316
332
  - Simple visible same-origin images can still use normal browser anchors with href and download.
317
333
  - Example: await Twinkle.files.saveAs({ fileName: 'fashion-guide.png', dataUrl: imageUrl, mimeType: 'image/png' });
318
334
  - async uploadGenerated({ fileName, url, dataUrl, data, text, json, bytes, blob, file, mimeType } = {}) | scopes: files:write
@@ -902,11 +918,12 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
902
918
 
903
919
  ### Twinkle.minecraft
904
920
  - async getWorlds() | scopes: content:read
905
- - Returns: { worlds: [{ id, name, dimension, spawn: { x, z } | null, map: { tileUrlTemplate, minZoom, maxZoom, tilePixels, blocksPerTileAtMaxZoom, renderedAt } | null }], stale }
921
+ - Returns: { worlds: [{ id, name, dimension, spawn: { x, z } | null, map: { tileUrlTemplate, minZoom, maxZoom, tilePixels, blocksPerTileAtMaxZoom, renderedAt, heightUrlTemplate } | null }], stale }
906
922
  - List the server's worlds (world, world1, world2, world_nether, world_the_end) with squaremap tile info for drawing a top-down map.
907
923
  - Tiles are 512x512 PNGs over HTTPS from www.twinklemc.site. At maxZoom one pixel is one block; blocksPerTile = blocksPerTileAtMaxZoom * 2 ** (maxZoom - z); tile x/y = floor(block x/z / blocksPerTile). Fill {z}, {x}, {y}. map.renderedAt tells how fresh the tiles are.
908
924
  - Limited rollout: apps not enabled yet get 403 with code minecraft_sdk_not_enabled.
909
925
  - stale: true means the Minecraft server was briefly unreachable and the data is a recent cached copy.
926
+ - map.heightUrlTemplate (or null) gives the land's shape on the same tile grid for 3D maps (ground heights; buildings and trees narrower than about 20 blocks are left out, so draw those from getTerrainChunks): a gzipped 'THM1' file, 16-byte little-endian header (magic, uint16 samples per side, uint16 blocks per sample, float64 source time) then samples x samples int16 heights row by row (z, then x); -32768 = no ground. Images and heights are served with CORS, so WebGL can use them.
910
927
  - Example: const { worlds } = await Twinkle.minecraft.getWorlds(); const w = worlds.find((x) => x.id === 'world'); const z = w.map.maxZoom; const blocksPerTile = w.map.blocksPerTileAtMaxZoom * 2 ** (w.map.maxZoom - z); const tx = Math.floor(blockX / blocksPerTile), ty = Math.floor(blockZ / blocksPerTile); const url = w.map.tileUrlTemplate.replace('{z}', z).replace('{x}', tx).replace('{y}', ty);
911
928
  - async getOnlinePlayers() | scopes: content:read
912
929
  - Returns: { players: [{ name, world, x, y, z, isZero }], count, stale }
@@ -1109,10 +1126,18 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
1109
1126
  - Example: const { level } = await Twinkle.minecraft.getPlayArea({ id: 'twinkle-keep' });
1110
1127
  - async getPlayAreaChunks({ id, chunks: [[cx, cz], ...] }) | scopes: content:read
1111
1128
  - Returns: { scenes: [{ cx, cz, scene: { origin: [x,y,z], size: [w,h,d], palette, blocks, light, biomes, count, ... } | null }] }
1112
- - Up to 9 chunk columns of a play area in the /snap scene format (packed visible blocks with per-face light), cropped to the area's box. Stream the ones around the player.
1129
+ - Up to 24 chunk columns of a play area in the /snap scene format (packed visible blocks with per-face light), cropped to the area's box. Stream the ones around the player.
1113
1130
  - Same scene format as getSnap; only blocks with a visible face inside the area are included. scene is null for a chunk that is not part of the area.
1114
- - At most 9 chunks per call; reads share the 180-per-minute build read limit, so cache chunks and load the nearest first.
1131
+ - At most 24 chunks per call; reads share the 180-per-minute build read limit, so cache chunks and load the nearest first.
1115
1132
  - Example: const { scenes } = await Twinkle.minecraft.getPlayAreaChunks({ id: level.id, chunks: [[64, 29], [65, 29]] });
1133
+ - async getTerrainChunks({ world, chunks: [[cx, cz], ...] }) | scopes: content:read
1134
+ - Returns: { scenes: [{ cx, cz, scene: { origin: [x,y,z], size: [w,h,d], palette, blocks, light, biomes, count, wide? } | null }] }
1135
+ - The visible surface blocks of any chunk columns of a world (up to 24 per call), read from the saved world, in the /snap scene format. For 3D maps: draw real blocks near the camera.
1136
+ - Each column keeps the blocks a camera above the ground can see: the top down to the lowest neighbouring column, through water, glass and leaves to the ground. Caves are left out.
1137
+ - scene is null where the world was never generated. A scene taller than 255 blocks has wide: true (8 bytes per block).
1138
+ - It shows the world as last saved (the server saves every few minutes). Pair it with world.map.heightUrlTemplate for the land farther away.
1139
+ - Reads share the 180-per-minute build read limit: ask for 24 at a time, nearest first, and keep what you loaded.
1140
+ - Example: const { scenes } = await Twinkle.minecraft.getTerrainChunks({ world: 'world', chunks: [[71, 38], [72, 38]] });
1116
1141
  - async createPlayArea({ name, world, box, spawn, id }) | scopes: content:write
1117
1142
  - Returns: { level }
1118
1143
  - Capture a box of a Minecraft world as a new play area (or re-capture an existing id). Runs in the background: poll getPlayArea until status is 'ready'.
@@ -1563,7 +1588,7 @@ world.updatePresence({ x, y, z, facing });
1563
1588
 
1564
1589
  ### Twinkle.rewards
1565
1590
  - await Twinkle.rewards.getStatus() | scopes: rewards:claim
1566
- - Returns: { mode: "live", dayKey, userDailyClaims, claimsToday, budgets: { userDailyXP, userDailyCoins }, rules: [{ id, title, howTo?, category?, series?, xp, coins, verifier: "numeric-quiz" | "completion", minSeconds?, progression?, retryReward: { xp, coins }, maxAttempts, available, setKey, questionCount }], challenges: [{ challengeId, ruleId, attempts, attemptsRemaining, state: "open" | "finished" | "earned", setKey, questions: [{ prompt, hint?, guide? }] }], history: [{ ruleId, xp, coins, attempt, createdAt }], balances: { xp, coins } } | { mode: "preview", dayKey?, rules, challenges: [], history: [], balances?, problems?: string[], message }
1591
+ - Returns: { mode: "live", dayKey, userDailyClaims, claimsToday, missedRematch?: { afterDays, perDay, usedToday, ready: [{ ruleId, setKey, variantOf, missed }], upcoming, nextReadyDay }, budgets: { userDailyXP, userDailyCoins }, rules: [{ id, title, howTo?, category?, series?, xp, coins, verifier: "numeric-quiz" | "completion", minSeconds?, progression?, retryReward: { xp, coins }, maxAttempts, available, setKey, questionCount }], challenges: [{ challengeId, ruleId, attempts, attemptsRemaining, state: "open" | "finished" | "earned", setKey, questions: [{ prompt, hint?, guide? }] }], history: [{ ruleId, xp, coins, attempt, createdAt }], balances: { xp, coins } } | { mode: "preview", dayKey?, rules, challenges: [], history: [], balances?, problems?: string[], message }
1567
1592
  - Read canonical earning rules (without answer keys), today’s started challenges, today’s receipts and balances. Drafts return preview mode: for the app's owner the rules come from the draft's own rewards.json and question sheet (problems lists what is still wrong with them); anyone else sees no rules. Unapproved or revoked published releases return an error.
1568
1593
  - rules[].available is false on a site day (UTC) the reviewer scheduled no questions for; show the rule as not available instead of starting it. xp/coins are the first-try amounts; retryReward is what a correct answer pays after a wrong one (equal to xp/coins unless the reviewer set a retry share). maxAttempts null means unlimited wrong answers until the site's daily reset (UTC midnight, 9:00 AM in Korea). retry.paidAttempts, when set, is the last attempt number a correct answer is still paid on: a later correct answer is recorded as solved (receipt xp 0, coins 0) and pays nothing — tell the learner before they pass it.
1569
1594
  - challenges lists challenges this viewer already started today with their questions, so an app can resume after a reload without calling start. A question's guide (reviewer-approved JSON teaching content: explanation, interactive-model configuration) is present only once the viewer has answered at least once, right or wrong; render it as the after-attempt lesson. claimsToday against userDailyClaims (null = uncapped) tells whether another bounty can still pay today.
@@ -1573,6 +1598,7 @@ world.updatePresence({ x, y, z, facing });
1573
1598
  - category and series are optional rewards.json labels for a long list. category is a short section header (up to 40 characters, e.g. "Bring friends"); series is a short lowercase key (e.g. "new-friend") tying repeat rules like vigil-friend-1/2/3 together, and rules in one series must share category, verifier, XP and Coins. The Earn page groups rules by category and shows each series as one row ("up to 3 a day", "1 of 3 today"); group the app's own rewards list the same way.
1574
1599
  - nonogram-picture-v1 uses Twinkle’s private, editorially reviewed picture bank. Set pictureSize to 5, 10 or 15 on each completion rule. Do not bundle daily solutions or a fixed answer catalog in app files. The collection is maintained through lumine admin nonogram; the reward amounts and daily limits still come from the approved rules.
1575
1600
  - Collection progression keeps every approved keyed set available independently of other players' solves. collectionSets lists { setKey, number }; challenges carry collection, practice, completed and completion. Live status includes viewerId for a per-viewer durable outbox. Attempts and completion persist across days/releases; the daily reward allowance does not reset a puzzle's paid tries. Existing receipts (including rematches), Vault solves/practice and studied guides count. Completed puzzles can be revisited; do not treat any receipt for the grade as completing all its puzzles. Collection rules support up to 500 permanent sets per rule; legacy queued/dated rules keep their 62-set limit.
1601
+ - missedRematch appears when rewards.json declares missedRematch ({ afterDays, perDay }; it requires userDailyClaims) and the app has collection rules. A collection set may declare variantOf: another set's key, meaning the same puzzle with new numbers (an ordinary puzzle for everyone). A learner who missed any puzzle of a family (an original plus its variants) and has not solved one of its puzzles on the first try gets the family's first unmet puzzle as a rematch afterDays site days after their last answer in the family. ready lists today's rematches (variantOf is the family's original, missed the puzzle this learner got wrong most recently; once perDay are used it only keeps one already opened today): open setKey with start({ ruleId, setKey }) at any time (before or after today's bounties) and the challenge comes back with rematch: { variantOf, answered }: one answer, the rule's full first-try amounts, a receipt (rematch: true) that does not count toward userDailyClaims but does count toward the XP/Coin budgets. usedToday against perDay says how many rematches were opened today; opening one uses it. upcoming and nextReadyDay describe rematches not ready yet.
1576
1602
  - await Twinkle.rewards.getReceipt({ challengeId }) | scopes: rewards:claim
1577
1603
  - Returns: { mode: "live", status: "awarded" | "completed" | "pending" | "expired" | "not_found", receipt: { id, challengeId, ruleId, reviewId, artifactVersionId, dayKey, xp, coins, attempt, createdAt } | null, balances: { xp, coins } } | { mode: "preview", status: "not_found", receipt: null, message }
1578
1604
  - Read an existing receipt for this app and signed-in viewer by server-issued challengeId, including previous UTC days and previous approved versions. Requires the current approved published release and runtime grant; a stale frame must reload first. Never awards, retries a claim, returns answer keys, or restores removed rewards permission.
@@ -1589,6 +1615,7 @@ world.updatePresence({ x, y, z, facing });
1589
1615
  - For nonogram-picture-v1, start returns completion { profile, completed: false, readyInSeconds, puzzle: { size, rows: number[][], cols: number[][] } }. Render those row and column clues; there is no progress call. Live readyInSeconds is the remaining approved minimum wait, so reopening a puzzle does not restart its clock. The private picture is durably bound to this exact viewer, app, release, rule, challenge and expiry. Reopening returns the same clues even after a refill. New challenges use unseen pictures; build_reward_picture_collection_complete means this size needs a refill. Offer practice/community puzzles while waiting.
1590
1616
  - For collection rules, setKey is required and must be one of the approved permanent keys. Start returns that exact question and persistent attempts. practice:true means no XP/Coins or official rating (daily allowance reached, earlier practice, or viewed archive guide); display that before answering. Merely opening a practice question does not consume its future eligibility, but answering it does. completed:true returns its lesson and completion, without a new challenge or payout. A current eligible daily-rematch puzzle must be opened as the rematch. A challenge still expires at the usual day/release boundary; reopen the same set to resume its saved attempts.
1591
1617
  - Live completion starts return readyInSeconds, the remaining server-enforced wait for that challenge (including a resumed challenge). Anchor it at response arrival and queue one claim after that time; do not poll claim while too early. Retain the finished proof in a per-viewer outbox and reconcile getReceipt after a lost reply or reload. It is timing metadata, not proof of completion or permission to pay. Older servers may omit it; use the rule minSeconds conservatively. Preview timing and receipts remain separate.
1618
+ - A collection puzzle listed in getStatus().missedRematch.ready starts as a paid rematch, before or after today's bounties: rematch: { variantOf, answered }, attemptsRemaining 1, reward = the rule's full xp/coins, retryReward 0, and its receipt does not count toward userDailyClaims. A wrong answer ends the rematch (the puzzle stays in the collection as an ordinary missed puzzle); a right one pays and completes it.
1592
1619
  - await Twinkle.rewards.progress({ challengeId, completionToken?, frames?, record?, requestId? }) | scopes: rewards:claim
1593
1620
  - Returns: { mode: "live" | "preview", completion: { profile, token?, completed, failed?, decision?, message?, maxFrames? }, aiUsagePolicy? }
1594
1621
  - Verify a bounded batch of inputs for an approved server-simulated climb.