@stage5/lumine 0.2.91 → 0.2.92
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/admin.js +123 -0
- package/lib/cardcraft.js +141 -0
- package/lib/commands.js +7 -0
- package/lib/constants.js +1 -0
- package/lib/rewards.js +3 -1
- package/package.json +1 -1
- package/sdk/BUILD_SDK_INDEX.md +30 -3
package/lib/admin.js
CHANGED
|
@@ -1699,6 +1699,61 @@ export function parseAdminOperation(options) {
|
|
|
1699
1699
|
);
|
|
1700
1700
|
}
|
|
1701
1701
|
|
|
1702
|
+
if (
|
|
1703
|
+
namespace === "cardcraft-review" ||
|
|
1704
|
+
namespace === "cardcraft-reviews" ||
|
|
1705
|
+
namespace === "cardcraft"
|
|
1706
|
+
) {
|
|
1707
|
+
// AI Card crafting recipes (cardcraft.json). Same reviewer as rewards and,
|
|
1708
|
+
// like rewards, never part of a delegated daily run.
|
|
1709
|
+
if (!action || action === "list") {
|
|
1710
|
+
return readOperation(
|
|
1711
|
+
"cardcraft-review.list",
|
|
1712
|
+
withQuery("/cli/admin/cardcraft-reviews", {
|
|
1713
|
+
status: parseChoice(
|
|
1714
|
+
options.adminStatus || "pending",
|
|
1715
|
+
"--status",
|
|
1716
|
+
REWARD_REVIEW_STATUSES,
|
|
1717
|
+
),
|
|
1718
|
+
beforeId: options.adminCursor
|
|
1719
|
+
? parseRequiredInteger(options.adminCursor, "--cursor", 1)
|
|
1720
|
+
: "",
|
|
1721
|
+
}),
|
|
1722
|
+
{ requiresRun: false },
|
|
1723
|
+
);
|
|
1724
|
+
}
|
|
1725
|
+
if (action === "show" || action === "get") {
|
|
1726
|
+
const reviewId = parseRequiredInteger(target, "Card crafting review ID", 1);
|
|
1727
|
+
return readOperation(
|
|
1728
|
+
"cardcraft-review.show",
|
|
1729
|
+
`/cli/admin/cardcraft-reviews/${reviewId}`,
|
|
1730
|
+
{ requiresRun: false, reviewId },
|
|
1731
|
+
);
|
|
1732
|
+
}
|
|
1733
|
+
if (REWARD_REVIEW_DECISIONS.includes(action)) {
|
|
1734
|
+
const reviewId = parseRequiredInteger(target, "Card crafting review ID", 1);
|
|
1735
|
+
const reason = String(options.adminReason || "").trim();
|
|
1736
|
+
if (action !== "approve" && !reason) {
|
|
1737
|
+
throw cliValidationError(
|
|
1738
|
+
`lumine admin cardcraft-review ${action} <id> needs --reason <text> the creator will read.`,
|
|
1739
|
+
);
|
|
1740
|
+
}
|
|
1741
|
+
if (reason.length > 1000) {
|
|
1742
|
+
throw cliValidationError("--reason must be at most 1000 characters.");
|
|
1743
|
+
}
|
|
1744
|
+
return writeOperation(
|
|
1745
|
+
"cardcraft-review.decide",
|
|
1746
|
+
"POST",
|
|
1747
|
+
`/cli/admin/cardcraft-reviews/${reviewId}`,
|
|
1748
|
+
{ decision: action, reason },
|
|
1749
|
+
{ requiresRun: false, reviewId, decision: action },
|
|
1750
|
+
);
|
|
1751
|
+
}
|
|
1752
|
+
throw cliValidationError(
|
|
1753
|
+
"Usage: lumine admin cardcraft-review list [--status pending|approved|all] [--cursor <id>] | show <id> | approve <id> [--reason <text>] | reject <id> --reason <text> | revoke <id> --reason <text>.",
|
|
1754
|
+
);
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1702
1757
|
if (namespace === "chat-reports" || namespace === "chat-report") {
|
|
1703
1758
|
// Members' in-chat message reports. Like reward reviews they are handled
|
|
1704
1759
|
// any time, never inside a daily-run lease; recording an outcome only
|
|
@@ -4339,6 +4394,70 @@ function printChatSafetyResult({ operation, data }) {
|
|
|
4339
4394
|
return false;
|
|
4340
4395
|
}
|
|
4341
4396
|
|
|
4397
|
+
function formatCardCraftReviewLine(review) {
|
|
4398
|
+
return `#${review.id} · ${review.status}${review.isLive ? " (live)" : ""} · Build ${review.buildId}${review.appTitle ? ` "${review.appTitle}"` : ""}${review.ownerUsername ? ` by ${review.ownerUsername}` : ""}${review.reason ? ` · ${review.reason}` : ""}`;
|
|
4399
|
+
}
|
|
4400
|
+
|
|
4401
|
+
function printCardCraftReviewResult({ operation, data }) {
|
|
4402
|
+
if (operation.name === "cardcraft-review.list") {
|
|
4403
|
+
const reviews = Array.isArray(data.reviews) ? data.reviews : [];
|
|
4404
|
+
console.log(
|
|
4405
|
+
`${reviews.length} card crafting review(s) (${data.filter || "pending"}):`,
|
|
4406
|
+
);
|
|
4407
|
+
for (const review of reviews)
|
|
4408
|
+
console.log(` ${formatCardCraftReviewLine(review)}`);
|
|
4409
|
+
if (data.nextCursor) console.log(`More: --cursor ${data.nextCursor}`);
|
|
4410
|
+
return;
|
|
4411
|
+
}
|
|
4412
|
+
const review = data.review || {};
|
|
4413
|
+
if (operation.name === "cardcraft-review.decide") {
|
|
4414
|
+
console.log(
|
|
4415
|
+
`Decision recorded: ${operation.decision} → #${review.id} is ${review.status}.`,
|
|
4416
|
+
);
|
|
4417
|
+
if (review.status === "approved")
|
|
4418
|
+
console.log(
|
|
4419
|
+
"Crafting in the published app now uses this recipe (no republish needed).",
|
|
4420
|
+
);
|
|
4421
|
+
if (review.status === "revoked")
|
|
4422
|
+
console.log(
|
|
4423
|
+
"New crafts stopped. Existing assets stay with their cards.",
|
|
4424
|
+
);
|
|
4425
|
+
return;
|
|
4426
|
+
}
|
|
4427
|
+
console.log(formatCardCraftReviewLine(review));
|
|
4428
|
+
if (review.craftedCount !== undefined)
|
|
4429
|
+
console.log(`Assets crafted under this recipe: ${review.craftedCount}`);
|
|
4430
|
+
for (const problem of review.problems || [])
|
|
4431
|
+
console.log(` problem: ${problem}`);
|
|
4432
|
+
const declaration = review.declaration || {};
|
|
4433
|
+
if (declaration.guidance) console.log(`Guidance: ${declaration.guidance}`);
|
|
4434
|
+
for (const kind of declaration.kinds || []) {
|
|
4435
|
+
console.log(`Kind ${kind.id} "${kind.label}"${kind.description ? ` — ${kind.description}` : ""}`);
|
|
4436
|
+
for (const [name, param] of Object.entries(kind.params || {})) {
|
|
4437
|
+
const shape =
|
|
4438
|
+
param.type === "enum"
|
|
4439
|
+
? `one of ${param.values.join(", ")}`
|
|
4440
|
+
: param.type === "integer" || param.type === "number"
|
|
4441
|
+
? `${param.type} ${param.min}-${param.max}${param.scaleWithTier ? " (scales with colour)" : ""}`
|
|
4442
|
+
: param.type === "string"
|
|
4443
|
+
? `text up to ${param.maxLength}`
|
|
4444
|
+
: param.type;
|
|
4445
|
+
console.log(` ${name}: ${shape}`);
|
|
4446
|
+
}
|
|
4447
|
+
}
|
|
4448
|
+
const colours = ["", "blue", "pink", "orange", "magenta", "gold", "black"];
|
|
4449
|
+
for (const level of ["1", "2", "3", "4", "5", "6"]) {
|
|
4450
|
+
const kinds = (declaration.tiers || {})[level];
|
|
4451
|
+
console.log(
|
|
4452
|
+
` ${level} ${colours[Number(level)]}: ${kinds?.length ? kinds.join(", ") : "not craftable"}`,
|
|
4453
|
+
);
|
|
4454
|
+
}
|
|
4455
|
+
for (const event of review.events || [])
|
|
4456
|
+
console.log(
|
|
4457
|
+
` ${new Date(event.createdAt * 1000).toISOString()} ${event.action} by ${event.actorId}${event.reason ? `: ${event.reason}` : ""}`,
|
|
4458
|
+
);
|
|
4459
|
+
}
|
|
4460
|
+
|
|
4342
4461
|
function printAdminResult({ operation, result }) {
|
|
4343
4462
|
const data = result?.data || {};
|
|
4344
4463
|
if (printChatSafetyResult({ operation, data })) return;
|
|
@@ -4350,6 +4469,10 @@ function printAdminResult({ operation, result }) {
|
|
|
4350
4469
|
printRewardReviewResult({ operation, data });
|
|
4351
4470
|
return;
|
|
4352
4471
|
}
|
|
4472
|
+
if (operation.name.startsWith("cardcraft-review.")) {
|
|
4473
|
+
printCardCraftReviewResult({ operation, data });
|
|
4474
|
+
return;
|
|
4475
|
+
}
|
|
4353
4476
|
if (operation.name === "runtime.evidence") {
|
|
4354
4477
|
const evidence = data.evidence || {};
|
|
4355
4478
|
console.log(
|
package/lib/cardcraft.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
import { requestJson } from "./http.js";
|
|
5
|
+
import { findLocalProjectMetadata } from "./workspace.js";
|
|
6
|
+
import { ensureAuth, assertAuthScope } from "./auth.js";
|
|
7
|
+
import { resolveRequiredBuildIdOrSelected } from "./commands.js";
|
|
8
|
+
|
|
9
|
+
// Creator-side AI Card crafting tooling (Twinkle.cardCraft).
|
|
10
|
+
//
|
|
11
|
+
// An app declares what AI Cards can become in it in `cardcraft.json` at the
|
|
12
|
+
// project root: kinds (pet, snack, furniture …) with their parameters, and
|
|
13
|
+
// which kinds each card colour (1 blue … 6 black) can become. A reviewer
|
|
14
|
+
// approves the recipe; the approved copy is what crafting uses, so editing
|
|
15
|
+
// the file changes nothing live until the next approval.
|
|
16
|
+
|
|
17
|
+
const CARDCRAFT_FILE = "cardcraft.json";
|
|
18
|
+
|
|
19
|
+
async function readWorkspaceCardCraftJson(options) {
|
|
20
|
+
const localProject = await findLocalProjectMetadata(
|
|
21
|
+
path.resolve(options.dir || process.cwd()),
|
|
22
|
+
);
|
|
23
|
+
if (!localProject?.rootDir) return { present: false, value: undefined };
|
|
24
|
+
let raw;
|
|
25
|
+
try {
|
|
26
|
+
raw = await fs.readFile(path.join(localProject.rootDir, CARDCRAFT_FILE), "utf8");
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (error?.code === "ENOENT") return { present: false, value: undefined };
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
return { present: true, value: JSON.parse(raw) };
|
|
33
|
+
} catch (error) {
|
|
34
|
+
throw new Error(`${CARDCRAFT_FILE} is not valid JSON: ${error?.message || error}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function printCheck(result) {
|
|
39
|
+
if (!result?.ok) {
|
|
40
|
+
console.log("Card crafting recipe: NOT ready.");
|
|
41
|
+
for (const error of result?.errors || []) console.log(` - ${error}`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const summary = result.summary || {};
|
|
45
|
+
console.log(
|
|
46
|
+
`Card crafting recipe: ok (${(summary.kinds || []).length} kind(s); card colours ${(summary.acceptedLevels || []).join(", ") || "none"}).`,
|
|
47
|
+
);
|
|
48
|
+
for (const kind of summary.kinds || [])
|
|
49
|
+
console.log(` ${kind.id}: ${kind.label} · params ${kind.params.join(", ") || "none"}`);
|
|
50
|
+
if (result.nextStep) console.log(result.nextStep);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function cardcraftCommand(options) {
|
|
54
|
+
const action = String(options.positional?.[0] || "check");
|
|
55
|
+
if (options.help) {
|
|
56
|
+
printCardCraftHelp();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const auth = await ensureAuth(options);
|
|
60
|
+
const buildId = await resolveRequiredBuildIdOrSelected(options, auth);
|
|
61
|
+
if (action === "check") {
|
|
62
|
+
const local = await readWorkspaceCardCraftJson(options);
|
|
63
|
+
const result = await requestJson({
|
|
64
|
+
url: `${options.apiUrl}/build/${buildId}/cardcraft/check`,
|
|
65
|
+
method: "POST",
|
|
66
|
+
authToken: auth.token,
|
|
67
|
+
timeoutMs: options.timeoutMs,
|
|
68
|
+
body: local.present ? { cardcraftJson: local.value } : {},
|
|
69
|
+
});
|
|
70
|
+
if (options.json) {
|
|
71
|
+
console.log(JSON.stringify(result, null, 2));
|
|
72
|
+
} else {
|
|
73
|
+
console.log(
|
|
74
|
+
local.present
|
|
75
|
+
? `Checked ${CARDCRAFT_FILE} from this workspace.`
|
|
76
|
+
: `No ${CARDCRAFT_FILE} in this workspace; checked the saved version instead.`,
|
|
77
|
+
);
|
|
78
|
+
printCheck(result);
|
|
79
|
+
}
|
|
80
|
+
if (!result.ok) process.exitCode = 1;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (action === "status") {
|
|
84
|
+
const result = await requestJson({
|
|
85
|
+
url: `${options.apiUrl}/build/${buildId}/cardcraft`,
|
|
86
|
+
authToken: auth.token,
|
|
87
|
+
timeoutMs: options.timeoutMs,
|
|
88
|
+
});
|
|
89
|
+
if (options.json) console.log(JSON.stringify(result, null, 2));
|
|
90
|
+
else {
|
|
91
|
+
console.log(`Card crafting: ${result.state}. ${result.message || ""}`.trim());
|
|
92
|
+
if (result.latestReview)
|
|
93
|
+
console.log(
|
|
94
|
+
`Latest review #${result.latestReview.id}: ${result.latestReview.status}${result.latestReview.reason ? ` · ${result.latestReview.reason}` : ""}`,
|
|
95
|
+
);
|
|
96
|
+
for (const problem of result.problems || []) console.log(` - ${problem}`);
|
|
97
|
+
}
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (action === "review") {
|
|
101
|
+
await assertAuthScope({ options, auth, scope: "build:write" });
|
|
102
|
+
const result = await requestJson({
|
|
103
|
+
url: `${options.apiUrl}/build/${buildId}/cardcraft/reviews`,
|
|
104
|
+
method: "POST",
|
|
105
|
+
authToken: auth.token,
|
|
106
|
+
timeoutMs: options.timeoutMs,
|
|
107
|
+
body: {},
|
|
108
|
+
});
|
|
109
|
+
if (options.json) {
|
|
110
|
+
console.log(JSON.stringify(result, null, 2));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (result.state === "approved")
|
|
114
|
+
console.log(`The saved ${CARDCRAFT_FILE} is already the approved recipe (#${result.reviewId}).`);
|
|
115
|
+
else
|
|
116
|
+
console.log(
|
|
117
|
+
`Sent the saved ${CARDCRAFT_FILE} of Build ${buildId} for review (request #${result.reviewId}${result.duplicate ? ", already waiting" : ""}). Any approved recipe keeps working until this one is approved.`,
|
|
118
|
+
);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
printCardCraftHelp();
|
|
122
|
+
throw new Error(`Unknown cardcraft action: ${action}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function printCardCraftHelp() {
|
|
126
|
+
console.log(`Usage:
|
|
127
|
+
lumine cardcraft check Validate cardcraft.json (workspace, else the saved version)
|
|
128
|
+
lumine cardcraft status Approved / waiting for review / needs review
|
|
129
|
+
lumine cardcraft review Send the saved cardcraft.json for review
|
|
130
|
+
|
|
131
|
+
cardcraft.json (project root) says what AI Cards can become in this app:
|
|
132
|
+
{ "guidance"?: "short creative brief",
|
|
133
|
+
"kinds": [{ "id", "label", "description"?,
|
|
134
|
+
"params": { "<name>": { "type": "enum", "values": [...] }
|
|
135
|
+
| { "type": "integer" | "number", "min", "max", "scaleWithTier"? }
|
|
136
|
+
| { "type": "boolean" } | { "type": "string", "maxLength" } } }],
|
|
137
|
+
"tiers": { "1": ["<kind>"], … "6": ["<kind>"] } }
|
|
138
|
+
Card colours: 1 blue, 2 pink, 3 orange, 4 magenta, 5 gold, 6 black (grander as they go up);
|
|
139
|
+
quality (common … legendary) sets the finish effects. A card can be crafted once, ever;
|
|
140
|
+
the asset stays with the card when it is sold. Use Twinkle.cardCraft in the app.`);
|
|
141
|
+
}
|
package/lib/commands.js
CHANGED
|
@@ -58,6 +58,7 @@ import {
|
|
|
58
58
|
} from "./assets.js";
|
|
59
59
|
import { thumbnailCommand } from "./thumbnail.js";
|
|
60
60
|
import { rewardsCommand, reportRewardDeclaration } from "./rewards.js";
|
|
61
|
+
import { cardcraftCommand } from "./cardcraft.js";
|
|
61
62
|
import { chatlogCommand } from "./chatlog.js";
|
|
62
63
|
import {
|
|
63
64
|
assertAuthScope,
|
|
@@ -284,6 +285,10 @@ export async function main() {
|
|
|
284
285
|
await rewardsCommand(options);
|
|
285
286
|
return;
|
|
286
287
|
}
|
|
288
|
+
if (options.command === "cardcraft") {
|
|
289
|
+
await cardcraftCommand(options);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
287
292
|
if (options.command === "thumbnail") {
|
|
288
293
|
await thumbnailCommand(options);
|
|
289
294
|
return;
|
|
@@ -2956,6 +2961,7 @@ export function printHelp() {
|
|
|
2956
2961
|
lumine rewards sheet <file.json>
|
|
2957
2962
|
lumine rewards sheet --show
|
|
2958
2963
|
lumine rewards review
|
|
2964
|
+
lumine cardcraft check|status|review
|
|
2959
2965
|
lumine thumbnail set <file>
|
|
2960
2966
|
lumine thumbnail capture [--out <file>]
|
|
2961
2967
|
lumine thumbnail generate ["<prompt>"] --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana>
|
|
@@ -2986,6 +2992,7 @@ export function printHelp() {
|
|
|
2986
2992
|
lumine admin sponsor integrity get <case-id> [--json]
|
|
2987
2993
|
lumine admin sponsor integrity review <case-id> --decision clear|hold|flag|disqualify [--note <evidence>] [--json]
|
|
2988
2994
|
lumine admin reward-review list [--status pending|approved|all] [--cursor <id>] [--json]
|
|
2995
|
+
lumine admin cardcraft-review list [--status pending|approved|all] [--cursor <id>] | show <id> | approve|reject|revoke <id> [--reason <text>]
|
|
2989
2996
|
lumine admin reward-activity [--date YYYY-MM-DD] [--days <1..31>] [--build <id>] [--json]
|
|
2990
2997
|
lumine admin chat-reports list [--status pending|open|reviewing|resolved|dismissed|all] [--cursor <id>] [--json]
|
|
2991
2998
|
lumine admin chat-reports show <report-id> [--json] (reported message snapshot + surrounding context)
|
package/lib/constants.js
CHANGED
package/lib/rewards.js
CHANGED
|
@@ -238,7 +238,9 @@ function printRewardsHelp() {
|
|
|
238
238
|
|
|
239
239
|
rewards.json (project root) declares the economy the reviewer approves:
|
|
240
240
|
{ "userDailyXP", "userDailyCoins", "userDailyClaims"?,
|
|
241
|
-
"rules": [{ "id", "title", "howTo"? (how to earn it, in the player's words), "
|
|
241
|
+
"rules": [{ "id", "title", "howTo"? (how to earn it, in the player's words), "category"? (section header, up to 40 characters, e.g. "Bring friends"),
|
|
242
|
+
"series"? (lowercase key for repeat rules like friend-1/2/3; they must share category, verifier and amounts), "xp", "coins", "verifier": "numeric-quiz" | "completion",
|
|
242
243
|
"maxAttempts"?, "retry"?: { "xpPercent", "coinsPercent", "paidAttempts"? }, "minSeconds"? (completion), "progression"?: "dated" | "until-earned" (quiz) }] }
|
|
244
|
+
The Earn page shows rules under their category headers and each series as one row ("up to 3 a day").
|
|
243
245
|
Questions and answer keys never go in project files; they belong in the sheet.`);
|
|
244
246
|
}
|
package/package.json
CHANGED
package/sdk/BUILD_SDK_INDEX.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Version: 1.60.0
|
|
4
4
|
Updated: 2026-09-27
|
|
5
|
-
Generated: 2026-09-
|
|
5
|
+
Generated: 2026-09-27T13:54:27.792Z
|
|
6
6
|
|
|
7
7
|
## Notes
|
|
8
8
|
- This SDK is injected into Build iframes via the Build preview/runtime.
|
|
@@ -33,7 +33,7 @@ Generated: 2026-09-27T07:17:40.033Z
|
|
|
33
33
|
- Use Twinkle.live for one-way app livestreams and Twinkle.chat for the accompanying thread. Free livestreams require a verified host, end after at most 15 minutes, and issue at most 10 private viewer grants. Twinkle keeps platform-owned live-status/end controls above active hosts, so app code cannot hide or replace the broadcaster's Stop path.
|
|
34
34
|
- Media Energy is separate from AI Energy. Replace Media Energy UI only from canonical mediaEnergy/getUsage responses; never decrement, reserve, or synthesize it in app code.
|
|
35
35
|
- Twinkle.rewards awards real XP and Coins only in the current approved published release. Drafts, local previews, private apps and superseded releases cannot earn. The server supplies a published-runtime grant; app code cannot choose a recipient or award amount.
|
|
36
|
-
- The creator's agent designs the rewards. Declare the economy in a project file `rewards.json` at the root: budgets (userDailyXP, userDailyCoins, optional userDailyClaims; there is no app-wide daily or lifetime budget, only what one learner can earn per day) and rules [{ id, title, xp, coins, verifier: 'numeric-quiz' | 'completion', maxAttempts?, retry?: { xpPercent, coinsPercent, paidAttempts? }, minSeconds? (completion), progression?: 'dated' | 'until-earned' (quiz) }]. Wire the matching Twinkle.rewards calls with those literal rule ids. Questions and answer keys NEVER go in project files (published source is readable by every player): quiz rules get them from the private question sheet uploaded with `lumine rewards sheet <file.json>` ({ rules: { <ruleId>: { questions?, sets? } } }); `lumine rewards check` validates both together. A review request freezes the code and proposes rewards.json merged with the sheet; the administrator reads the code, checks the amounts and whether the app is exploitable, may change any amount, and approves. Creators are kids and teens: show approval status and one Send for review action; do not ask them to fill in technical forms. Every code update that retains rewards needs a new approval before publishing. Removing the SDK automatically clears its gate. Apps read amounts, tries and sets from getStatus, never from their own file.
|
|
36
|
+
- The creator's agent designs the rewards. Declare the economy in a project file `rewards.json` at the root: budgets (userDailyXP, userDailyCoins, optional userDailyClaims; there is no app-wide daily or lifetime budget, only what one learner can earn per day) and rules [{ id, title, howTo?, category? (section header, up to 40 characters), series? (key for repeat rules that share category and amounts), xp, coins, verifier: 'numeric-quiz' | 'completion', maxAttempts?, retry?: { xpPercent, coinsPercent, paidAttempts? }, minSeconds? (completion), progression?: 'dated' | 'until-earned' (quiz) }]. Wire the matching Twinkle.rewards calls with those literal rule ids. Questions and answer keys NEVER go in project files (published source is readable by every player): quiz rules get them from the private question sheet uploaded with `lumine rewards sheet <file.json>` ({ rules: { <ruleId>: { questions?, sets? } } }); `lumine rewards check` validates both together. A review request freezes the code and proposes rewards.json merged with the sheet; the administrator reads the code, checks the amounts and whether the app is exploitable, may change any amount, and approves. Creators are kids and teens: show approval status and one Send for review action; do not ask them to fill in technical forms. Every code update that retains rewards needs a new approval before publishing. Removing the SDK automatically clears its gate. Apps read amounts, tries and sets from getStatus, never from their own file.
|
|
37
37
|
- Verifiers: 'numeric-quiz' pays for server-checked numeric answers (retry share, attempt limits, dated sets or until-earned sets that stay up until somebody earns them, after-answer guides). 'completion' pays when the app reports an activity finished — a cleared stage, a finished round — at least minSeconds after start({ ruleId }); the server checks only the elapsed time, once per learner per site day (UTC midnight), and the budgets. Call start when the activity begins and claim({ challengeId }) with no answers when it ends; keep completion amounts and userDailyXP small enough that a player scripting the calls would not matter, because nothing else is verified.
|
|
38
38
|
- Numeric quiz answers are verified on the server; client scores, privateDb state, timers and completion booleans are not verified reward evidence. Limits reset at UTC midnight. Rules are earned once per viewer per UTC day; attempt limits and retry payouts come from the approved rule. Challenges expire at the UTC day boundary. Budgets apply across release changes.
|
|
39
39
|
- Optional reward rule controls: maxLifetimeClaims caps one learner’s receipts for that rule across every day and release; completionProof: classic-tower-v1 requires a server-simulated Classic Tower finish in addition to minSeconds. These are server-enforced controls. Existing completion rules without completionProof still verify elapsed time only. Registered proof profiles also include breadface-v1, breadface-v2 and breadface-v3 (server-simulated Breadface inputs; each is one reviewed release's exact physics, chosen by the reviewer) study-record-v1 (a private study record reviewed by JEV, billed to the learner’s AI Energy), and Groove Lab's groove-lab-song-v1 (a song the learner published today, checked on the server for length, notes and originality) and groove-lab-heard-v1 (three established accounts finished the learner's songs today), Ashen Vigil's vigil-guest-coplay-v1 (a new guest spent 10 minutes with the learner in a private world room, measured by the world relay; each guest pays once) and minecraft-first-link-v1 (the learner is the first Twinkle account ever linked to that Minecraft player; each player pays once). Profiles are platform-owned; an app cannot invent a verifier or authorize its own reward.
|
|
@@ -1335,13 +1335,14 @@ world.updatePresence({ x, y, z, facing });
|
|
|
1335
1335
|
|
|
1336
1336
|
### Twinkle.rewards
|
|
1337
1337
|
- await Twinkle.rewards.getStatus() | scopes: rewards:claim
|
|
1338
|
-
- Returns: { mode: "live", dayKey, userDailyClaims, claimsToday, budgets: { userDailyXP, userDailyCoins }, rules: [{ id, title, howTo?, xp, coins, verifier: "numeric-quiz" | "completion", minSeconds?, progression?, retryReward: { xp, coins }, maxAttempts, available, setKey, questionCount }], challenges: [{ challengeId, ruleId, attempts, attemptsRemaining, state: "open" | "finished" | "earned", setKey, questions: [{ prompt, hint?, guide? }] }], history: [{ ruleId, xp, coins, attempt, createdAt }], balances: { xp, coins } } | { mode: "preview", dayKey?, rules, challenges: [], history: [], balances?, problems?: string[], message }
|
|
1338
|
+
- Returns: { mode: "live", dayKey, userDailyClaims, claimsToday, budgets: { userDailyXP, userDailyCoins }, rules: [{ id, title, howTo?, category?, series?, xp, coins, verifier: "numeric-quiz" | "completion", minSeconds?, progression?, retryReward: { xp, coins }, maxAttempts, available, setKey, questionCount }], challenges: [{ challengeId, ruleId, attempts, attemptsRemaining, state: "open" | "finished" | "earned", setKey, questions: [{ prompt, hint?, guide? }] }], history: [{ ruleId, xp, coins, attempt, createdAt }], balances: { xp, coins } } | { mode: "preview", dayKey?, rules, challenges: [], history: [], balances?, problems?: string[], message }
|
|
1339
1339
|
- Read canonical earning rules (without answer keys), today’s started challenges, today’s receipts and balances. Drafts return preview mode: for the app's owner the rules come from the draft's own rewards.json and question sheet (problems lists what is still wrong with them); anyone else sees no rules. Unapproved or revoked published releases return an error.
|
|
1340
1340
|
- rules[].available is false on a site day (UTC) the reviewer scheduled no questions for; show the rule as not available instead of starting it. xp/coins are the first-try amounts; retryReward is what a correct answer pays after a wrong one (equal to xp/coins unless the reviewer set a retry share). maxAttempts null means unlimited wrong answers until the site's daily reset (UTC midnight, 9:00 AM in Korea). retry.paidAttempts, when set, is the last attempt number a correct answer is still paid on: a later correct answer is recorded as solved (receipt xp 0, coins 0) and pays nothing — tell the learner before they pass it.
|
|
1341
1341
|
- challenges lists challenges this viewer already started today with their questions, so an app can resume after a reload without calling start. A question's guide (reviewer-approved JSON teaching content: explanation, interactive-model configuration) is present only once the viewer has answered at least once, right or wrong; render it as the after-attempt lesson. claimsToday against userDailyClaims (null = uncapped) tells whether another bounty can still pay today.
|
|
1342
1342
|
- Under progression 'until-earned' the same set stays up day after day until somebody earns it; setKey names the set currently up. Completion rules are always available and have questionCount 0.
|
|
1343
1343
|
- Rules may set maxLifetimeClaims, a per-learner limit for that rule across days and releases. rules[].lifetime contains the server-confirmed limit and remaining claims. App storage never enforces this limit.
|
|
1344
1344
|
- howTo is the rule's optional plain-words steps from rewards.json (up to 300 characters, e.g. "Save a new design of at least 64 blocks in Designs"). The Earn page shows it under the title; show it in the app's own rewards list too so players always know exactly what earns each reward.
|
|
1345
|
+
- category and series are optional rewards.json labels for a long list. category is a short section header (up to 40 characters, e.g. "Bring friends"); series is a short lowercase key (e.g. "new-friend") tying repeat rules like vigil-friend-1/2/3 together, and rules in one series must share category, verifier, XP and Coins. The Earn page groups rules by category and shows each series as one row ("up to 3 a day", "1 of 3 today"); group the app's own rewards list the same way.
|
|
1345
1346
|
- await Twinkle.rewards.getReceipt({ challengeId }) | scopes: rewards:claim
|
|
1346
1347
|
- Returns: { mode: "live", status: "awarded" | "pending" | "expired" | "not_found", receipt: { id, challengeId, ruleId, reviewId, artifactVersionId, dayKey, xp, coins, attempt, createdAt } | null, balances: { xp, coins } } | { mode: "preview", status: "not_found", receipt: null, message }
|
|
1347
1348
|
- Read an existing receipt for this app and signed-in viewer by server-issued challengeId, including previous UTC days and previous approved versions. Requires the current approved published release and runtime grant; a stale frame must reload first. Never awards, retries a claim, returns answer keys, or restores removed rewards permission.
|
|
@@ -1386,6 +1387,32 @@ world.updatePresence({ x, y, z, facing });
|
|
|
1386
1387
|
- Use a receiptId returned by getTimeline. Both reads independently check retirement and app ownership; guessing an active or other app’s receipt cannot reveal its questions or guides. Returns build_reward_archive_unavailable (404) if unavailable.
|
|
1387
1388
|
- Uses that receipt’s frozen approved sheet, never today’s edited question or the mutable draft. Answer keys and tolerances are never returned. Render the question first and offer Reveal guide for learning, without a reward-claim button.
|
|
1388
1389
|
|
|
1390
|
+
### Twinkle.cardCraft
|
|
1391
|
+
- await Twinkle.cardCraft.getStatus() | scopes: cardCraft:read
|
|
1392
|
+
- 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? }
|
|
1393
|
+
- Live mode (the published app) returns the reviewer-approved recipe, which may differ from the cardcraft.json in the app files until the next approval. In a draft the owner sees their own draft recipe and its problems; anyone else sees no recipe.
|
|
1394
|
+
- cardcraft.json (project root): { guidance?, kinds: [{ id, label, description?, params: { <name>: { type: "enum", values } | { type: "integer" | "number", min, max, scaleWithTier? } | { type: "boolean" } | { type: "string", maxLength } } }], tiers: { "1"…"6": [kindId] } }. Levels are card colours (1 blue, 2 pink, 3 orange, 4 magenta, 5 gold, 6 black); a missing level means cards of that colour cannot be crafted here. scaleWithTier narrows a numeric range to the level's sixth of it (blue = bottom band, black = top band). Validate with `lumine cardcraft check`, send for review with `lumine cardcraft review`.
|
|
1395
|
+
- await Twinkle.cardCraft.craft({ cardId, requestId? }) | scopes: cardCraft:craft
|
|
1396
|
+
- Returns: { asset: { assetId, cardId, kind, name, description, params, palette: ["#rrggbb"], tier: { level, colour, quality, effects, rank }, state, stateRevision, craftedAt, stateUpdatedAt, card: { id, word, level, colour, quality, style, imageUrl, creator: { id, username }, crafterId }, app: { buildId } }, duplicate: boolean } | { mode: "preview", asset: { assetId: null, preview: true, … }, duplicate: false }
|
|
1397
|
+
- Twinkle asks the player to confirm first (a card can only ever be crafted once). Omit cardId to let Twinkle show the player a picker of their own cards that can be crafted here, or pick cards yourself with Twinkle.aiCards.list({ userId: viewer.id }); the card must be revealed (have its picture) and its colour must be accepted by the recipe. A player who cancels gets an error with code card_craft_cancelled.
|
|
1398
|
+
- The AI writes the name and description; Twinkle checks that text is fine to show publicly for all ages. Text that is not clearly fine is rewritten once, and if it is still not clearly fine the asset gets a plain name from its colour and species or kind (for example "Gold Fox") and a stock description, with free-text params left empty. The craft itself never fails because of this check.
|
|
1399
|
+
- The card page shows "Crafted into <name> in <app>" for a public app, and only "Crafted into <name>" (no app name or link) while the app is private. Deleting the app frees the card so it can be crafted again; unpublishing does not.
|
|
1400
|
+
- Only the published app crafts for real. In a draft the owner gets a preview asset: the AI runs on the draft recipe, nothing is stored and the card keeps its craft.
|
|
1401
|
+
- Pass the same requestId when retrying after a lost reply to get the same asset back (duplicate: true). Errors carry code: card_already_crafted (details.name, details.appTitle), card_craft_in_progress, card_changed_hands, card_craft_level_not_accepted, card_craft_card_unrevealed, card_craft_generation_failed (the card is unchanged; try again), card_craft_not_approved.
|
|
1402
|
+
- Example: const { asset } = await Twinkle.cardCraft.craft({ cardId });
|
|
1403
|
+
spawnPet(asset.kind, asset.params, asset.palette, asset.tier.effects);
|
|
1404
|
+
- await Twinkle.cardCraft.list({ cursor?, limit? }) | scopes: cardCraft:read
|
|
1405
|
+
- Returns: { assets: [{ assetId, cardId, kind, name, description, params, palette: ["#rrggbb"], tier: { level, colour, quality, effects, rank }, state, stateRevision, craftedAt, stateUpdatedAt, card: { id, word, level, colour, quality, style, imageUrl, creator: { id, username }, crafterId }, app: { buildId } }], cursor: { assetId } | null }
|
|
1406
|
+
- The viewer's assets in this app: assets whose card they own right now. After a sale the old owner's list no longer has it and the buyer's does. limit 1-50 (default 20).
|
|
1407
|
+
- await Twinkle.cardCraft.get({ assetId } | { cardId }) | scopes: cardCraft:read
|
|
1408
|
+
- Returns: { asset: { assetId, cardId, kind, name, description, params, palette: ["#rrggbb"], tier: { level, colour, quality, effects, rank }, state, stateRevision, craftedAt, stateUpdatedAt, card: { id, word, level, colour, quality, style, imageUrl, creator: { id, username }, crafterId }, app: { buildId } } | null }
|
|
1409
|
+
- null when the viewer does not own the card (any more), the card was burned, or it was crafted in another app.
|
|
1410
|
+
- await Twinkle.cardCraft.setState({ assetId, state, expectedRevision }) | scopes: cardCraft:write
|
|
1411
|
+
- Returns: { asset }
|
|
1412
|
+
- Saves the asset's own progress (JSON object/array up to 16,000 bytes, or null to clear): level, hunger, nickname, outfit. It travels with the card when it is sold, so keep asset progress here and player progress (coins, room layout) in privateDb.
|
|
1413
|
+
- The asset's name is fixed: it is shown on the card page and cannot be renamed. To let owners name their pet, keep an in-game nickname in state (for example state.nickname, trimmed and length-limited by your app) and show it in the game. After a sale the new owner can set their own nickname the same way.
|
|
1414
|
+
- expectedRevision is the stateRevision you last read; a mismatch fails with code card_craft_state_conflict (details.stateRevision), and a card the viewer no longer owns fails with card_craft_not_owner.
|
|
1415
|
+
|
|
1389
1416
|
## Examples
|
|
1390
1417
|
|
|
1391
1418
|
### Daily reflection feed
|