@stage5/lumine 0.3.87 → 0.3.89

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.
@@ -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
@@ -2481,6 +2481,8 @@ export function parseArgs(args) {
2481
2481
  const raw = {};
2482
2482
  const positional = [];
2483
2483
  const booleanFlags = new Set([
2484
+ // lumine admin coins refund <receiptId> --keep-recipient
2485
+ "keepRecipient",
2484
2486
  // lumine admin jev-images thumbnails --featured [--saved-only]
2485
2487
  "featured",
2486
2488
  "savedOnly",
@@ -2904,6 +2906,15 @@ export function parseArgs(args) {
2904
2906
  adminAwsProfile: raw.awsProfile,
2905
2907
  adminMaxStep: raw.maxStep ? String(raw.maxStep) : "",
2906
2908
  adminBuild: raw.build ? String(raw.build) : "",
2909
+ // lumine admin coins (admin-coins.js)
2910
+ adminCoinPrice: raw.price ? String(raw.price) : "",
2911
+ adminCoinKey: raw.key ? String(raw.key) : "",
2912
+ adminCoinKeepRecipient: Boolean(raw.keepRecipient),
2913
+ // lumine admin coins tally-import --replace-region <region>
2914
+ adminCoinReplaceRegion: raw.replaceRegion ? String(raw.replaceRegion) : "",
2915
+ // lumine admin coins tally-clear --region <region> --area <x0:z0:x1:z1>
2916
+ adminCoinRegion: raw.region ? String(raw.region) : "",
2917
+ adminCoinArea: raw.area ? String(raw.area) : "",
2907
2918
  adminEditionId: raw.editionId ? String(raw.editionId) : "",
2908
2919
  adminLeaseToken: raw.leaseToken ? String(raw.leaseToken) : "",
2909
2920
  adminFile: raw.file ? String(raw.file) : "",
@@ -3379,6 +3390,7 @@ export function printHelp() {
3379
3390
  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
3391
  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
3392
  lumine admin nonogram status | add --file <pictures.json> | retire <key> | restore <key> [--confirm] (private reviewed pictures; changes are dry runs without --confirm)
3393
+ 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
3394
  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
3395
  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
3396
  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)
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.87",
3
+ "version": "0.3.89",
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.0
3
+ Version: 1.82.0
4
4
  Updated: 2026-11-07
5
- Generated: 2026-11-07T02:35:09.137Z
5
+ Generated: 2026-11-07T15:13:41.763Z
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.
@@ -71,6 +71,7 @@ Features every app needs whenever they apply. When you build a new app or make a
71
71
  - 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
72
  - 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
73
  - 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.
74
+ - 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
75
 
75
76
  ## AI decision design
76
77
  - 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 +82,7 @@ Features every app needs whenever they apply. When you build a new app or make a
81
82
  - 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
83
 
83
84
  ## 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
85
+ 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
86
 
86
87
  ## Namespaces
87
88
 
@@ -124,7 +125,8 @@ files:read, media:read, media:write, live:read, live:write, user:read, users:rea
124
125
 
125
126
  ### Twinkle.app
126
127
  - async getInfo() | scopes: none
127
- - Returns: App info object from the parent ({ buildId, appUrl, source, artifactVersionId }) or null
128
+ - Returns: App info object from the parent ({ buildId, ownerId, ownerUsername, appUrl, source, artifactVersionId }) or null
129
+ - 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
130
  - Cached after the first call for the iframe session.
129
131
  - 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
132
  - async getShareUrl(pathSegment) | scopes: none
@@ -241,7 +243,7 @@ files:read, media:read, media:write, live:read, live:write, user:read, users:rea
241
243
  - Report live player or avatar bounds so the preview host can detect floor, wall, or out-of-bounds issues.
242
244
  - Always available in the build iframe.
243
245
  - Reports live gameplay bounds in the same coordinate space as setPlayfield.
244
- - Use this for moving players or avatars so preview review and auto-fix can detect floor or wall escapes.
246
+ - Use this for moving players or avatars so the preview check can detect floor or wall escapes.
245
247
  - Do not report viewport-relative or screen-pixel rectangles; keep telemetry in stable game/world coordinates.
246
248
  - Example: Twinkle.preview.reportGameplayState({ playerBounds: { x: player.x, y: player.y, width: player.width, height: player.height } });
247
249
  - getGameplayTelemetry() | scopes: none
@@ -1892,6 +1894,57 @@ world.updatePresence({ x, y, z, facing });
1892
1894
  - Report a wrong or unclear bank question the learner has seen.
1893
1895
  - One report per learner per question; three reports hide it from everyone. Show a small 'report this problem' action next to the question.
1894
1896
 
1897
+ ### Twinkle.coins
1898
+ - await Twinkle.coins.getBalance() | scopes: coins:charge
1899
+ - Returns: { mode: "live" | "preview", coins, prices: [{ id, title, howTo?, coins, destination, uniqueKey, userDailyLimit?, maxLifetime?, offers?: { minCoins, maxCoins } }], spendBudget: { userDailyCoins } | null, spentToday }
1900
+ - Read the viewer's Coin balance and this app's approved prices.
1901
+ - 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".
1902
+ - await Twinkle.coins.charge({ priceId, key?, payload?, requestId? }) | scopes: coins:charge
1903
+ - 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 }
1904
+ - Buy one approved price. Twinkle asks the player to confirm in its own box first.
1905
+ - 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.
1906
+ - 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).
1907
+ - 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.
1908
+ - 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.
1909
+ - payload is optional JSON up to 1,024 characters stored on the receipt (e.g. plot bounds).
1910
+ - 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).
1911
+ - 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.
1912
+ - 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.
1913
+ - await Twinkle.coins.getReceipts({ priceId?, key?, mine?, held?, cursor?, limit? }) | scopes: coins:charge
1914
+ - 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: [] }
1915
+ - Read server-written purchase receipts for this app: who holds which item.
1916
+ - 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).
1917
+ - 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.
1918
+ - await Twinkle.coins.recordBlocks({ region, placed?: [[x, y, z], ...], removed?: [[x, y, z], ...] }) | scopes: coins:charge
1919
+ - Returns: { mode: "live", recorded: { placed, removed, keptForOthers }, today: { cells, limit } } | { mode: "preview", recorded: { placed: 0, removed: 0, keptForOthers: 0 }, message }
1920
+ - Record the blocks this player placed or broke, so builders are paid from Twinkle's own record when land is bought.
1921
+ - 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).
1922
+ - 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.
1923
+ - 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.
1924
+ - 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.
1925
+ - Only the approved published release records. In a draft preview the call is checked and answered with mode "preview", and nothing is written.
1926
+ - await Twinkle.coins.makeOffer({ priceId, key, coins }) | scopes: coins:charge
1927
+ - 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 }
1928
+ - Offer to buy a unique item another player holds. Twinkle asks the player to confirm first.
1929
+ - 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.
1930
+ - 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.
1931
+ - await Twinkle.coins.respondOffer({ offerId, decision: "keep" | "sell" }) | scopes: coins:charge
1932
+ - Returns: { mode: "live", status: "kept" | "sold" | "failed" | "closed" | "lapsed", receipt?, balances?, offer? }
1933
+ - The holder answers an offer. Twinkle asks the holder to confirm either answer.
1934
+ - 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.
1935
+ - await Twinkle.coins.takeover({ offerId }) | scopes: coins:charge
1936
+ - Returns: { mode: "live", status: "taken" | "closed" | "lapsed", receipt?, balances?, replayed }
1937
+ - After 30 days without an answer, the offerer buys the item at the offered price.
1938
+ - 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).
1939
+ - await Twinkle.coins.withdrawOffer({ offerId }) | scopes: coins:charge
1940
+ - 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 } }
1941
+ - Take back your own open offer. Twinkle asks the player to confirm first.
1942
+ - 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.
1943
+ - await Twinkle.coins.getOffers({ priceId?, key?, mine?: "received" | "made", cursor? }) | scopes: coins:charge
1944
+ - 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 }
1945
+ - List offers on one item (key) or the viewer's own offers.
1946
+ - 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 }.
1947
+
1895
1948
  ### Twinkle.cardCraft
1896
1949
  - await Twinkle.cardCraft.getStatus() | scopes: cardCraft:read
1897
1950
  - 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? }