@stage5/lumine 0.3.60 → 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/commands.js CHANGED
@@ -3323,7 +3323,7 @@ export function printHelp() {
3323
3323
  lumine admin chat-reports export <report-id> | --hold <hold-id> --out <new-dir> [--json] (evidence package + SHA-256 manifest)
3324
3324
  lumine admin chat-reports suspend --user <id> --note <why> [--json] (full ban: signs them out)
3325
3325
  lumine admin reward-review show <review-id> [--dir <path>] [--json]
3326
- 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)
3327
3327
  lumine admin reward-review approve <review-id> [--config <rules.json>] [--reason <text>] [--json] (approval publishes the approved version)
3328
3328
  lumine admin reward-review propose <review-id> --dir <edited-snapshot> --config <rules.json> [--reason <text>] [--title <new app name>] [--thumbnail <image|workspace>] [--json]
3329
3329
  lumine admin reward-review reject|revoke <review-id> --reason <text> [--json]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.60",
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,8 @@
1
1
  # Build SDK Index
2
2
 
3
- Version: 1.69.0
3
+ Version: 1.71.0
4
4
  Updated: 2026-10-24
5
- Generated: 2026-10-24T02:37:05.998Z
5
+ Generated: 2026-10-24T05:37:23.377Z
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.
@@ -1588,7 +1588,7 @@ world.updatePresence({ x, y, z, facing });
1588
1588
 
1589
1589
  ### Twinkle.rewards
1590
1590
  - await Twinkle.rewards.getStatus() | scopes: rewards:claim
1591
- - 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 }
1592
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.
1593
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.
1594
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.
@@ -1598,6 +1598,7 @@ world.updatePresence({ x, y, z, facing });
1598
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.
1599
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.
1600
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.
1601
1602
  - await Twinkle.rewards.getReceipt({ challengeId }) | scopes: rewards:claim
1602
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 }
1603
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.
@@ -1614,6 +1615,7 @@ world.updatePresence({ x, y, z, facing });
1614
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.
1615
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.
1616
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.
1617
1619
  - await Twinkle.rewards.progress({ challengeId, completionToken?, frames?, record?, requestId? }) | scopes: rewards:claim
1618
1620
  - Returns: { mode: "live" | "preview", completion: { profile, token?, completed, failed?, decision?, message?, maxFrames? }, aiUsagePolicy? }
1619
1621
  - Verify a bounded batch of inputs for an approved server-simulated climb.