@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.
- package/lib/admin-coins.js +239 -0
- package/lib/admin.js +21 -0
- package/lib/commands.js +12 -0
- package/lib/rewards.js +61 -0
- package/lib/sdk.js +23 -1
- package/package.json +1 -1
- package/sdk/BUILD_SDK_INDEX.md +58 -5
|
@@ -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
|
|
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
package/sdk/BUILD_SDK_INDEX.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Build SDK Index
|
|
2
2
|
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.82.0
|
|
4
4
|
Updated: 2026-11-07
|
|
5
|
-
Generated: 2026-11-
|
|
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
|
|
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? }
|