@stage5/lumine 0.3.38 → 0.3.40

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.
@@ -3,11 +3,12 @@ import { readAdminJsonFile } from "./admin-news.js";
3
3
  // `lumine admin craft seed|stats|revise`: our agents curate a Twinkle.craft
4
4
  // universe (Infinity Craft), Math Lab style. seed writes pairs we wrote (no
5
5
  // AI call), stats shows sizes, the frontier, today's target and the newest
6
- // AI answers to audit, revise corrects an item's teaching text. No daily run
6
+ // AI answers to audit, revise corrects an item's teaching text or one pair's
7
+ // why (the line a player sees when they make that pair). No daily run
7
8
  // needed. seed and revise are dry runs unless --confirm is given.
8
9
 
9
10
  export const CRAFT_USAGE =
10
- "Usage: lumine admin craft seed --build <id> --file <pairs.json> [--confirm] | stats --build <id> [--limit <n>] | revise --build <id> --item <id|name> [--why <text>] [--emoji <emoji>] [--category <field>] [--confirm] | retire --build <id> --item <id|name> --reason <text> [--confirm] | unfizzle --build <id> [--since <iso>] [--pair <a+b>] [--confirm] [--json].";
11
+ "Usage: lumine admin craft seed --build <id> --file <pairs.json> [--confirm] | stats --build <id> [--limit <n>] | 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] [--json].";
11
12
  const CRAFT_SEED_MAX = 500;
12
13
 
13
14
  function validationError(message) {
@@ -82,10 +83,31 @@ export function buildCraftOperation({ action, options }) {
82
83
  };
83
84
  }
84
85
  if (action === "revise") {
86
+ const pair = textFlag(options.adminCraftPair, "--pair");
85
87
  const item = textFlag(options.adminCraftItem, "--item");
86
- if (!item) throw validationError(`Pass --item <id|name>. ${CRAFT_USAGE}`);
87
- const body = { item: item.trim() };
88
88
  const why = textFlag(options.adminCraftWhy, "--why");
89
+ const confirm = confirmed(options);
90
+ if (pair !== undefined) {
91
+ // A pair's own why; its result never changes.
92
+ if (item !== undefined || options.adminCraftEmoji != null || options.adminCraftCategory != null) {
93
+ throw validationError("--pair revises only the pair's --why: leave out --item, --emoji and --category.");
94
+ }
95
+ const ref = pair.trim();
96
+ if (!/^\d+$/.test(ref) && ref.split("+").filter((name) => name.trim()).length !== 2) {
97
+ throw validationError('--pair must be a pair id or two item names joined by +, e.g. "Earth + Smoke".');
98
+ }
99
+ if (why === undefined) throw validationError("Pass --why <text> with --pair.");
100
+ return {
101
+ name: confirm ? "craft.revise" : "craft.revise.preview",
102
+ method: "POST",
103
+ path: `${base}/revise`,
104
+ body: { pair: ref, why, dryRun: !confirm },
105
+ mutates: confirm,
106
+ requiresRun: false,
107
+ };
108
+ }
109
+ if (!item) throw validationError(`Pass --item <id|name> or --pair <pairId|a+b>. ${CRAFT_USAGE}`);
110
+ const body = { item: item.trim() };
89
111
  const emoji = textFlag(options.adminCraftEmoji, "--emoji");
90
112
  const category = textFlag(options.adminCraftCategory, "--category");
91
113
  if (why !== undefined) body.why = why;
@@ -94,7 +116,6 @@ export function buildCraftOperation({ action, options }) {
94
116
  if (Object.keys(body).length === 1) {
95
117
  throw validationError("Pass at least one of --why, --emoji or --category (the name never changes).");
96
118
  }
97
- const confirm = confirmed(options);
98
119
  return {
99
120
  name: confirm ? "craft.revise" : "craft.revise.preview",
100
121
  method: "POST",
@@ -162,6 +183,16 @@ export function formatCraftResult({ operation, data }) {
162
183
  if (data.dryRun) lines.push("Nothing was written. Add --confirm to seed.");
163
184
  return lines;
164
185
  }
186
+ if (name.startsWith("craft.revise") && data.pair) {
187
+ const { pair } = data;
188
+ lines.push(
189
+ `${data.dryRun ? "DRY RUN: " : ""}pair #${pair.id} ${pair.a} + ${pair.b} = ${pair.result.emoji} ${pair.result.name} (#${pair.result.id})`,
190
+ );
191
+ lines.push(` before: ${data.before.why ?? "(no explanation)"}`);
192
+ lines.push(` after: ${data.after.why}`);
193
+ if (data.dryRun) lines.push("Nothing was written. Add --confirm to revise.");
194
+ return lines;
195
+ }
165
196
  if (name.startsWith("craft.revise")) {
166
197
  const show = (value) => `${value.emoji} ${value.category} · ${value.why ?? "(no explanation)"}`;
167
198
  lines.push(`${data.dryRun ? "DRY RUN: " : ""}${data.item.name} (#${data.item.id})`);
package/lib/admin.js CHANGED
@@ -2911,6 +2911,19 @@ export function parseAdminOperation(options) {
2911
2911
  // Mikey's decisions, any time; each comes with its evidence (approvals.ts).
2912
2912
  return buildApprovalOperation({ action, target, options });
2913
2913
  }
2914
+ if (namespace === "word-games") {
2915
+ if (action === "status") return readOperation("word-games.status", "/cli/admin/word-games/status", { requiresRun: false });
2916
+ if (action === "candidates") return readOperation("word-games.candidates", withQuery("/cli/admin/word-games/candidates", {
2917
+ level: parseRequiredInteger(target, "word level", 1, 5), after: options.adminAfter || 0, limit: options.adminLimit || 50,
2918
+ }), { requiresRun: false });
2919
+ if (action === "import" && target) {
2920
+ const raw = readAdminJsonFile(target, "<reviewed-words.json>", { maxBytes: 256 * 1024 });
2921
+ const items = Array.isArray(raw) ? raw : raw?.items;
2922
+ if (!Array.isArray(items) || !items.length || items.length > 100) throw cliValidationError("Import 1 to 100 reviewed words, with definitions and examples.");
2923
+ return writeOperation("word-games.import", "POST", "/cli/admin/word-games/import", { items, dryRun: Boolean(options.adminDryRun) }, { requiresRun: false, mutates: !options.adminDryRun, timeoutMs: 120000 });
2924
+ }
2925
+ throw cliValidationError("Use: lumine admin word-games status | candidates <1-5> [--after <id>] [--limit <n>] | import <reviewed-words.json> [--dry-run]");
2926
+ }
2914
2927
  if (namespace === "english") {
2915
2928
  return buildEnglishOperation({ action, target, options });
2916
2929
  }
package/lib/commands.js CHANGED
@@ -2807,6 +2807,7 @@ export function parseArgs(args) {
2807
2807
  rewardPreflight: Boolean(raw.preflight),
2808
2808
  rewardApprovalFile: raw.approvalFile ? String(raw.approvalFile) : "",
2809
2809
  // admin craft revise --item <id|name> [--why] [--emoji] [--category]
2810
+ // or --pair <pairId|a+b> --why
2810
2811
  // (kept raw: a flag given without a text value is refused in
2811
2812
  // admin-craft.js rather than sent as the string "true")
2812
2813
  adminCraftItem: raw.item,
@@ -3252,7 +3253,7 @@ export function printHelp() {
3252
3253
  lumine admin english import <verdicts.json> --pass a|b | adjudicate <file> | add-questions <file> | relevel <file> | mastery <userId> --reason <why> | backfill-distractors | summary | review [--limit <n>] | accept|retire <questionId> | retag <questionId> --skill <code> (English skill tags and bank upkeep for Grammarbles; content written by Claude Code agents)
3253
3254
  lumine admin reward-review list [--status pending|approved|all] [--cursor <id>] [--json]
3254
3255
  lumine admin cardcraft-review list [--status pending|approved|all] [--cursor <id>] | show <id> | approve|reject|revoke <id> [--reason <text>]
3255
- 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] | 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)
3256
+ 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)
3256
3257
  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>]
3257
3258
  lumine admin meetup list [--status review|active|completed|all] | approve-crew <crewId> | approve-grownup <crewId> | request-changes <crewId> --note <text> | emails [list|send <id>|discard <id>|review on|off] | friends [status|on|off] | parent-reply <crewId> <memberUserId> --note <text> | funnel [--days <1-90>] | branches [list [--status pending|official|rejected|all] | show <id> | approve <id> | reject <id> [--note <text>] | merge <id> --into <officialId>] | show <crewId> | approve-plan <crewId> [--note <text>] | send-back <crewId> --note <text> | approve <crewId> --attended <userId,userId,...> [--note <text>] | info <crewId> <userId> --decision ask|accept|ask-again|withdraw [--note <text>] | set-branch <crewId> <userId> --branch "<name>" [--note <text>] | slot <crewId> --confirm <slotIndex> | resend-email <crewId> (Bridge Builder meetup quest)
3258
3259
  lumine admin meetup story list | show <crewId> | approve <crewId> [--note <text>] [--no-announce] | send-back <crewId> --note <text> | unpublish <crewId> --note <text> (Bridge Builder story pages: publishes only what every tagged member's parent allowed)
package/lib/sdk.js CHANGED
@@ -38,6 +38,18 @@ export const SDK_CLI_METHODS = {
38
38
  "aiCards.list": { path: "api/content/ai-cards/list", scopes: ["content:read"] },
39
39
  "aiCards.search": { path: "api/content/ai-cards/search", scopes: ["content:read"] },
40
40
  "aiCards.get": { path: "api/content/ai-card", scopes: ["content:read"] },
41
+ "wordMaster.reportIssue": { path: "api/content/word-master/telemetry", scopes: ["content:write"], write: true },
42
+ "wordMaster.getDailyWords": { path: "api/content/word-master/words", scopes: ["content:read"] },
43
+ "wordMaster.getWord": { path: "api/content/word-master/details", scopes: ["content:write"], write: true },
44
+ "wordMaster.generateEntry": { path: "api/content/word-master/generate-entry", scopes: ["content:write"], write: true },
45
+ "wordMaster.getEntry": { path: "api/content/word-master/entry-status", scopes: ["content:read"], write: true },
46
+ "wordMaster.encounterWords": { path: "api/content/word-master/encounter", scopes: ["content:write"], write: true },
47
+ "wordMaster.getWordBook": { path: "api/content/word-master/book", scopes: ["content:read"] },
48
+ "wordMaster.createQuiz": { path: "api/content/word-master/quiz", scopes: ["content:write"], write: true },
49
+ "wordMaster.getQuiz": { path: "api/content/word-master/quiz-status", scopes: ["content:read"], write: true },
50
+ "wordMaster.answerQuiz": { path: "api/content/word-master/answer", scopes: ["content:write"], write: true },
51
+ "wordMaster.getItems": { path: "api/content/word-master/items", scopes: ["content:read"] },
52
+ "wordMaster.useItem": { path: "api/content/word-master/use-item", scopes: ["content:write"], write: true },
41
53
  "grammarbles.listQuestions": { path: "api/content/grammarbles/questions", scopes: ["content:read"] },
42
54
  "grammarbles.getMyQuestionHistory": { path: "api/content/grammarbles/history", scopes: ["content:read"] },
43
55
  "minecraft.getWorlds": { path: "api/minecraft/worlds", scopes: ["content:read"] },
@@ -118,6 +130,19 @@ export const SDK_CLI_METHODS = {
118
130
  },
119
131
  "sharedDb.kv.setMany": { path: "api/shared-db/kv/set", scopes: ["sharedDb:write"], write: true },
120
132
  "sharedDb.kv.remove": { path: "api/shared-db/kv/delete", scopes: ["sharedDb:write"], write: true },
133
+ // Infinity Craft reads. merge is the player's own (it spends their Energy)
134
+ // and hint only peeks from a CLI login: a reveal gives up today's par bonus.
135
+ "craft.getState": { path: "api/craft/state", scopes: ["craft:read"] },
136
+ "craft.list": { path: "api/craft/list", scopes: ["craft:read"] },
137
+ "craft.item": { path: "api/craft/item", scopes: ["craft:read"] },
138
+ "craft.journey": { path: "api/craft/journey", scopes: ["craft:read"] },
139
+ "craft.rankings": { path: "api/craft/rankings", scopes: ["craft:read"] },
140
+ "craft.hint": {
141
+ path: "api/craft/hint",
142
+ scopes: ["craft:read"],
143
+ readOnly: true,
144
+ mapArgs: () => ({ peek: true }),
145
+ },
121
146
  "chat.listRooms": { path: "api/chat/rooms/list", scopes: ["chat:read"] },
122
147
  "chat.createRoom": { path: "api/chat/rooms/create", scopes: ["chat:write"], write: true },
123
148
  "chat.listMessages": { path: "api/chat/messages/list", scopes: ["chat:read"] },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.38",
3
+ "version": "0.3.40",
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.65.1
4
- Updated: 2026-10-13
5
- Generated: 2026-10-12T17:14:00.482Z
3
+ Version: 1.67.0
4
+ Updated: 2026-10-16
5
+ Generated: 2026-10-16T15:37:18.409Z
6
6
 
7
7
  ## Notes
8
8
  - This SDK is injected into Build iframes via the Build preview/runtime.
@@ -36,7 +36,10 @@ Generated: 2026-10-12T17:14:00.482Z
36
36
  - The creator's agent designs the rewards. Declare the economy in a project file `rewards.json` at the root: budgets (userDailyXP, userDailyCoins, optional userDailyClaims; there is no app-wide daily or lifetime budget, only what one learner can earn per day; an award larger than the learner's remaining room today pays what fits, XP and Coins clamped separately; a capped award still counts as claimed, including a one-time maxLifetimeClaims reward, and its receipt carries cappedFrom) and rules [{ id, title, howTo?, category? (section header, up to 40 characters), series? (key for repeat rules that share category and amounts), xp, coins, verifier: 'numeric-quiz' | 'completion' | 'generated-quiz', maxAttempts?, retry?: { xpPercent, coinsPercent, paidAttempts? }, minSeconds? (completion), progression?: 'dated' | 'until-earned' (quiz), bank? (generated-quiz): { steps: [percent, ...] rising to 100, startStep?, instructions, topics: [{ key, title, description }] } }]. generated-quiz rules need no question sheet. Wire the matching Twinkle.rewards calls with those literal rule ids. Questions and answer keys NEVER go in project files (published source is readable by every player): quiz rules get them from the private question sheet uploaded with `lumine rewards sheet <file.json>` ({ rules: { <ruleId>: { questions?, sets? } } }); `lumine rewards check` validates both together. A review request freezes the code and proposes rewards.json merged with the sheet; the administrator reads the code, checks the amounts and whether the app is exploitable, may change any amount, and approves. Creators are kids and teens: show approval status and one Send for review action; do not ask them to fill in technical forms. Every code update that retains rewards needs a new approval before publishing. Removing the SDK automatically clears its gate. Apps read amounts, tries and sets from getStatus, never from their own file.
37
37
  - Verifiers: 'numeric-quiz' pays for server-checked numeric answers (retry share, attempt limits, dated sets or until-earned sets that stay up until somebody earns them, after-answer guides). 'completion' pays when the app reports an activity finished — a cleared stage, a finished round — at least minSeconds after start({ ruleId }); the server checks only the elapsed time, once per learner per site day (UTC midnight), and the budgets. Call start when the activity begins and claim({ challengeId }) with no answers when it ends; keep completion amounts and userDailyXP small enough that a player scripting the calls would not matter, because nothing else is verified. 'generated-quiz' pays from a question bank the server fills itself: for one declared topic at the learner's step of the ladder, the server writes a question with GPT-6.1 Sol (billed to that learner's AI Energy), solves it again independently and keeps it only when both answers agree; every learner who has not seen a question gets it before anything new is written. `xp`/`coins` are the top step's amounts and each step pays its `steps` percent of them (then the usual retry share). Two first-try answers in a row climb a step; two misses in a row step down. Learners may earn many bank questions a day, bounded by userDailyXP/userDailyCoins. The reviewer approves the ladder, amounts, topics and instructions, not each question; learner reports and solve rates hide bad questions. Use the rewards bank methods (getBankStatus, startBankQuestion, generateBankQuestion, claimBankQuestion, reportBankQuestion); start and claim refuse bank rules. 'archive-rematch' is the daily rematch: one retired until-earned puzzle of the app a day, chosen by the server (celebrated, high-rated solves first; never repeated within 60 days) and answered from its frozen approved sheet. Declare at most one, with no questions, sets, progression, series, minSeconds or completionProof, e.g. { "id": "rematch-daily", "title": "Daily rematch", "xp": 20000, "coins": 1000, "verifier": "archive-rematch", "retry": { "xpPercent": 50, "coinsPercent": 0, "paidAttempts": 3 }, "maxAttempts": null }. Only players who never met that puzzle (no official receipt, wrong answer or challenge while it was live, no Vault practice) may answer it; its receipt does not use userDailyClaims but counts toward userDailyXP/userDailyCoins. Use getRematch and claimRematch; start and claim refuse it.
38
38
  - Numeric quiz answers are verified on the server; client scores, privateDb state, timers and completion booleans are not verified reward evidence. Limits reset at UTC midnight. Rules are earned once per viewer per UTC day; attempt limits and retry payouts come from the approved rule. Challenges expire at the UTC day boundary. Budgets apply across release changes.
39
- - Optional reward rule controls: maxLifetimeClaims caps one learner’s receipts for that rule across every day and release (a receipt capped by the daily budget still counts, so a one-time reward claimed near the cap pays only what fits); completionProof: classic-tower-v1 requires a server-simulated Classic Tower finish in addition to minSeconds. These are server-enforced controls. Existing completion rules without completionProof still verify elapsed time only. Registered proof profiles also include breadface-v1, breadface-v2 and breadface-v3 (server-simulated Breadface inputs; each is one reviewed release's exact physics, chosen by the reviewer) study-record-v1 (a private study record reviewed by JEV, billed to the learner’s AI Energy), and Groove Lab's groove-lab-song-v1 (a song the learner published today, checked on the server for length, notes and originality) and groove-lab-heard-v1 (three established accounts finished the learner's songs today), groove-lab-listen-v1 (the learner listened to most of someone else's published song: start binds the song, the server clock covers three quarters of it up to two minutes, and the listener's own groove-lab-listens row for it was written today; each song pays a listener once a day across listening rules) and groove-lab-own-listen-v1 (the same for the learner's own song; own listens never count toward groove-lab-heard-v1), Ashen Vigil's vigil-guest-coplay-v1 (a new guest spent 10 minutes with the learner in a private world room, measured by the world relay; each guest pays once) and minecraft-first-link-v1 (the learner is the first Twinkle account ever linked to that Minecraft player; each player pays once), and writing-words-v1 with minWords (counted words the learner wrote today as chapters in their own books registered in the app's shared books topic; each chapter is read once by a small model for genuine writing and language, English words count fully and other languages half, and the same text counts once), reading-quiz-v1 (the learner passed a server-graded three-question quiz on a chapter of someone else's registered book after its reading time; each chapter pays a reader once, one attempt per chapter per day) and reading-readers-v1 with minReaders (that many established accounts passed a quiz on the learner's chapters today). Twinkle.craft apps also have craft-target-v1 (the learner's run of merges reaches today's target in this app's universe; every merge is a known pair and every result is one the learner made through Twinkle.craft.merge), craft-par-v1 (the same run reaches it in at most par merges) and craft-discovery-v1 (the oldest World First of the learner's that no rule has paid yet; each World First pays once ever; optional minWordLevel basic|elementary|intermediate|advanced|epic makes tiers: either every craft-discovery-v1 rule has one or none does; each World First pays under exactly one tier, the highest configured tier at or below its Word Master level, so a series per tier can pay rarer words more and the order of claims never matters; an item whose level is still pending pays under no tier yet). Profiles are platform-owned; an app cannot invent a verifier or authorize its own reward.
39
+ - Optional reward rule controls: maxLifetimeClaims caps one learner’s receipts for that rule across every day and release (a receipt capped by the daily budget still counts, so a one-time reward claimed near the cap pays only what fits); completionProof: classic-tower-v1 requires a server-simulated Classic Tower finish in addition to minSeconds. These are server-enforced controls. Existing completion rules without completionProof still verify elapsed time only. Registered proof profiles also include breadface-v1, breadface-v2 and breadface-v3 (server-simulated Breadface inputs; each is one reviewed release's exact physics, chosen by the reviewer) study-record-v1 (a private study record reviewed by JEV, billed to the learner’s AI Energy), and Groove Lab's groove-lab-song-v1 (a song the learner published today, checked on the server for length, notes and originality) and groove-lab-heard-v1 (three established accounts finished the learner's songs today), groove-lab-listen-v1 (the learner listened to most of someone else's published song: start binds the song, the server clock covers three quarters of it up to two minutes, and the listener's own groove-lab-listens row for it was written today; each song pays a listener once a day across listening rules) and groove-lab-own-listen-v1 (the same for the learner's own song; own listens never count toward groove-lab-heard-v1), Ashen Vigil's vigil-guest-coplay-v1 (a new guest spent 10 minutes with the learner in a private world room, measured by the world relay; each guest pays once) and minecraft-first-link-v1 (the learner is the first Twinkle account ever linked to that Minecraft player; each player pays once), and writing-words-v1 with minWords (counted words the learner wrote today as chapters in their own books registered in the app's shared books topic; each chapter is read once by a small model for genuine writing and language, English words count fully and other languages half, and the same text counts once), reading-quiz-v1 (the learner passed a server-graded three-question quiz on a chapter of someone else's registered book after its reading time; each chapter pays a reader once, one attempt per chapter per day) and reading-readers-v1 with minReaders (that many established accounts passed a quiz on the learner's chapters today). Twinkle.craft apps also have craft-target-v1 (the learner's run of merges reaches today's target in this app's universe; every merge is a known pair and every result is one the learner made through Twinkle.craft.merge), craft-par-v1 (the same run reaches it in at most par merges, with no Twinkle.craft.hint revealed that day) and craft-discovery-v1 (the oldest World First of the learner's that no rule has paid yet; each World First pays once ever; optional minWordLevel basic|elementary|intermediate|advanced|epic makes tiers: either every craft-discovery-v1 rule has one or none does; each World First pays under exactly one tier, the highest configured tier at or below its Word Master level, so a series per tier can pay rarer words more and the order of claims never matters; an item whose level is still pending pays under no tier yet). Profiles are platform-owned; an app cannot invent a verifier or authorize its own reward.
40
+ - word-game-quiz-v1 is a completion reward proof for a correctly answered, Battery-settled Twinkle.wordMaster quiz from today in the same app/viewer/runtime mode. Pass quiz.completionToken to rewards.claim; each quiz pays once across all rules. A series of identical rules allows a bounded number of distinct daily quizzes. Normal review and publication approval still apply; a contribution preview never pays real XP or Coins.
41
+ - word-game-entry-v1 verifies a Battery-settled missing-definition/example contribution made today in this app/runtime mode, paying each contribution once. Its platform cap is three claims per viewer/app/day. word-game-quiz-v1 is capped at five. Declare matching completion rules and normal reward review; these proofs do not approve the app themselves.
42
+ - word-game-puzzle-v1 preserves Word Search streak bonuses using canonical puzzle reward history: daily-easy/medium/hard with optional -s10 and -s20 tiers. A learner earns each difficulty once per UTC day across every tier and release. rewards.getStatus returns wordGamePuzzle { streakDays, ruleIds, earnedLevels }; choose its ruleIds and display canonical rule amounts. Streaks count consecutive days with a puzzle reward, capped at 30. This profile checks streak eligibility and duplicate difficulty claims in addition to the approved timer; it does not simulate or prove the puzzle solve.
40
43
 
41
44
  ## AI decision design
42
45
  - Twinkle.ai.decide runs JEV, a model for narrow decisions over supplied text or structured state, and it is free for viewers: decisions are covered by Twinkle and never use their AI Energy. Whenever an app makes a judgment that ordinary code can only approximate, consider JEV first. Proven patterns: an opponent or boss coach that reads the player's habits (favourite lane, repeated trick, cheese move) and picks a counter-plan from a fixed list, as in Red Square's boss and Open Football's opponent coach; NPCs that understand what a player types and choose an action; classifying or scoring user submissions; ranking supplied candidates; and picking the next exercise or difficulty from evidence about the learner. Any app with an AI opponent, NPC, or adaptive difficulty should decide explicitly whether JEV improves it. These are examples to reason from, not a keyword checklist or a requirement to add AI to every app.
@@ -718,6 +721,64 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
718
721
  - This namespace is read-only and does not generate new AI Stories.
719
722
  - Example: const { story } = await Twinkle.aiStories.get(storyId);
720
723
 
724
+ ### Twinkle.wordMaster
725
+ - getReadyWords({ level, dayKey }) | scopes: none
726
+ - Returns: { dayKey, level, revision, catalogVersion, words, source } | null
727
+ - Read already-selected words synchronously, without a word request.
728
+ - Add <!--twinkle:word-game-ready--> inside the app HTML head. All five reviewed difficulty banks arrive in the normal HTML response, independently of creator code updates.
729
+ - Return the current UTC day as dayKey and easy, medium, hard, super or impossible as level. If null, use a bundled reviewed catalog immediately; never block play on fetching or preparing words. Pass the packet catalogVersion to encounterWords.
730
+ - async getDailyWords({ level }) | scopes: content:read
731
+ - Returns: { dayKey, level, revision, words: [{ id, word, wordLevel, definition }], source }
732
+ - Get today’s reviewed Word Master vocabulary for a word game.
733
+ - level: easy, medium, hard, super, impossible. Shared UTC daily sets are stable by revision. Words are checked against content_words_censored and reviewed before play. Missing daily rows use a checked catalog immediately; this endpoint never starts preparation or semantic review. Definitions may be empty until separately reviewed. Prefer getReadyWords plus a bundled catalog for zero word-loading requests.
734
+ - async encounterWords({ dayKey, level, catalogVersion, wordIds }) | scopes: content:write
735
+ - Returns: { addedWordIds }
736
+ - Add words encountered in today’s puzzle to this viewer’s private word book.
737
+ - Only IDs in today’s server word set are accepted. Up to 32 per request; repeated encounters do not duplicate entries.
738
+ - Pass catalogVersion from a ready packet or the reviewed bundled catalog. The server checks its exact day, level, revision and word IDs. Omitting it is supported only for legacy getDailyWords callers.
739
+ - async getWordBook({ cursor, query } = {}) | scopes: content:read
740
+ - Returns: { words: [{ id, word, definition, wordLevel, encounteredAt, correctCount, lastCorrectAt }], cursor }
741
+ - Read this viewer’s personal word book for this app.
742
+ - 40 words per page. Follow cursor until null. Reading never uses AI Battery.
743
+ - Optional query searches word text (up to 64 characters) across the book; keep the query unchanged when following cursor.
744
+ - async createQuiz({ wordId, itemType, requestId, useAiBattery, retry }) | scopes: content:write
745
+ - Returns: { quiz, aiUsagePolicy, mode }
746
+ - Generate one Word Master-style quiz using the viewer’s AI Battery.
747
+ - Show the Battery cost notice before calling; useAiBattery must be true. Use a stable 16–64 character requestId. itemType: clock (30 seconds), compass (first letter), trail (first three letters), freeze (20 seconds). Exactly one quiz per word per UTC day in each runtime mode. Ready/pending retries reuse it without generation or extra charging. Failed quizzes charge nothing; retry:true deliberately retries up to three total attempts. After a wrong answer, canAskAgain is true once: retry:true asks one new question about the same word, charged as a new Battery use, and a correct answer earns the item and quiz proof. Limit 60 generation attempts per viewer per day across apps. The word must already be in the book.
748
+ - async getQuiz({ quizId, requestId }) | scopes: content:read
749
+ - Returns: { quiz, aiUsagePolicy, mode }
750
+ - Recover a saved quiz after a timeout or reload without generating another.
751
+ - Pass quizId or requestId. A generated quiz finishes its one atomic Battery settlement here; ready quizzes never charge twice. This is a mutation for preview-capture and CLI permission purposes. Poll pending status with backoff. Failed status requires a deliberate retry. No answerIndex until answered.
752
+ - async answerQuiz({ quizId, choiceIndex }) | scopes: content:write
753
+ - Returns: { quiz, mode }
754
+ - Save the first answer and earn the chosen item when correct.
755
+ - One immutable answer, indexes 0–3. Duplicate/concurrent submissions return the canonical answer. After grading the correct answerIndex is returned for learning. A correct answer gives completionToken for word-game-quiz-v1 rewards; apps cannot mint items or rewards themselves.
756
+ - async getItems({ cursor } = {}) | scopes: content:read
757
+ - Returns: { items: [{ id, wordId, itemType, earnedAt, completionToken, claimedAt }], cursor, mode }
758
+ - List this viewer’s unspent, server-issued items.
759
+ - 100 per page. Follow cursor for more. Preview and live items are separate. Earned items do not expire when a day changes.
760
+ - async useItem({ itemId, requestId, puzzleKey }) | scopes: content:write
761
+ - Returns: { item: { id, itemType, usedAt, requestId, puzzleKey }, mode }
762
+ - Consume an earned item once and return a receipt for its game effect.
763
+ - puzzleKey starts items: and has at most 128 letters/digits/:|_-. Persist requestId and puzzleKey before sending. Retry the same IDs after a lost response; apply each receipt once and persist it with the session. Reuse on another puzzle is rejected. Item use is free of AI Battery.
764
+ - async getWord({ wordId, word }) | scopes: content:write
765
+ - Returns: { word: { id, word, definition, example, wordLevel, canGenerate }, mode }
766
+ - Open an existing Word Master word’s reviewed definition and example, and add it to the viewer’s book.
767
+ - Pass wordId or the exact word. Reuses reviewed existing definitions and suitable public AI Card examples. Blank definition/example means missing. Adds this encounter to the book but awards nothing; this is a write for CLI/capture permission purposes. No viewer AI Battery is used.
768
+ - async generateEntry({ wordId, requestId, useAiBattery }) | scopes: content:write
769
+ - Returns: { entry?, word?, alreadyComplete?, aiUsagePolicy, mode }
770
+ - Use viewer AI Battery to fill a missing meaning/example once, with a server-verified contribution receipt.
771
+ - Show Battery disclosure first; useAiBattery:true is required. A stable 16–64 character requestId prevents duplicate charging. Only missing text is generated; existing text is preserved. One shared reservation per word/runtime mode prevents concurrent duplicate generations. Failed/rejected output charges no Battery. A known failed job may be deliberately retried using a new requestId; never do this after an unknown transport outcome. At most 20 entry generation attempts per viewer/day. Successful live entries are shared across apps; preview entries remain separate. entry.completionToken works with word-game-entry-v1.
772
+ - async getEntry({ requestId }) | scopes: content:read
773
+ - Returns: { entry, aiUsagePolicy, mode }
774
+ - Recover an interrupted word-entry request without regenerating it.
775
+ - Recovery can finish the one Battery settlement and shared-text write, so CLI calls require --allow-write and preview captures cannot call it. Pending jobs may be polled with backoff; failed jobs need an explicit retry.
776
+ - async reportIssue({ event }) | scopes: content:write
777
+ - Returns: { recorded: true }
778
+ - Report a fixed word-game failure signal for daily website management.
779
+ - Allowed events: bank-failed, details-failed, quiz-failed, quiz-recovery-failed, answer-failed, entry-failed, entry-recovery-failed, item-recovery-failed, reward-failed, storage-failed, placement-failed.
780
+ - Send once per failure category per minute; send no word text, answers, free-form errors or member/device identifiers. These client reports are symptoms, not verified server failures.
781
+
721
782
  ### Twinkle.grammarbles
722
783
  - async listQuestions({ level, limit, cursor } = {}) | scopes: content:read
723
784
  - Returns: { questions: [{ id, level, rating, question, choices, isChecked }], cursor?, pagination: { level, limit, hasMore, nextCursor } }
@@ -1029,9 +1090,9 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
1029
1090
  - Example: const { level } = await Twinkle.minecraft.getPlayArea({ id: 'twinkle-keep' });
1030
1091
  - async getPlayAreaChunks({ id, chunks: [[cx, cz], ...] }) | scopes: content:read
1031
1092
  - Returns: { scenes: [{ cx, cz, scene: { origin: [x,y,z], size: [w,h,d], palette, blocks, light, biomes, count, ... } | null }] }
1032
- - 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.
1093
+ - 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.
1033
1094
  - 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.
1034
- - At most 24 chunks per call; reads share the 180-per-minute build read limit, so cache chunks and load the nearest first.
1095
+ - At most 9 chunks per call; reads share the 180-per-minute build read limit, so cache chunks and load the nearest first.
1035
1096
  - Example: const { scenes } = await Twinkle.minecraft.getPlayAreaChunks({ id: level.id, chunks: [[64, 29], [65, 29]] });
1036
1097
  - async createPlayArea({ name, world, box, spawn, id }) | scopes: content:write
1037
1098
  - Returns: { level }
@@ -1517,7 +1578,7 @@ world.updatePresence({ x, y, z, facing });
1517
1578
  - Completion rules take no answers: claim({ challengeId }) when the activity is finished. build_reward_too_fast (HTTP 409) means fewer than minSeconds passed since start; show nothing and let play continue. In preview mode the receipt carries preview: true and nothing is paid.
1518
1579
  - A completionProof rule also requires the signed completionToken from a successful rewards.progress response. The server simulates the registered game physics and must reach the goal. A timer, forged position, client win flag, altered inventory or token from another viewer, challenge or release cannot authorize payment. maxLifetimeClaims is enforced from receipts in the same award transaction; every receipt counts, including one capped by the daily budget (cappedFrom). build_reward_lifetime_claims_reached means all rewards for this rule have been collected; do not retry it.
1519
1580
  - For study-record-v1, the server verifies the settled private review row and its viewer, app, exact release, challenge and day binding. It never trusts the client’s decision, requested award amount or AI answer. Each successful daily study record is claimable once; display only canonical receipt/balances. For groove-lab-song-v1, pass completionToken as the published song's sharedDb entry id; for groove-lab-heard-v1, pass none; for vigil-guest-coplay-v1, pass the guest's world guestId (from world events) or the co-play record id; for minecraft-first-link-v1, pass nothing or the Minecraft UUID to use; for reading-quiz-v1, pass completionToken as the chapter commentId and answers as the three chosen option indexes (all three must be right; a wrong attempt cannot be retried that day); for groove-lab-listen-v1 and groove-lab-own-listen-v1, pass the same song entry id after listenSeconds of foreground playback; for reading-readers-v1, pass nothing; for writing-words-v1, pass nothing (the server counts today's chapters itself and refuses with a plain reason, e.g. "320 of 500 counted words today" or "still being checked"). The server reads the app's own groove-lab-songs, groove-lab-song-parts and groove-lab-listens rows and refuses with build_reward_completion_proof_required and a plain reason when the song or listeners do not qualify.
1520
- - For craft-target-v1 and craft-par-v1, pass completionToken as JSON.stringify({ v: 1, merges: [[a, b], ...] }): the item ids of the run's successful merges in order, starting from the starters (at most 300 merges, 32 KB). The server replays it against this app's known pairs, needs today's target (Twinkle.craft.getState().today) to appear, every result to be in the learner's collection, and at least one second per merge since start; it refuses an unknown pair, an item the run had not made yet, an over-par run (par rule) or a run on another day. For craft-discovery-v1, pass nothing: the server pays the oldest unpaid World First (receipt slot craft:<itemId>) and puts its name on the bounty story ("discovered <item>"). With tiers, a rule pays only World Firsts of its own tier (refused with a plain reason when the learner has none).
1581
+ - For craft-target-v1 and craft-par-v1, pass completionToken as JSON.stringify({ v: 1, merges: [[a, b], ...] }): the item ids of the run's successful merges in order, starting from the starters (at most 300 merges, 32 KB). The server replays it against this app's known pairs, needs today's target (Twinkle.craft.getState().today) to appear, every result to be in the learner's collection, and at least one second per merge since start; it refuses an unknown pair, an item the run had not made yet, an over-par run (par rule), a par claim on a day the learner revealed a Twinkle.craft.hint (the target rule still pays) or a run on another day. For craft-discovery-v1, pass nothing: the server pays the oldest unpaid World First (receipt slot craft:<itemId>) and puts its name on the bounty story ("discovered <item>"). With tiers, a rule pays only World Firsts of its own tier (refused with a plain reason when the learner has none).
1521
1582
  - await Twinkle.rewards.getLeaderboard({ metric?: "xp" | "coins", period?: "day" | "week" | "all", limit? }) | scopes: rewards:claim
1522
1583
  - Returns: { mode: "live", metric, period, limit, dayKey, from, available: { xp, coins }, entries: [{ rank, userId, username, profilePicUrl, xp, coins, claims, lastAt }], me: { rank, xp, coins, claims } | null } | { mode: "preview", metric, period, available, entries: [], me: null, message }
1523
1584
  - Standings of who earned the most XP or Coins in THIS app, computed by Twinkle from its own receipts (never from anything the app submits). period 'day' is today (site day, UTC), 'week' the last 7 site days, 'all' (default) every day since approval. limit defaults to 20, max 100.
@@ -1679,6 +1740,15 @@ if (result.status === 'ok') showCard(result.item, result.why, result.worldFirst)
1679
1740
  - await Twinkle.craft.journey({ day? }) | scopes: craft:read
1680
1741
  - Returns: { day, journey: { target: item, par, steps: [{ a, b, result }] } | null, reason?: "no_target" | "reach_target_first" | "sign_in_required" }
1681
1742
  - The server's par plan for a day (YYYY-MM-DD, default today), each step { id, name, emoji } items. For today it opens only after the player made the target today (reason reach_target_first before that); past days are open too, except a past day whose target is today's target (same rule as today).
1743
+ - await Twinkle.craft.hint({ peek? }) | scopes: craft:read
1744
+ - Returns: { day, hints: [{ item: { id, name, emoji }, a: { id, name, emoji }, b: { id, name, emoji } }], remaining: number, parForfeited: boolean, reason?: "no_target" | "sign_in_required" }
1745
+ - The hint ladder for Today's Star. Each call reveals ONE more recipe of today's par plan, top-down: first the target's own recipe (item = a + b), then the recipe of one of its ingredients, and so on down the plan (breadth first; starters have no recipe). It returns every recipe this player revealed today, in that order, plus how many are left (remaining 0: the whole plan is shown).
1746
+ - Any hint gives up today's par bonus: once the player revealed one, parForfeited is true and a craft-par-v1 claim is refused for the rest of the site day (UTC). The craft-target-v1 reward still pays. Tell the player before the first hint, and use { peek: true } to show the hints already revealed (after a reload) without revealing another.
1747
+ - Hints are kept per player per app per site day on the server and start over the next day. Only today's plan is ever revealed.
1748
+ - reason no_target: no Today's Star yet (hints []). Signed-out viewers get { day, hints: [], remaining: 0, parForfeited: false, reason: "sign_in_required" }; a hint asks nothing of them.
1749
+ - Example: const { hints, remaining, parForfeited } = await Twinkle.craft.hint();
1750
+ const latest = hints[hints.length - 1];
1751
+ showHint(`${latest.item.emoji} ${latest.item.name} = ${latest.a.name} + ${latest.b.name}`, remaining);
1682
1752
  - await Twinkle.craft.rankings() | scopes: craft:read
1683
1753
  - Returns: { allTime: { top: [{ rank, userId, username, profilePicUrl, worldFirsts }], me: { rank: number | null, worldFirsts } }, month: { month, top, me: { rank: number | null, worldFirsts } } }
1684
1754
  - World First rankings in this app: the top 30 discoverers all time and this month (UTC), and the viewer's own rank (null with no World Firsts). The top lists may be up to a minute old. Signed-out viewers get empty top lists and me: { rank: null, worldFirsts: 0 }.
@@ -1609,7 +1609,7 @@ lumine admin roles --min-level 3 # only the admin types' holders
1609
1609
  lumine admin roles --min-level 0 --limit 200 --json
1610
1610
  ```
1611
1611
 
1612
- ### Twinkle.craft universes: seed, audit, revise (any time; added 2026-10-12)
1612
+ ### Twinkle.craft universes: seed, audit, revise (any time; added 2026-10-12, revise --pair 2026-10-16)
1613
1613
 
1614
1614
  An app using `Twinkle.craft` (Infinity Craft, build 1499) has its own
1615
1615
  universe of items and pairs. Players' new pairs are answered by the AI on
@@ -1623,6 +1623,8 @@ lumine admin craft stats --build 1499 [--limit 50] # sizes, frontier, toda
1623
1623
  lumine admin craft seed --build 1499 --file pairs.json # ALWAYS first: what would be added, skipped, rejected, warned
1624
1624
  lumine admin craft seed --build 1499 --file pairs.json --confirm # only after reading every warning
1625
1625
  lumine admin craft revise --build 1499 --item Obsidian --why "Lava that cools very fast turns into glassy rock." --confirm
1626
+ lumine admin craft revise --build 1499 --pair "Earth + Smoke" --why "Smoke rising from the earth can mean a volcano below." # dry run
1627
+ lumine admin craft revise --build 1499 --pair 4821 --why "<text>" --confirm # by pair id
1626
1628
  ```
1627
1629
 
1628
1630
  - `pairs.json` is up to 500 pairs, in order: `[{ "a": "Water", "b": "Fire",
@@ -1657,6 +1659,15 @@ lumine admin craft revise --build 1499 --item Obsidian --why "Lava that cools ve
1657
1659
  - `revise` changes an item's `--why` (the explanation on its card: the pair
1658
1660
  that first made it), `--emoji` or `--category`, through the same checks. The
1659
1661
  name never changes: players hold it. Audited as `craft.revise`.
1662
+ - `revise --pair` (added 2026-10-16) changes one pair's `--why`: the line a
1663
+ player sees when they make THAT pair (an item made by several pairs has one
1664
+ why per pair, and `--item` reaches only the first). The pair is its id (the
1665
+ `#n` in `stats`' newest AI answers) or `"A + B"` by item names, in either
1666
+ order. Only the why changes, never the pair's result; a fizzle has no why.
1667
+ Same checks as AI answers (one plain-text sentence, at most 140
1668
+ characters), dry run unless `--confirm`, audited as `craft.revise` with the
1669
+ pair in the audit metadata. `--pair` takes no `--item`, `--emoji` or
1670
+ `--category`.
1660
1671
  - Expansion: `stats` shows the frontier (items no pair uses yet, and untried
1661
1672
  pairs); seed pairs that use frontier items so Today's Star has fresh targets.
1662
1673
 
@@ -5348,3 +5359,8 @@ On the website, Zero and Ciel acting for an admin can make these staff decisions
5348
5359
  - `lumine admin meetup set-branch <crewId> <userId> --branch <name> [--note <text>]` corrects a member's branch instead of a database write. `--branch` must be an official branch (a registry alias such as `Busan` resolves to its official name, `Haeundae`), a name still pending in the branch registry, or `"Not a Twinkle student"`. Former Twinkle students get the branch they went to. An official branch is verified at once; a pending name waits for its branch decision. The crew's progress, tier and staff alerts follow the new branch; approvals already given stay. The same branch again changes nothing. The private note and the change history are shown only to admins (`meetup show`, the crew page's staff panel); nobody is messaged. The command refuses members who left and crews that are not active.
5349
5360
 
5350
5361
  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.
5362
+
5363
+
5364
+ ## Word Search vocabulary upkeep
5365
+
5366
+ Every full daily website-management run adds reviewed Word Master words across all five difficulty bands, with accurate definitions and natural example sentences. Use `lumine admin word-games status`, `candidates <1-5> --after <id> --limit 100`, and `import <reviewed-words.json> --dry-run` followed by the reviewed import. File shape: `{items:[{wordId,word,wordLevel,definition,example}]}` (up to 100). Candidate exports are unreviewed; use agent judgment and check the current censor table before importing. No paid batch APIs. The import is audited and preserves active generation and today's existing puzzles; future banks include the additions. The app receives all five ready banks in its normal HTML and has a bundled checked fallback, so word selection never creates a loading screen or separate word request. Report additions per difficulty and text corrections. Repository agents follow `agent-guides/word-search-vocabulary.md` for the full procedure. A scoped management request does not start this full-run duty.