@stage5/lumine 0.3.88 → 0.3.90

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/README.md CHANGED
@@ -486,6 +486,19 @@ after validation passes and still uses the workspace's server-issued
486
486
  overwritten. Provider login and model usage stay inside the selected local CLI;
487
487
  Twinkle never receives a provider credential and does not reserve AI Energy.
488
488
 
489
+ The project tools are Lumine's own (list, read, search, edit). Like grep,
490
+ `search_project_files` can match plain text (the default), a JavaScript regular
491
+ expression (`mode: "regex"`, up to 200 characters, with a time limit, one
492
+ regex search at a time and a per-minute text budget per member), or a whole
493
+ class with `kind: "emoji"` or `kind: "symbol"`. `kind: "emoji"` counts only
494
+ characters that show as emoji: a text-style symbol such as an arrow or bullet
495
+ counts only when followed by U+FE0F, otherwise it is listed under
496
+ `kind: "symbol"`. A kind finds each one written directly and written as an
497
+ escape: `\u25B6`, `\u{1F512}`, surrogate
498
+ pairs, CSS `content: "\25B6"`, `🔒`, or `String.fromCodePoint(...)`. When
499
+ results are capped, a regex runs out of time, or a long line is only partly
500
+ checked, the result sets `truncated` and says in `note` what was left out.
501
+
489
502
  Every run writes a sanitized observable tool trace under
490
503
  `.twinkle/agent-runs/`. By default, the selected subscription agent reviews
491
504
  that trace after the pass and records evidence-based loop feedback without
@@ -0,0 +1,239 @@
1
+ // lumine admin coins: Twinkle.coins purchases and the builder tally's one-time
2
+ // fill (the server is controllers/cli/admin/coinCharges.ts). Writes are dry
3
+ // runs unless --confirm.
4
+ import { createHash } from "node:crypto";
5
+ import { readFileSync } from "node:fs";
6
+ import path from "node:path";
7
+ import { requestJson } from "./http.js";
8
+
9
+ const USAGE =
10
+ "Usage: lumine admin coins list --build <id> [--user <id>] [--price <priceId>] [--key <itemKey>] | refund <receiptId> --reason <text> [--keep-recipient (purchases only, not sales/takeovers)] [--confirm] | release --build <id> --price <priceId> --key <itemKey> --reason <text> [--confirm] | tally-import <buildId> --file <cells.json> --reason <text> [--replace-region <region>] [--confirm] | tally-clear <buildId> --user <id> [--region <region>] [--area <x0:z0:x1:z1>] --reason <text> [--confirm] [--json]";
11
+ // Cells per tally-import request (COIN_TALLY_LIMITS.importBatchCells on the API).
12
+ export const TALLY_IMPORT_BATCH_CELLS = 5000;
13
+ // One spelling per world (review-r2 M3): COIN_TALLY_REGION_PATTERN on the API.
14
+ const TALLY_REGION = /^[a-z0-9-]{1,64}$/;
15
+ const TALLY_NUMBER = /^(0|-?[1-9][0-9]{0,6})$/;
16
+ function invalid(message) {
17
+ const error = new Error(message);
18
+ error.code = "CLI_ADMIN_CLI_VALIDATION";
19
+ throw error;
20
+ }
21
+ function positiveInteger(value, flag) {
22
+ const number = Number(String(value || "").trim());
23
+ if (!Number.isSafeInteger(number) || number <= 0) invalid(`${flag} must be a positive whole number. ${USAGE}`);
24
+ return number;
25
+ }
26
+ export function buildCoinsOperation({ action, target, options }) {
27
+ if (options.adminConfirm !== "" && options.adminConfirm !== "true")
28
+ invalid("Pass --confirm on its own, without a value.");
29
+ const confirm = options.adminConfirm === "true";
30
+ if (!action || action === "list") {
31
+ const query = new URLSearchParams({ buildId: String(positiveInteger(options.adminBuild, "--build")) });
32
+ if (options.adminUser) query.set("userId", String(positiveInteger(options.adminUser, "--user")));
33
+ if (options.adminCoinPrice) query.set("priceId", options.adminCoinPrice);
34
+ if (options.adminCoinKey) query.set("key", options.adminCoinKey);
35
+ return {
36
+ name: "coins.list", method: "GET", path: `/cli/admin/coins/purchases?${query}`,
37
+ mutates: false, requiresRun: false
38
+ };
39
+ }
40
+ const reason = String(options.adminReason || "").trim();
41
+ if (!reason) invalid(`--reason is required. ${USAGE}`);
42
+ if (action === "tally-import") {
43
+ const buildId = positiveInteger(target, "<buildId>");
44
+ if (!options.adminFile) invalid(`--file is required. ${USAGE}`);
45
+ const file = readTallyFile(options.adminFile);
46
+ if (file.buildId !== undefined && Number(file.buildId) !== buildId)
47
+ invalid(`${options.adminFile} was made for app ${file.buildId}, not ${buildId}.`);
48
+ const replaceRegion = String(options.adminCoinReplaceRegion || "").trim();
49
+ // Clearing a region is spelled out: the flag must name the file's region.
50
+ if (replaceRegion && replaceRegion !== file.region)
51
+ invalid(`--replace-region must name the file's region (${file.region}).`);
52
+ return {
53
+ name: confirm ? "coins.tally-import" : "coins.tally-import.preview",
54
+ method: "POST", path: "/cli/admin/coins/tally-import",
55
+ coinsWorkflow: "tally-import", buildId, reason, replace: Boolean(replaceRegion), file,
56
+ batches: tallyImportBatches(file, { buildId, replace: Boolean(replaceRegion), reason, dryRun: !confirm }),
57
+ mutates: confirm, requiresRun: false
58
+ };
59
+ }
60
+ // The admin's fix for fake builder records (review-r2 M2): one player's
61
+ // recorded blocks in one app, optionally one region and one area.
62
+ if (action === "tally-clear") {
63
+ const buildId = positiveInteger(target, "<buildId>");
64
+ const userId = positiveInteger(options.adminUser, "--user");
65
+ const region = String(options.adminCoinRegion || "").trim();
66
+ const area = String(options.adminCoinArea || "").trim();
67
+ if (region && !TALLY_REGION.test(region)) invalid("--region is the world name: 1 to 64 lowercase letters, digits or dashes.");
68
+ if (area) {
69
+ if (!region) invalid("--area needs --region.");
70
+ const parts = area.split(":");
71
+ if (parts.length !== 4 || !parts.every((part) => TALLY_NUMBER.test(part)))
72
+ invalid("--area is x0:z0:x1:z1 (whole numbers, both corners included).");
73
+ const [x0, z0, x1, z1] = parts.map(Number);
74
+ if (x1 < x0 || z1 < z0) invalid("--area is x0:z0:x1:z1 with x0 <= x1 and z0 <= z1.");
75
+ }
76
+ return {
77
+ name: confirm ? "coins.tally-clear" : "coins.tally-clear.preview",
78
+ method: "POST", path: "/cli/admin/coins/tally-clear",
79
+ body: { buildId, userId, ...(region ? { region } : {}), ...(area ? { area } : {}), reason, dryRun: !confirm },
80
+ mutates: confirm, requiresRun: false
81
+ };
82
+ }
83
+ if (action === "refund")
84
+ return {
85
+ name: confirm ? "coins.refund" : "coins.refund.preview", method: "POST", path: "/cli/admin/coins/refund",
86
+ body: {
87
+ receiptId: positiveInteger(target, "<receiptId>"), reason,
88
+ fromRecipient: !options.adminCoinKeepRecipient, dryRun: !confirm
89
+ },
90
+ mutates: confirm, requiresRun: false
91
+ };
92
+ if (action === "release") {
93
+ if (!options.adminCoinPrice || !options.adminCoinKey) invalid(`--price and --key are required. ${USAGE}`);
94
+ return {
95
+ name: confirm ? "coins.release" : "coins.release.preview", method: "POST", path: "/cli/admin/coins/release",
96
+ body: {
97
+ buildId: positiveInteger(options.adminBuild, "--build"), priceId: options.adminCoinPrice,
98
+ key: options.adminCoinKey, reason, dryRun: !confirm
99
+ },
100
+ mutates: confirm, requiresRun: false
101
+ };
102
+ }
103
+ invalid(USAGE);
104
+ }
105
+ // cells.json from firestar-land/probes/build-tally-import.mjs:
106
+ // { format: "twinkle-coin-tally-v1", buildId?, region, at, placed: [[x, y, z, userId]], removed?: [[x, y, z]] }
107
+ export function readTallyFile(filePath) {
108
+ let buffer;
109
+ try {
110
+ buffer = readFileSync(path.resolve(filePath));
111
+ } catch (error) {
112
+ invalid(`Could not read ${filePath}: ${error.message}`);
113
+ }
114
+ let file;
115
+ try {
116
+ file = JSON.parse(buffer.toString("utf8"));
117
+ } catch {
118
+ invalid(`${filePath} is not JSON.`);
119
+ }
120
+ if (file?.format !== "twinkle-coin-tally-v1") invalid(`${filePath} is not a tally file (format twinkle-coin-tally-v1).`);
121
+ if (!TALLY_REGION.test(String(file.region || ""))) invalid(`${filePath}: region must be 1 to 64 lowercase letters, digits or dashes (a-z 0-9 -).`);
122
+ if (!Number.isSafeInteger(file.at) || file.at <= 0) invalid(`${filePath}: at must be the server time (seconds) of the newest history row.`);
123
+ const placed = Array.isArray(file.placed) ? file.placed : invalid(`${filePath}: placed must be a list.`);
124
+ const removed = file.removed === undefined ? [] : Array.isArray(file.removed) ? file.removed : invalid(`${filePath}: removed must be a list.`);
125
+ return {
126
+ buildId: file.buildId, region: file.region, at: file.at, placed, removed,
127
+ sha256: createHash("sha256").update(buffer).digest("hex")
128
+ };
129
+ }
130
+ // The requests one file becomes: placed cells, then removed cells, at most
131
+ // TALLY_IMPORT_BATCH_CELLS a request. Only the first may clear the region.
132
+ export function tallyImportBatches(file, { buildId, replace, reason, dryRun }) {
133
+ const cells = [...file.placed.map((cell) => ["placed", cell]), ...file.removed.map((cell) => ["removed", cell])];
134
+ const count = Math.max(1, Math.ceil(cells.length / TALLY_IMPORT_BATCH_CELLS));
135
+ return Array.from({ length: count }, (_, index) => {
136
+ const slice = cells.slice(index * TALLY_IMPORT_BATCH_CELLS, (index + 1) * TALLY_IMPORT_BATCH_CELLS);
137
+ return {
138
+ buildId, region: file.region, at: file.at,
139
+ placed: slice.filter(([kind]) => kind === "placed").map(([, cell]) => cell),
140
+ removed: slice.filter(([kind]) => kind === "removed").map(([, cell]) => cell),
141
+ replace: Boolean(replace) && index === 0,
142
+ ...(replace && index === 0 ? { replaceRegion: file.region } : {}),
143
+ fileSha256: file.sha256, batchIndex: index, batchCount: count, reason, dryRun
144
+ };
145
+ });
146
+ }
147
+ // The same file, region, mode and batch always use the same idempotency key:
148
+ // a re-run after a crash replays finished batches from their audit records
149
+ // and applies only the rest.
150
+ export function tallyImportRequestKey(batch) {
151
+ return `cli:tally:${batch.buildId}:${batch.fileSha256.slice(0, 16)}:${batch.replace ? "r" : "m"}:${batch.batchIndex}`;
152
+ }
153
+ export async function runCoinsTallyImport({ options, operation, authToken, request = requestJson, log = () => {} }) {
154
+ const results = [];
155
+ for (const batch of operation.batches) {
156
+ const receipt = await request({
157
+ method: "POST",
158
+ url: `${options.apiUrl}${operation.path}`,
159
+ authToken,
160
+ body: batch,
161
+ headers: batch.dryRun ? {} : { "x-lumine-idempotency-key": tallyImportRequestKey(batch) },
162
+ timeoutMs: Math.max(120000, Number(options.timeoutMs) || 0),
163
+ });
164
+ if (!receipt || receipt.ok !== true) {
165
+ const error = new Error(receipt?.error?.message || `Tally import batch ${batch.batchIndex + 1} failed; earlier batches stay applied. Re-run the same command to finish.`);
166
+ error.code = receipt?.error?.code || "CLI_ADMIN_COINS_TALLY_FAILED";
167
+ throw error;
168
+ }
169
+ results.push(receipt.data || {});
170
+ log(`batch ${batch.batchIndex + 1}/${batch.batchCount}: ${batch.placed.length} placed, ${batch.removed.length} removed`);
171
+ }
172
+ const sum = (key) => results.reduce((total, row) => total + (Number(row?.[key]) || 0), 0);
173
+ const last = results[results.length - 1] || {};
174
+ return {
175
+ ok: true,
176
+ status: "success",
177
+ data: {
178
+ dryRun: operation.batches[0]?.dryRun !== false,
179
+ buildId: operation.buildId,
180
+ region: operation.file.region,
181
+ at: operation.file.at,
182
+ fileSha256: operation.file.sha256,
183
+ batches: results.length,
184
+ regionCellsBefore: Number(results[0]?.regionCellsBefore || 0),
185
+ cleared: sum("cleared") || sum("wouldClear"),
186
+ // What --replace-region keeps (review-r2 M4): live, newer and used-up blocks.
187
+ replaceKeeps: results[0]?.replaceKeeps || null,
188
+ placed: sum("placed"),
189
+ removed: sum("removed"),
190
+ keptNewer: sum("keptNewer"),
191
+ keptNewerPlaced: sum("keptNewerPlaced"),
192
+ keptNewerRemoved: sum("keptNewerRemoved"),
193
+ regionCellsAfter: last.regionCellsAfter ?? null,
194
+ importIds: results.map((row) => row?.importId).filter(Boolean)
195
+ }
196
+ };
197
+ }
198
+
199
+ export function formatCoinsResult({ operation, data }) {
200
+ if (operation.name.startsWith("coins.tally-clear")) {
201
+ const regions = (data?.regions || []).map((row) => `${row.region} ${row.cells} (x ${row.bounds?.x0}..${row.bounds?.x1}, z ${row.bounds?.z0}..${row.bounds?.z1})`);
202
+ return [
203
+ `${data?.dryRun ? "Dry run: would clear" : "Cleared"} ${data?.dryRun ? data?.cells : data?.cleared} recorded blocks of ${data?.username || "player"} (${data?.userId}) in app ${data?.buildId}${data?.region ? `, region ${data.region}` : ", every region"}${data?.area ? `, area ${data.area.x0}:${data.area.z0}:${data.area.x1}:${data.area.z1}` : ""}.`,
204
+ ...(regions.length ? [` by region: ${regions.join("; ")}`] : []),
205
+ ` ${data?.consumed || 0} were already used up by a sale (that sale paid them; only a refund moves Coins) · ${data?.touchedByOthers || 0} were broken or built over by another player since (a hint the block was real) · ${data?.imported || 0} came from the one-time fill.`,
206
+ ...(data?.dryRun ? ["Nothing was written. Add --confirm to apply."] : [` receipt ${data?.receiptId}`]),
207
+ ];
208
+ }
209
+ if (operation.name.startsWith("coins.tally-import")) {
210
+ const keeps = data?.replaceKeeps;
211
+ return [
212
+ `${data?.dryRun ? "Dry run: would import" : "Imported"} the builder tally of app ${data?.buildId}, region ${data?.region} (history up to ${data?.at ? new Date(data.at * 1000).toISOString() : "?"}), in ${data?.batches} batch${data?.batches === 1 ? "" : "es"}:`,
213
+ ` region had ${data?.regionCellsBefore} recorded blocks${data?.cleared || keeps ? `; ${data?.dryRun ? "would clear" : "cleared"} ${data?.cleared || 0} older imported blocks (--replace-region)` : ""}`,
214
+ ...(keeps ? [` --replace-region keeps ${keeps.total} blocks: ${keeps.live} recorded live (never touched by an import), ${keeps.newer} imported from newer history, ${keeps.consumed} used up by a sale`] : []),
215
+ ` ${data?.placed} placed blocks, ${data?.removed} removals${data?.keptNewer ? `; ${data.keptNewer} file cells left alone because a live, newer or used-up block holds them (${data.keptNewerPlaced || 0} placements, ${data.keptNewerRemoved || 0} removals)` : ""}`,
216
+ ...(data?.dryRun ? ["Nothing was written. Add --confirm to apply."] : [` region now has ${data?.regionCellsAfter} recorded blocks · receipts ${(data?.importIds || []).join(", ")}`]),
217
+ ];
218
+ }
219
+ if (operation.name === "coins.list") {
220
+ const receipts = data?.receipts || [];
221
+ if (!receipts.length) return ["No purchases."];
222
+ return [
223
+ ...receipts.map((r) =>
224
+ `#${r.id} ${new Date(r.createdAt * 1000).toISOString()} ${r.buyer?.username || r.buyer?.id} ${r.kind} ${r.priceId}${r.key ? ` [${r.key}]` : ""} ${r.coins} Coins → ${r.destination}${r.recipient ? ` ${r.recipient.username || r.recipient.id}` : ""}${r.builders ? ` + builders ${r.builders.map((b) => `${b.userId}:${b.coins}`).join(",")}` : ""} · ${r.status}${r.holds ? " (holds)" : ""}`),
225
+ ...(data?.nextBefore ? [`More: re-run with the oldest id as a cursor (before ${data.nextBefore}).`] : [])
226
+ ];
227
+ }
228
+ if (operation.name.startsWith("coins.refund")) {
229
+ const takeBack = (data?.takeBackFrom || []).map((entry) => `${entry.username || entry.id} ${entry.coins}`).join(", ");
230
+ return [
231
+ `${data?.dryRun ? "Dry run: would refund" : "Refunded"} receipt #${data?.receiptId}: ${data?.refundCoins} Coins back to ${data?.buyer?.username || data?.buyer?.id}${takeBack ? `, taking back ${takeBack}` : ", taking nothing back"}${data?.platformCovers ? ` (the platform covers ${data.platformCovers} Coins)` : ""}${data?.restoresReceiptId ? `; the item returns to receipt #${data.restoresReceiptId}` : ""}.`,
232
+ ...(data?.dryRun ? ["Nothing was written. Add --confirm to apply."] : [])
233
+ ];
234
+ }
235
+ return [
236
+ `${data?.dryRun ? "Dry run: would release" : "Released"} receipt #${data?.receiptId} (holder ${data?.holderId}). No Coins move.`,
237
+ ...(data?.dryRun ? ["Nothing was written. Add --confirm to apply."] : [])
238
+ ];
239
+ }
package/lib/admin.js CHANGED
@@ -52,6 +52,8 @@ import { buildRolesOperation, printRoles } from "./admin-roles.js";
52
52
  import { buildSpendingOperation, completeSpendingReport, formatSpending } from "./admin-spending.js";
53
53
  import { buildCraftOperation, formatCraftResult } from "./admin-craft.js";
54
54
  import { buildNonogramOperation, formatNonogramResult } from './admin-nonogram.js';
55
+ import { buildCoinsOperation, formatCoinsResult, runCoinsTallyImport } from './admin-coins.js';
56
+ import { formatCoinPrices } from './rewards.js';
55
57
  import { buildArenaOperation, formatArenaResult } from "./admin-arena.js";
56
58
  import { buildJevRoutingReviewOperation, formatJevRoutingReview } from "./admin-jev-review.js";
57
59
  import { buildJevImagesOperation, formatJevImagesResult } from "./admin-jev-images.js";
@@ -1698,6 +1700,14 @@ export async function adminCommand(options) {
1698
1700
  runId,
1699
1701
  parseTarget: parseRecommendationTarget,
1700
1702
  });
1703
+ } else if (operation.coinsWorkflow === "tally-import") {
1704
+ // The builder tally's one-time fill: one request per batch of cells.
1705
+ result = await runCoinsTallyImport({
1706
+ options,
1707
+ operation,
1708
+ authToken: auth.token,
1709
+ log: options.json ? () => {} : (line) => console.error(line),
1710
+ });
1701
1711
  } else if (operation.teacherWorkflow) {
1702
1712
  result = await runTeacherWorkflow({
1703
1713
  options,
@@ -2826,6 +2836,8 @@ export function parseAdminOperation(options) {
2826
2836
  return buildSlidingBlockOperation({ action, options });
2827
2837
  }
2828
2838
  if (namespace === 'nonogram') return buildNonogramOperation({ action, target, options });
2839
+ // Twinkle.coins purchases: list, refund, release (admin-coins.js); no run.
2840
+ if (namespace === "coins") return buildCoinsOperation({ action, target, options });
2829
2841
  // Arena's mock-exam item bank (admin-arena.js); no run.
2830
2842
  if (namespace === "arena") return buildArenaOperation({ action, options });
2831
2843
  // Stuck Lumine Build plans and their free recheck (admin-build-plans.js).
@@ -6474,6 +6486,11 @@ export function printRewardReviewResult({ operation, data }) {
6474
6486
  for (const rule of config.rules)
6475
6487
  console.log(` ${formatRewardRuleLine(rule)}`);
6476
6488
  }
6489
+ // Coin prices, with what builder payments add (review L6, 11-07), and the
6490
+ // same Coin warnings the website reviewer sees (residual builder risk).
6491
+ for (const line of formatCoinPrices(config)) console.log(line);
6492
+ for (const warning of Array.isArray(review.coinPriceWarnings) ? review.coinPriceWarnings : [])
6493
+ console.log(`WARNING: ${warning}`);
6477
6494
  if (Array.isArray(review.files)) {
6478
6495
  console.log(
6479
6496
  `Source: ${review.files.length} file(s)${data.snapshotDirectory ? ` written to ${data.snapshotDirectory}` : " (pass --dir <path> to write the snapshot)"}`,
@@ -7816,6 +7833,10 @@ function printAdminResult({ operation, result }) {
7816
7833
  for (const line of formatNonogramResult({ operation, data })) console.log(line);
7817
7834
  return;
7818
7835
  }
7836
+ if (operation.name.startsWith("coins.")) {
7837
+ for (const line of formatCoinsResult({ operation, data })) console.log(line);
7838
+ return;
7839
+ }
7819
7840
  if (operation.name.startsWith("build-plans.")) {
7820
7841
  for (const line of formatBuildPlansResult({ operation, data }))
7821
7842
  console.log(line);
package/lib/commands.js CHANGED
@@ -1036,10 +1036,27 @@ export async function updateBranchFromMain(options) {
1036
1036
  method: "POST",
1037
1037
  url: `${options.apiUrl}/build/${rootBuildId}/contributions/${contributionBuildId}/update-from-main`,
1038
1038
  authToken: auth.token,
1039
- body: projectFiles ? { projectFiles, baseFilesHash } : {},
1040
- timeoutMs: options.timeoutMs,
1039
+ body: {
1040
+ ...(projectFiles ? { projectFiles, baseFilesHash } : {}),
1041
+ // Only an explicit --stop-lumine stops a Lumine run working on the
1042
+ // branch (like its Stop button) before the update.
1043
+ ...(options.stopLumine ? { stopActiveRun: true } : {}),
1044
+ },
1045
+ // The server waits (up to 20 s) for the stopped run to finish first.
1046
+ timeoutMs: options.stopLumine
1047
+ ? Math.max(Number(options.timeoutMs) || 0, 90_000)
1048
+ : options.timeoutMs,
1041
1049
  });
1042
1050
  } catch (error) {
1051
+ // Without --stop-lumine the server refuses, before writing anything,
1052
+ // while a Lumine run is working on this branch.
1053
+ if (error?.data?.code === "build_lumine_run_active") {
1054
+ throw new Error(
1055
+ options.stopLumine
1056
+ ? `${error.message} Nothing was changed.`
1057
+ : "Wait for Lumine to finish first. Lumine is working on this branch; run `lumine update-from-main` again when it is done, or rerun with --stop-lumine to stop it now (its unfinished changes are dropped). Nothing was changed.",
1058
+ );
1059
+ }
1043
1060
  if (error?.data?.code === "build_project_files_stale") {
1044
1061
  throw new Error(
1045
1062
  [
@@ -2481,6 +2498,8 @@ export function parseArgs(args) {
2481
2498
  const raw = {};
2482
2499
  const positional = [];
2483
2500
  const booleanFlags = new Set([
2501
+ // lumine admin coins refund <receiptId> --keep-recipient
2502
+ "keepRecipient",
2484
2503
  // lumine admin jev-images thumbnails --featured [--saved-only]
2485
2504
  "featured",
2486
2505
  "savedOnly",
@@ -2498,6 +2517,8 @@ export function parseArgs(args) {
2498
2517
  "published",
2499
2518
  "save",
2500
2519
  "force",
2520
+ // lumine update-from-main --stop-lumine
2521
+ "stopLumine",
2501
2522
  "noUpdateCheck",
2502
2523
  "allowWrite",
2503
2524
  "main",
@@ -2904,6 +2925,15 @@ export function parseArgs(args) {
2904
2925
  adminAwsProfile: raw.awsProfile,
2905
2926
  adminMaxStep: raw.maxStep ? String(raw.maxStep) : "",
2906
2927
  adminBuild: raw.build ? String(raw.build) : "",
2928
+ // lumine admin coins (admin-coins.js)
2929
+ adminCoinPrice: raw.price ? String(raw.price) : "",
2930
+ adminCoinKey: raw.key ? String(raw.key) : "",
2931
+ adminCoinKeepRecipient: Boolean(raw.keepRecipient),
2932
+ // lumine admin coins tally-import --replace-region <region>
2933
+ adminCoinReplaceRegion: raw.replaceRegion ? String(raw.replaceRegion) : "",
2934
+ // lumine admin coins tally-clear --region <region> --area <x0:z0:x1:z1>
2935
+ adminCoinRegion: raw.region ? String(raw.region) : "",
2936
+ adminCoinArea: raw.area ? String(raw.area) : "",
2907
2937
  adminEditionId: raw.editionId ? String(raw.editionId) : "",
2908
2938
  adminLeaseToken: raw.leaseToken ? String(raw.leaseToken) : "",
2909
2939
  adminFile: raw.file ? String(raw.file) : "",
@@ -3037,6 +3067,7 @@ export function parseArgs(args) {
3037
3067
  summary: raw.summary ? String(raw.summary) : "",
3038
3068
  publish: parseBoolean(raw.publish, false),
3039
3069
  force: parseBoolean(raw.force, false),
3070
+ stopLumine: parseBoolean(raw.stopLumine, false),
3040
3071
  saveFirst: parseBoolean(raw.save, false),
3041
3072
  limit: Math.min(
3042
3073
  Math.max(
@@ -3302,7 +3333,7 @@ export function printHelp() {
3302
3333
  lumine diff <twinkle-branch-url>
3303
3334
  lumine merge <twinkle-branch-url>
3304
3335
  lumine replace-main <twinkle-branch-url>
3305
- lumine update-from-main [twinkle-branch-url]
3336
+ lumine update-from-main [twinkle-branch-url] [--stop-lumine]
3306
3337
  lumine save [--summary <text>] [--publish] [--json]
3307
3338
  lumine check [twinkle-build-url]
3308
3339
  lumine preview [--viewport phone|desktop] [--actions <json|file>] [--focus <text>] [--out <file>] [--saved] [--build <id>] [--json]
@@ -3379,6 +3410,7 @@ export function printHelp() {
3379
3410
  lumine admin arena queue | paper-queue [--cursor <id>] [--limit <1-100>] | import --dir <paper folder> [--audio audio/<exam>/<paper>] [--live] [--confirm] | status --item <id> --to live|retired|checked --confirm | revise --file <revisions.json> [--confirm] | picture-audit [--items <id,id,...> | --cursor <id>] [--limit 1-20] [--renders <dir of <itemId>.png>] [--output <review-list.json>] (Arena's mock-exam bank; picture-audit is an advisory JEV image review list that never retires or edits items; import and revise are validation-only dry runs without --confirm; revise fixes a live item in place as a new content version; upload audio first with twinkle-api scripts/arena-upload-media.ts)
3380
3411
  lumine admin arena today-queue [--cursor <id>] | today-member --user <id> | today-import --file <reviewed-questions.json> [--confirm] | today-withdraw --user <id> (--keys <key,...> | --file <withdraw.json>) --reason <text> [--confirm] (Today's Challenge personal queues; import and withdraw are dry runs without --confirm; withdraw removes only unserved queued questions and is audited)
3381
3412
  lumine admin nonogram status | add --file <pictures.json> | retire <key> | restore <key> [--confirm] (private reviewed pictures; changes are dry runs without --confirm)
3413
+ lumine admin coins list --build <id> [--user <id>] [--price <id>] [--key <k>] | refund <receiptId> --reason <text> [--keep-recipient] [--confirm] | release --build <id> --price <id> --key <k> --reason <text> [--confirm] | tally-import <buildId> --file <cells.json> --reason <text> [--replace-region <region>] [--confirm] | tally-clear <buildId> --user <id> [--region <region>] [--area <x0:z0:x1:z1>] --reason <text> [--confirm] (Twinkle.coins purchases; refund writes the reverse ledger rows and gives a tally purchase's used-up builder credits back; --keep-recipient lets the platform cover a purchase recipient's share, keeps those builders' credits used up, and is refused for sales/takeovers; tally-import is the builder tally's one-time fill from server-attributed history, batched and idempotent, never changing or deleting a block recorded live; --replace-region clears only that region's older imported blocks and says what it keeps; tally-clear removes one player's recorded blocks, the admin's fix for fake records, since players cannot clear each other's; dry runs without --confirm)
3382
3414
  lumine admin build-plans stuck [--days <1-90>] [--all] | recheck <buildId> [<buildId> ...] [--confirm] (Lumine Build plans stuck on one milestone: runs, Energy vs estimate, gradings, loop-guard state; recheck re-grades the current milestone with today's evaluator, free for the creator, dry run without --confirm)
3383
3415
  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>]
3384
3416
  lumine admin meetup list [--status review|active|completed|all] | show <crewId> | info <crewId> <userId> --decision ask|accept|ask-again|withdraw [--note <text>] | set-branch <crewId> <userId> --branch "<name>" [--note <text>] | branches [list [--status pending|official|rejected|all] | show <id> | approve <id> | reject <id> [--note <text>] | merge <id> --into <officialId>] | emails [list|send <id> [--fingerprint <revision>]|discard <id>|review on|off] | friends [status|on|off] | funnel [--days <1-90>] | announce-checkins [--send] [--as zero|ciel] (Bridge Builder crews and check-ins)
@@ -1,4 +1,4 @@
1
1
  // Generated by twinkle-api/scripts/generate-build-sdk-index.mjs from
2
2
  // constants/buildSdkManifest.json (platformRules, essentialSdk). Do not edit by hand.
3
- 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. Before changing an existing view, read its relevant event handlers, animations and state flow, and identify the behavior the edit must keep. A new appearance, reference image, renderer or performance improvement does not authorize removing an interaction, animation or capability; preserve it unless the creator explicitly asks for that change. After editing, compare the affected behavior before and after, including its active and inactive states (for example, playing and paused). Exercise those states when tools allow; a still screenshot or a clean console cannot prove motion or interaction. If a state cannot be exercised, report that limit rather than claiming it is preserved.\n10. When changing stored data or a renderer, inspect both the old save/resume shape and every reader, including fallback views. Keep existing saves readable with an explicit, non-destructive conversion and retain fields needed by another supported renderer. Verify an old save in the new version, new state after reload, and switching to the fallback (for example, 2D x/y toppings becoming 3D positions and returning to a flat view). Never silently drop saved objects or reset progress to make the new view work; an incomplete migration is part of this change to finish, not unrelated future polish.\n11. Keep local gameplay and unsaved edits separate from confirmed server state. Await storage writes and replace saved counters, scores and other durable state from the documented canonical response; Twinkle.privateDb.get/set return { item }, and the stored data is item.value (get may return item: null). Never read a guessed response shape, present a local increment as saved, or swallow a save/load error. Keep unfinished work recoverable, show a clear pending or failed-save state, and let the viewer retry deliberately without counting the same action twice. Verify canonical response restoration, rejected saves and delayed replies, not only a successful local animation.\n12. 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.\n13. 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.\n14. 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.\n15. 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.";
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. Before changing an existing view, read its relevant event handlers, animations and state flow, and identify the behavior the edit must keep. A new appearance, reference image, renderer or performance improvement does not authorize removing an interaction, animation or capability; preserve it unless the creator explicitly asks for that change. After editing, compare the affected behavior before and after, including its active and inactive states (for example, playing and paused). Exercise those states when tools allow; a still screenshot or a clean console cannot prove motion or interaction. If a state cannot be exercised, report that limit rather than claiming it is preserved.\n10. Change how something looks by editing the code that actually renders it. First find it (search for its visible text, id or class) and read that markup, CSS rule or canvas drawing code; text and shapes drawn on a canvas change in the drawing code, not in CSS. Then style that element through its own selector or code. Do not cover a guess with broad selectors that match class-name fragments or tags across the app (for example [class*=\"panel\"] or every h1) or with !important overrides of rules you have not read: on someone else's app or a contribution branch they restyle screens nobody asked to change. If you cannot find the element, say so and name what you searched for instead of guessing broadly.\n11. When changing stored data or a renderer, inspect both the old save/resume shape and every reader, including fallback views. Keep existing saves readable with an explicit, non-destructive conversion and retain fields needed by another supported renderer. Verify an old save in the new version, new state after reload, and switching to the fallback (for example, 2D x/y toppings becoming 3D positions and returning to a flat view). Never silently drop saved objects or reset progress to make the new view work; an incomplete migration is part of this change to finish, not unrelated future polish.\n12. Keep local gameplay and unsaved edits separate from confirmed server state. Await storage writes and replace saved counters, scores and other durable state from the documented canonical response; Twinkle.privateDb.get/set return { item }, and the stored data is item.value (get may return item: null). Never read a guessed response shape, present a local increment as saved, or swallow a save/load error. Keep unfinished work recoverable, show a clear pending or failed-save state, and let the viewer retry deliberately without counting the same action twice. Verify canonical response restoration, rejected saves and delayed replies, not only a successful local animation.\n13. 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.\n14. 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.\n15. 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.\n16. On phones a long press on game controls selects text or opens the Copy/Look Up menu unless the whole game area (controls, HUD, menus, empty play space) is marked data-twinkle-no-select and styled user-select: none; -webkit-user-select: none; -webkit-touch-callout: none. A game drawn without a canvas sets data-twinkle-game on <html> too, so Twinkle's phone touch protections apply. Keep inputs and copyable text selectable with data-twinkle-selectable.";
4
4
  export const LUMINE_ESSENTIAL_SDK = "Features every app needs whenever they apply. When you build a new app or make a substantial change, add every one that applies as part of that change, without being asked. On a small, targeted request, add a missing one only when the request touches what it covers (a change to levels, scoring or saving: keep-place; a new screen: back-button); otherwise name it in one line so the creator can ask. Say in one line what you added. Never add one that does not apply.\n\n1. When: The app has a place worth keeping: a game, a level or score in play, an open document, drawing or editor, a quiz in progress, a chosen view or tab, a long page the viewer scrolls. Use: Register Twinkle.app.lifecycle.onSuspend(() => smallState, { work: [...] }) and, at startup, restore from await Twinkle.app.lifecycle.getResumeState(). The snapshot holds where the viewer is looking (scroll, open tab or view, selected item) and unfinished work (a game or level in play, a half-written answer, an unsaved drawing, a quiz in progress); list the work keys in { work }. A return (the phone unloaded the app, a reload, a quick switch away) gets everything back; a new visit (the next day, a new tab) starts at the app's start and gets only the work keys. Phones unload apps that are not on screen (3D apps right away) and browsers kill tabs; without this the player starts over every time.\n2. When: The app has more than one view: a list and its details, menus, chapters, pages, settings. Use: Twinkle.app.history.push on each confirmed view change and Twinkle.app.history.subscribe to restore views, so Back and Forward move inside the app instead of leaving it.\n3. When: Progress, collections, unlocks, high scores or settings the viewer expects to still have tomorrow. Use: Twinkle.privateDb (Twinkle.sharedDb for what everyone sees), written on a user action or as an occasional snapshot, never per frame.\n4. When: The app shares or links to something inside itself (a level, a post, a result). Use: Twinkle.app.getShareUrl(pathSegment) for the link, and read the segment back at startup, instead of building twin-kle.com URLs by hand.";
package/lib/rewards.js CHANGED
@@ -68,6 +68,46 @@ async function checkDeclaration({
68
68
  });
69
69
  }
70
70
 
71
+ export function formatCoinPrices(result) {
72
+ const prices = Array.isArray(result?.prices) ? result.prices : [];
73
+ if (!prices.length) return [];
74
+ const lines = [`Coin prices: ${prices.length}${result.spendBudget ? ` · at most ${result.spendBudget.userDailyCoins} Coins a player a day` : " · no daily spend budget"}`];
75
+ for (const price of prices) {
76
+ const limits = [
77
+ price.userDailyLimit ? `${price.userDailyLimit} a day` : "",
78
+ price.maxLifetime ? `${price.maxLifetime} ever` : "",
79
+ price.uniqueKey ? `one holder per key${price.keyGroup ? ` (key group ${price.keyGroup})` : ""}` : "",
80
+ price.offers
81
+ ? `resale: offers from ${price.offers.minCoins} to ${price.offers.maxCoins ?? "?"} Coins, each Sell or takeover pays the previous holder (a person: extra review); ${price.offers.maxOpenPerPlayer || 1} open per player`
82
+ : price.uniqueKey && price.keyGroup ? "no offers on items bought with this price" : "",
83
+ ].filter(Boolean).join(" · ");
84
+ lines.push(
85
+ ` ${price.id}: ${price.title} · ${coinPriceDestination(price)}${limits ? ` · ${limits}` : ""}`,
86
+ );
87
+ }
88
+ return lines;
89
+ }
90
+ // Where a price's Coins go, including builder payments (they are not removed).
91
+ export function coinPriceDestination(price) {
92
+ // A 0-Coin price that pays builders is not free (round-2 review N7).
93
+ const base = price.coins > 0
94
+ ? `${price.coins} Coins ${price.destination === "owner" ? "to the app owner (extra review)" : "removed"}`
95
+ : price.builders ? "0 Coins of its own" : "free";
96
+ const b = price.builders;
97
+ if (!b) return base;
98
+ const most = Math.min(
99
+ b.maxCoinsPerPurchase || 100000,
100
+ b.coinsPerUnit * b.maxUnitsPerRecipient * (b.maxRecipients || 64),
101
+ );
102
+ const unit = b.unitName || "block";
103
+ const plot = b.tally
104
+ ? `the ${b.tally.plotSize}x${b.tally.plotSize} plot its key names ("${b.tally.keyFormat}")`
105
+ : "no tally declared, so nobody can be paid";
106
+ // r2: builders are paid from Twinkle's own block record, never the app's
107
+ // counts; review-r2 M2/M5: each builder paid for at most the limit.
108
+ return `${base} + ${b.coinsPerUnit} Coins per ${unit} to other players for the ${unit}s credited to them in Twinkle's record inside ${plot} (never the buyer or the owner; each paid for at most ${b.maxUnitsPerRecipient}; land with more than ${b.maxRecipients || 64} builders or over ${most} Coins for builders cannot be bought; extra review)`;
109
+ }
110
+
71
111
  export function printDeclaration(result, { prefix = "" } = {}) {
72
112
  const rules = Array.isArray(result?.rules) ? result.rules : [];
73
113
  if (result?.ok) {
@@ -87,6 +127,8 @@ export function printDeclaration(result, { prefix = "" } = {}) {
87
127
  `${prefix} ${rule.id}: ${rule.title} · ${rule.xp} XP + ${rule.coins} Coins · ${what}`,
88
128
  );
89
129
  }
130
+ // Coin prices and where each price's Coins go (approved in the same review)
131
+ for (const line of formatCoinPrices(result)) console.log(`${prefix}${line}`);
90
132
  // rules only a timer checks (the server's warning; never blocks)
91
133
  for (const warning of result.warnings || [])
92
134
  console.log(`${prefix}WARNING: ${warning}`);
@@ -289,6 +331,25 @@ rewards.json (project root) declares the economy the reviewer approves:
289
331
  "maxAttempts"?, "retry"?: { "xpPercent", "coinsPercent", "paidAttempts"? }, "minSeconds"? (completion), "progression"?: "dated" | "until-earned" (quiz),
290
332
  "bank"? (generated-quiz): { "steps": [percent, ...] rising to 100, "startStep"?, "instructions", "topics": [{ "key", "title", "description" }] } }] }
291
333
  The Earn page shows rules under their category headers and each series as one row ("up to 3 a day").
334
+ Coin prices (Twinkle.coins) go in the same file and the same review:
335
+ "prices": [{ "id", "title", "howTo"?, "coins", "destination": "removed" | "owner", "userDailyLimit"?, "maxLifetime"?,
336
+ "uniqueKey"? (one holder per item key), "keyGroup"? (prices sharing keys; offers stay per price; fixed once players hold items),
337
+ "offers"?: { "minCoins", "maxCoins" (highest offer price, required), "maxOpenPerPlayer"? (default 1) } (unique items only),
338
+ "builders"?: { "coinsPerUnit", "maxUnitsPerRecipient" (<= 4096), "maxRecipients"? (<= 64),
339
+ "maxCoinsPerPurchase"? (<= 100000), "unitName"?,
340
+ "tally": { "plotSize" (<= 64), "keyFormat": "region:x:z" | "region:cx:cz" } (required; uniqueKey too) } }],
341
+ "spendBudget"?: { "userDailyCoins" }
342
+ An app that only charges may leave out rules and the XP/Coin budgets. "owner" pays the app owner, "builders" pays
343
+ other players for the blocks credited to them in Twinkle's record inside the plot the purchase key names (on top of the
344
+ price). Each player's copy of the app records only that player's own blocks (Twinkle.coins.recordBlocks); Twinkle does
345
+ not see the world, so it cannot tell whether a recorded block is really there. A block stays credited to its builder when
346
+ someone else breaks or builds over it, until that builder removes it or a plot containing it is sold; only the admin can
347
+ clear a player's fake records (lumine admin coins tally-clear). Every price in a builders price's keyGroup reads its key
348
+ as the same plot: all builders prices there declare the same tally, a price that pays no builders cannot buy land with
349
+ other players' recorded blocks, and plots may not overlap. Regions are lowercase a-z 0-9 -, keys are written plainly
350
+ ("main:112:-48"). "offers" lets players resell items to each other (each Sell or takeover pays the previous holder);
351
+ all three pay a person and get extra review.
352
+ Offers, Sells and takeovers are not limited by spendBudget; purchases (builder payments included) are.
292
353
  Questions and answer keys never go in project files; they belong in the sheet.
293
354
  archive-rematch (at most one) needs no sheet either: each day the server picks one retired
294
355
  until-earned puzzle for players who never met it and checks the answer against its frozen sheet.
package/lib/sdk.js CHANGED
@@ -249,6 +249,28 @@ export const SDK_CLI_METHODS = {
249
249
  // holds no award logic; the server checks approval, version and budget.
250
250
  // Status and receipt reads use the endpoint's rewards:claim scope, but
251
251
  // only their fixed read operation is sent.
252
+ // Twinkle.coins reads use the same published-runtime grant as rewards.
253
+ // Spending (charge, offers, takeover) happens only in the website after the
254
+ // member confirms; the server refuses it from CLI sessions, so it is not
255
+ // mapped here.
256
+ "coins.getBalance": {
257
+ path: "api/coins/balance", special: "rewards", routeBase: "coins", operation: "balance",
258
+ scopes: ["coins:charge"], readOnly: true,
259
+ mapArgs: () => ({}),
260
+ },
261
+ "coins.getReceipts": {
262
+ path: "api/coins/receipts", special: "rewards", routeBase: "coins", operation: "receipts",
263
+ scopes: ["coins:charge"], readOnly: true,
264
+ mapArgs: (args) => ({
265
+ priceId: args.priceId, key: args.key, mine: args.mine === true ? true : undefined,
266
+ held: args.held === true ? true : undefined, cursor: args.cursor, limit: args.limit,
267
+ }),
268
+ },
269
+ "coins.getOffers": {
270
+ path: "api/coins/offers", special: "rewards", routeBase: "coins", operation: "offers",
271
+ scopes: ["coins:charge"], readOnly: true,
272
+ mapArgs: (args) => ({ priceId: args.priceId, key: args.key, mine: args.mine, cursor: args.cursor }),
273
+ },
252
274
  "rewards.getStatus": {
253
275
  path: "api/rewards/status",
254
276
  special: "rewards",
@@ -679,7 +701,7 @@ export async function sdkCall(options) {
679
701
  lastResult = await executeSdkHttpCall({
680
702
  options,
681
703
  method: "POST",
682
- url: `${options.apiUrl}/build/${buildId}/api/rewards/${endpoint.operation}`,
704
+ url: `${options.apiUrl}/build/${buildId}/api/${endpoint.routeBase || "rewards"}/${endpoint.operation}`,
683
705
  authToken: auth.token,
684
706
  body,
685
707
  buildApiToken: tokenResult.token,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.88",
3
+ "version": "0.3.90",
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.81.1
4
- Updated: 2026-11-07
5
- Generated: 2026-11-07T12:02:59.156Z
3
+ Version: 1.82.1
4
+ Updated: 2026-11-08
5
+ Generated: 2026-11-08T00:36:53.981Z
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.
@@ -16,12 +16,13 @@ Each rule below is a way an app breaks once it runs inside Twinkle's sandboxed f
16
16
  7. Call only Twinkle.* methods documented in the SDK reference. An invented or guessed method name throws and breaks the feature.
17
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
18
  9. Before changing an existing view, read its relevant event handlers, animations and state flow, and identify the behavior the edit must keep. A new appearance, reference image, renderer or performance improvement does not authorize removing an interaction, animation or capability; preserve it unless the creator explicitly asks for that change. After editing, compare the affected behavior before and after, including its active and inactive states (for example, playing and paused). Exercise those states when tools allow; a still screenshot or a clean console cannot prove motion or interaction. If a state cannot be exercised, report that limit rather than claiming it is preserved.
19
- 10. When changing stored data or a renderer, inspect both the old save/resume shape and every reader, including fallback views. Keep existing saves readable with an explicit, non-destructive conversion and retain fields needed by another supported renderer. Verify an old save in the new version, new state after reload, and switching to the fallback (for example, 2D x/y toppings becoming 3D positions and returning to a flat view). Never silently drop saved objects or reset progress to make the new view work; an incomplete migration is part of this change to finish, not unrelated future polish.
20
- 11. Keep local gameplay and unsaved edits separate from confirmed server state. Await storage writes and replace saved counters, scores and other durable state from the documented canonical response; Twinkle.privateDb.get/set return { item }, and the stored data is item.value (get may return item: null). Never read a guessed response shape, present a local increment as saved, or swallow a save/load error. Keep unfinished work recoverable, show a clear pending or failed-save state, and let the viewer retry deliberately without counting the same action twice. Verify canonical response restoration, rejected saves and delayed replies, not only a successful local animation.
21
- 12. 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.
22
- 13. 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.
23
- 14. 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.
24
- 15. 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.
19
+ 10. Change how something looks by editing the code that actually renders it. First find it (search for its visible text, id or class) and read that markup, CSS rule or canvas drawing code; text and shapes drawn on a canvas change in the drawing code, not in CSS. Then style that element through its own selector or code. Do not cover a guess with broad selectors that match class-name fragments or tags across the app (for example [class*="panel"] or every h1) or with !important overrides of rules you have not read: on someone else's app or a contribution branch they restyle screens nobody asked to change. If you cannot find the element, say so and name what you searched for instead of guessing broadly.
20
+ 11. When changing stored data or a renderer, inspect both the old save/resume shape and every reader, including fallback views. Keep existing saves readable with an explicit, non-destructive conversion and retain fields needed by another supported renderer. Verify an old save in the new version, new state after reload, and switching to the fallback (for example, 2D x/y toppings becoming 3D positions and returning to a flat view). Never silently drop saved objects or reset progress to make the new view work; an incomplete migration is part of this change to finish, not unrelated future polish.
21
+ 12. Keep local gameplay and unsaved edits separate from confirmed server state. Await storage writes and replace saved counters, scores and other durable state from the documented canonical response; Twinkle.privateDb.get/set return { item }, and the stored data is item.value (get may return item: null). Never read a guessed response shape, present a local increment as saved, or swallow a save/load error. Keep unfinished work recoverable, show a clear pending or failed-save state, and let the viewer retry deliberately without counting the same action twice. Verify canonical response restoration, rejected saves and delayed replies, not only a successful local animation.
22
+ 13. 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.
23
+ 14. 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.
24
+ 15. 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.
25
+ 16. 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.
25
26
 
26
27
  ## Essential SDK features
27
28
  Features every app needs whenever they apply. When you build a new app or make a substantial change, add every one that applies as part of that change, without being asked. On a small, targeted request, add a missing one only when the request touches what it covers (a change to levels, scoring or saving: keep-place; a new screen: back-button); otherwise name it in one line so the creator can ask. Say in one line what you added. Never add one that does not apply.
@@ -71,6 +72,7 @@ Features every app needs whenever they apply. When you build a new app or make a
71
72
  - 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.
72
73
  - 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.
73
74
  - word-game-puzzle-v2 is the same daily rule and also requires a server-verified finished solve of that difficulty today (Twinkle.wordMaster.startPuzzle and sendPuzzleEvents). word-search-champion-v1 pays only the rank-1 viewer of a completed UTC month on the server-written verified:month:YYYY-MM board (score, then earliest), once per month and only after the month is final (07:00 UTC on the 2nd, when its last boards can no longer take scores); once anyone collected a month, nobody else can; a removed or fully banned #1 passes the prize to the next eligible player; The daily Twinkle review confirms each month’s winner from the server’s record of their solves before the prize opens (a disqualified winner passes it to the next eligible player). rewards.getStatus returns wordSearchChampion: { month, months, claimable, checking } — months are ready to collect; checking lists wins still being confirmed (show a checking message, no collect button). Show the champion banner from that status, never from app storage.
75
+ - Twinkle.coins prices are part of the app's rewards review (rewards.json prices + spendBudget). Using Twinkle.coins requires the same approval as Twinkle.rewards; the website, never the app, confirms every purchase. Use getReceipts as the only proof of who owns a bought item.
74
76
 
75
77
  ## AI decision design
76
78
  - 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.
@@ -81,7 +83,7 @@ Features every app needs whenever they apply. When you build a new app or make a
81
83
  - Call at meaningful user actions or discrete app decision points, not every render, animation frame, keystroke, or background poll. Batch work, debounce changing input, keep one request active per decision flow, and discard results whose app state changed while waiting. Keep rendering, movement, and controls local and responsive. JEV decisions are covered by Twinkle and never use the viewer's AI Energy; the runtime AI rate limits still apply. Handle unavailable, timeout, and rate-limit errors with a clear retry or an explicitly local fallback; never fabricate an AI answer or run an automatic retry loop. Consider the guest and offline experience before making AI essential.
82
84
 
83
85
  ## Token Scopes
84
- files:read, media:read, media:write, live:read, live:write, user:read, users:read, dailyReflections:read, content:read, content:write, sharedDb:read, sharedDb:write, privateDb:read, privateDb:write, files:write, chat:read, chat:write, notifications:read, notifications:write, notifications:emit, reminders:read, reminders:write, rewards:claim
86
+ files:read, media:read, media:write, live:read, live:write, user:read, users:read, dailyReflections:read, content:read, content:write, sharedDb:read, sharedDb:write, privateDb:read, privateDb:write, files:write, chat:read, chat:write, notifications:read, notifications:write, notifications:emit, reminders:read, reminders:write, rewards:claim, coins:charge
85
87
 
86
88
  ## Namespaces
87
89
 
@@ -124,7 +126,8 @@ files:read, media:read, media:write, live:read, live:write, user:read, users:rea
124
126
 
125
127
  ### Twinkle.app
126
128
  - async getInfo() | scopes: none
127
- - Returns: App info object from the parent ({ buildId, appUrl, source, artifactVersionId }) or null
129
+ - Returns: App info object from the parent ({ buildId, ownerId, ownerUsername, appUrl, source, artifactVersionId }) or null
130
+ - ownerId/ownerUsername name the app's owner and are the same for every viewer (null on older hosts). Use ownerId to honour owner-only records (e.g. admin actions) by comparing it with the server-set userId on sharedDb entries, instead of hard-coding an id. It is not a permission check: the server still decides what a viewer may do.
128
131
  - Cached after the first call for the iframe session.
129
132
  - source is 'published' on the app page and 'workspace' in the editor preview; artifactVersionId is the published release this frame runs (null in the workspace). Stamp it on your own telemetry rows instead of a hand-written release label, so data can be tied to an exact version. Hosts older than website 2.3.113 omit source and artifactVersionId; treat a missing value as unknown.
130
133
  - async getShareUrl(pathSegment) | scopes: none
@@ -1892,6 +1895,57 @@ world.updatePresence({ x, y, z, facing });
1892
1895
  - Report a wrong or unclear bank question the learner has seen.
1893
1896
  - One report per learner per question; three reports hide it from everyone. Show a small 'report this problem' action next to the question.
1894
1897
 
1898
+ ### Twinkle.coins
1899
+ - await Twinkle.coins.getBalance() | scopes: coins:charge
1900
+ - Returns: { mode: "live" | "preview", coins, prices: [{ id, title, howTo?, coins, destination, uniqueKey, userDailyLimit?, maxLifetime?, offers?: { minCoins, maxCoins } }], spendBudget: { userDailyCoins } | null, spentToday }
1901
+ - Read the viewer's Coin balance and this app's approved prices.
1902
+ - Show prices from this response, never hard-coded numbers: the reviewer may change them. In a draft preview the prices come from the draft's rewards.json and mode is "preview".
1903
+ - await Twinkle.coins.charge({ priceId, key?, payload?, requestId? }) | scopes: coins:charge
1904
+ - Returns: { mode: "live", receipt: { id, priceId, key, kind: "purchase" | "sale" | "takeover", coins, destination: "removed" | "owner" | "holder", buyer: { id, username }, recipient: { id, username } | null, payload, builders?: [{ userId, units, coins }], status: "active" | "transferred" | "released" | "refunded", holds, previousReceiptId, offerId, createdAt, preview? }, balances: { coins }, replayed } | { mode: "preview", receipt: { id: "preview-…", preview: true, … }, balances, message }
1905
+ - Buy one approved price. Twinkle asks the player to confirm in its own box first.
1906
+ - Declare every price in rewards.json: "prices": [{ "id", "title", "howTo"?, "coins", "destination": "removed" | "owner", "userDailyLimit"?, "maxLifetime"?, "uniqueKey"?, "keyGroup"?, "offers"?: { "minCoins", "maxCoins", "maxOpenPerPlayer"? } }] and optionally "spendBudget": { "userDailyCoins" }. offers.maxCoins (required with offers) is the highest price an offer may be. spendBudget limits purchases (builder payments included), not offers, Sells or takeovers. Prices are approved with the app's rewards review; an app that only charges may leave out rules and the XP/Coin budgets.
1907
+ - A price may also pay builders (Firestar's World pays 50 Coins a block): declare "builders": { "coinsPerUnit", "maxUnitsPerRecipient" (up to 4,096), "maxRecipients"? (up to 64), "maxCoinsPerPurchase"? (all builders together, up to 100,000), "unitName"?, "tally": { "plotSize" (1 to 64), "keyFormat": "region:x:z" | "region:cx:cz" } } with uniqueKey: true. The purchase key names the plot: "region:x:z" is its lowest corner in blocks (e.g. "main:112:-48" covers x 112..127, z -48..-33 with plotSize 16), "region:cx:cz" a grid square (x from cx × plotSize); full height. Write keys plainly: the region in lowercase letters, digits and dashes, numbers without leading zeros, "+" or "-0" (anything else is build_coin_tally_key_invalid). Twinkle itself works out who is paid, from its record of who placed each block (Twinkle.coins.recordBlocks): every player credited with blocks inside that plot, except the buyer and the app owner, gets their blocks × coinsPerUnit on top of the price, at most maxUnitsPerRecipient blocks each (a builder with more is paid that many). The app never names builders or counts (a builders field is ignored). The confirmation box shows every builder and share; a plot with no other players' credited blocks costs just the price. The tally belongs to the key group: every price sharing that keyGroup (plain and free prices too) reads its key as the same plot, all builders prices in it must declare the same tally, a price that pays no builders is refused on land with other players' credited blocks (build_coin_builders_price_required, details { priceIds, builders }: buy with a price in priceIds), and a plot that overlaps a plot someone holds in the key group is refused (build_coin_plot_overlap, details { key, holderId }). A purchase uses up the credits inside its plot, so the same builders are never paid twice (an admin refund gives them back). Refused too: land whose builders would get more than maxCoinsPerPurchase, or with more builders than maxRecipients (build_coin_builders_over_limit, details { builders, units, recorded, coins, limits }; the admin can clear fake records). A price without tally cannot pay builders (refused at check, review and charge).
1908
+ - Twinkle shows the player a confirmation box outside the app naming the item, the price and where the Coins go ("The Coins are removed" or "go to <owner>"). A player who says no gets an error with code build_coin_cancelled. Nothing is charged until they say yes.
1909
+ - uniqueKey prices need key (up to 128 of A-Z a-z 0-9 . _ : -, e.g. "main:12:-4"): one player holds a key at a time. A held key is refused with build_coin_key_taken (use makeOffer when the price takes offers) or build_coin_key_owned. Prices with the same keyGroup share keys (e.g. a plain land purchase and a claim that pays builders both use keyGroup "plots", so one plot has one holder). Once players hold items, a later release must keep each held item's price in the same key group and keep every held key group declared (build_coin_key_group_held at check, review and publish); a key held under an older group is never sold again.
1910
+ - payload is optional JSON up to 1,024 characters stored on the receipt (e.g. plot bounds).
1911
+ - Refusals are errors with a code and a sentence to show: build_coin_insufficient, build_coin_daily_limit, build_coin_lifetime_limit, build_coin_spend_budget, build_coin_self_payment (the owner cannot buy an owner-paid price from their own app), build_coin_quote_changed (the price went up since the box was shown, or its destination changed; a builder split that changed without raising the total goes ahead with Twinkle's split), build_coin_not_live (draft or unapproved version).
1912
+ - Pass the same requestId (8 to 64 of A-Z a-z 0-9 _ -) again after a lost reply to get the first receipt back without paying twice. Sales and takeovers get server requestIds ("offer:<offerId>") an app cannot choose.
1913
+ - Drafts and previews never charge: the owner's preview returns a receipt with preview: true and id "preview-…" and the balance unchanged. Never treat a preview receipt as ownership.
1914
+ - await Twinkle.coins.getReceipts({ priceId?, key?, mine?, held?, cursor?, limit? }) | scopes: coins:charge
1915
+ - Returns: { mode: "live", receipts: [{ id, priceId, key, kind: "purchase" | "sale" | "takeover", coins, destination: "removed" | "owner" | "holder", buyer: { id, username }, recipient: { id, username } | null, payload, builders?: [{ userId, units, coins }], status: "active" | "transferred" | "released" | "refunded", holds, previousReceiptId, offerId, createdAt, preview? }], nextCursor } | { mode: "preview", receipts: [] }
1916
+ - Read server-written purchase receipts for this app: who holds which item.
1917
+ - Receipts are written only by Twinkle's server inside the charge, so they are the proof of ownership no client can forge. Decide who owns an item from getReceipts({ priceId, held: true }) (holds: true is the current holder of a unique key), not from sharedDb, privateDb or app state. Page with nextCursor (newest first, up to 50).
1918
+ - The viewer reads all of their own receipts (as buyer or recipient). Other players' receipts appear only while they hold an item, with only id, priceId, key, kind, coins (the last price), buyer, payload, status, holds and createdAt (no destination, recipient or builder split). A refunded or released receipt no longer holds its item; a sold or taken-over one shows status "transferred" and the new holder has a newer receipt for the same key.
1919
+ - await Twinkle.coins.recordBlocks({ region, placed?: [[x, y, z], ...], removed?: [[x, y, z], ...] }) | scopes: coins:charge
1920
+ - Returns: { mode: "live", recorded: { placed, removed, keptForOthers }, today: { cells, limit } } | { mode: "preview", recorded: { placed: 0, removed: 0, keptForOthers: 0 }, message }
1921
+ - Record the blocks this player placed or broke, so builders are paid from Twinkle's own record when land is bought.
1922
+ - Only for apps whose approved prices pay builders with a tally (build_coin_tally_off otherwise). Twinkle records the blocks as the signed-in player's own (from their website session; the app cannot name anyone else). A block placed on an empty cell is credited to that player. Once credited, a block stays that player's credit when anyone else breaks or builds over it (the record notes who did, for the admin); the credit ends only when that player sends its removal, or when a plot containing it is sold. So a removal or placement of another player's block changes nothing (counted in recorded.keptForOthers). Twinkle cannot see the world: it trusts each player's own reports of their own blocks, and only the admin can clear a player's records (lumine admin coins tally-clear).
1923
+ - region is the world's name (1 to 64 lowercase letters, digits or dashes: a-z 0-9 -), the same name the purchase keys use. Cells are [x, y, z] whole numbers (real numbers, not strings): x and z within ±1,000,000, y from -512 to 512.
1924
+ - Send batches: 1 to 64 blocks a call, each block once per call with its latest change. Record only blocks the player placed or broke by hand, never blocks the game made (water, lava results, revealed ores, falling sand, grown trees), and send a removal only for a block a player had placed (breaking natural ground needs no call). Calls run one at a time in the order you make them (the SDK waits for each answer before sending the next), so a newer change is never undone by an older one.
1925
+ - Limits per player per app: 5 blocks a second with bursts of 160 (each call counts at least 4), 20,000 blocks a day. Over the pace: build_coin_tally_throttled with details { retryAfterSeconds }: keep the blocks and send them again after that wait. Over the day: build_coin_tally_daily_limit. Never call it on every frame; flush about once a second.
1926
+ - Only the approved published release records. In a draft preview the call is checked and answered with mode "preview", and nothing is written.
1927
+ - await Twinkle.coins.makeOffer({ priceId, key, coins }) | scopes: coins:charge
1928
+ - Returns: { mode: "live", offer: { id, priceId, key, coins, status: "open" | "kept" | "sold" | "taken" | "withdrawn" | "closed" | "failed" | "lapsed", offerer: { id, username }, holder: { id, username }, createdAt, respondedAt, takeoverAt, lapsesAt, canTakeOver, saleReceiptId }, replayed }
1929
+ - Offer to buy a unique item another player holds. Twinkle asks the player to confirm first.
1930
+ - Only items bought under a price with offers take offers (a keyGroup shares keys, not offers); a builders price may declare offers too, and then its last price is the full total paid (price plus builder Coins). A sale or takeover pays only the previous holder, never the builders again. coins must be at least offers.minCoins and more than the price last paid for that key, but never more than offers.maxCoins: once the last price reaches offers.maxCoins an offer of exactly offers.maxCoins is enough (getReceipts shows the last price; build_coin_offer_too_low returns details.minimumOffer, build_coin_offer_too_high details.maximumOffer). Offers are not limited by spendBudget. No Coins move or are held now, but a player's open offers in the app together cannot exceed their balance (build_coin_insufficient). If the item changed hands after the box opened: build_coin_quote_changed.
1931
+ - One open offer per item (build_coin_offer_exists) and one open offer per player in the app unless the price declares offers.maxOpenPerPlayer (build_coin_offer_limit); at most 3 offers per player per app per day (build_coin_offer_rate_limited). The holder always gets a site notification, even if they muted the app, and answers Keep or Sell. After a Keep, a withdraw or a lapse the same player cannot offer on that item for 7 days (build_coin_offer_cooldown, details.retryAt). If the holder does not answer in 30 days, the offerer may buy it with takeover at the offered price between day 30 and day 44 (offer.lapsesAt); the Coins go to the previous holder. If the app's approval is revoked or the app is unpublished (or a release goes live after a time the app was not live), open offers end with status "closed" and a closeReason, no Coins move, and both players are notified.
1932
+ - await Twinkle.coins.respondOffer({ offerId, decision: "keep" | "sell" }) | scopes: coins:charge
1933
+ - Returns: { mode: "live", status: "kept" | "sold" | "failed" | "closed" | "lapsed", receipt?, balances?, offer? }
1934
+ - The holder answers an offer. Twinkle asks the holder to confirm either answer.
1935
+ - Sell moves the item to the offerer and pays the holder the offered Coins at once. If the offerer can no longer pay, the status is "failed" and the holder keeps the item. If a newer release stopped this price taking offers (or lowered offers.maxCoins below the offer), the status is "closed" and nothing moves. Keep stops this offer and its 30-day takeover, and the offerer cannot offer on the item again for 7 days. The holder may still Keep after day 30, until the offerer buys; the offerer is then notified that the holder came back and kept it, and when they may offer again.
1936
+ - await Twinkle.coins.takeover({ offerId }) | scopes: coins:charge
1937
+ - Returns: { mode: "live", status: "taken" | "closed" | "lapsed", receipt?, balances?, replayed }
1938
+ - After 30 days without an answer, the offerer buys the item at the offered price.
1939
+ - Allowed only when offer.canTakeOver is true (server clock: 30 days after the offer, with no answer). Twinkle asks the player to confirm. The Coins go to the previous holder and the price becomes the item's new last price. Too early: build_coin_takeover_early with details.takeoverAt. An offer nobody acts on for 14 more days lapses (day 44, offer.lapsesAt).
1940
+ - await Twinkle.coins.withdrawOffer({ offerId }) | scopes: coins:charge
1941
+ - Returns: { mode: "live", offer: { id, priceId, key, coins, status: "open" | "kept" | "sold" | "taken" | "withdrawn" | "closed" | "failed" | "lapsed", offerer: { id, username }, holder: { id, username }, createdAt, respondedAt, takeoverAt, lapsesAt, canTakeOver, saleReceiptId } }
1942
+ - Take back your own open offer. Twinkle asks the player to confirm first.
1943
+ - A player who says no gets build_coin_cancelled and the offer stays open. After a withdraw the same player cannot offer on that item for 7 days. Withdraw works even after a release stopped the price taking offers; such offers also end when that release goes live and no longer count as the player's open offer.
1944
+ - await Twinkle.coins.getOffers({ priceId?, key?, mine?: "received" | "made", cursor? }) | scopes: coins:charge
1945
+ - Returns: { mode: "live", offers: [{ id, priceId, key, coins, status: "open" | "kept" | "sold" | "taken" | "withdrawn" | "closed" | "failed" | "lapsed", offerer: { id, username }, holder: { id, username }, createdAt, respondedAt, takeoverAt, lapsesAt, canTakeOver, saleReceiptId }], nextCursor }
1946
+ - List offers on one item (key) or the viewer's own offers.
1947
+ - Pass key for one item's offers (any status), mine: "received" for open offers waiting for the viewer's answer, or mine: "made" for the viewer's own. On offers the viewer is not part of, offerer, coins, respondedAt, canTakeOver and saleReceiptId are left out. The notification target is { coinOfferId }.
1948
+
1895
1949
  ### Twinkle.cardCraft
1896
1950
  - await Twinkle.cardCraft.getStatus() | scopes: cardCraft:read
1897
1951
  - Returns: { mode: "live" | "preview", available, reason?: "not_approved" | "not_declared" | "invalid" | "published_app_required", recipe: { kinds: [{ id, label, description, params }], tiers: { "1": [kindId] … "6": [kindId] }, acceptedLevels: number[] } | null, problems?: string[], message? }