castle-web-cli 0.4.174 → 0.4.176
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/dist/agent-failures.d.ts +2 -0
- package/dist/agent-failures.js +34 -1
- package/dist/agent-prompts.js +2 -1
- package/dist/agent.js +23 -13
- package/dist/ide.d.ts +2 -0
- package/dist/ide.js +60 -16
- package/dist/metering.d.ts +11 -0
- package/dist/metering.js +33 -0
- package/dist/platformDoc.js +1 -1
- package/dist/serve.js +25 -1
- package/dist/shell/assets/index-BWOEraUy.js +447 -0
- package/dist/shell/assets/index-BkVF1OXc.css +1 -0
- package/dist/shell/index.html +2 -2
- package/kits/physics-3d/castle.json +1 -1
- package/kits/physics-3d/engine3d/editorWorld.js +2 -1
- package/kits/physics-3d/engine3d/modelEditorWorld.js +2 -1
- package/kits/physics-3d/engine3d/world3d.js +3 -1
- package/package.json +1 -1
- package/dist/shell/assets/index-6odVZQSZ.css +0 -1
- package/dist/shell/assets/index-DF1yMXPS.js +0 -445
package/dist/agent-failures.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export interface AgentFailure {
|
|
|
9
9
|
suggestion?: string;
|
|
10
10
|
resetAtMs?: number;
|
|
11
11
|
castleCreditsExhausted?: boolean;
|
|
12
|
+
castleCreditsInsufficient?: boolean;
|
|
13
|
+
castleSpendLimit?: boolean;
|
|
12
14
|
}
|
|
13
15
|
export declare function failureForStatus(status: number, body: string, model?: string): AgentFailure | undefined;
|
|
14
16
|
export declare function classifyProviderError(text: string | undefined, model?: string): AgentFailure | undefined;
|
package/dist/agent-failures.js
CHANGED
|
@@ -46,8 +46,35 @@ export function failureForStatus(status, body, model) {
|
|
|
46
46
|
return config("bad-key");
|
|
47
47
|
if (status === 402)
|
|
48
48
|
return config("no-credits");
|
|
49
|
-
if (status === 403)
|
|
49
|
+
if (status === 403) {
|
|
50
|
+
// Castle's proxy deliberately uses provider-shaped 403s for its own spend
|
|
51
|
+
// and model gates. A request-size reservation can refuse while the account
|
|
52
|
+
// still has a small positive balance, so pre-flight cannot catch this case.
|
|
53
|
+
// Keep the matches tied to the proxy's Castle-specific copy: a provider's
|
|
54
|
+
// ordinary permission/content 403 must continue to read as a refusal.
|
|
55
|
+
if (/Castle AI request[^.]*balance can(?:not|'t) cover|add credits at castle\.xyz\/credits/i.test(body)) {
|
|
56
|
+
return {
|
|
57
|
+
kind: "limit",
|
|
58
|
+
detail,
|
|
59
|
+
model,
|
|
60
|
+
verbose: `HTTP ${status}: ${body}`,
|
|
61
|
+
castleCreditsInsufficient: true,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (/model\s+.+\s+(?:isn['’]t|is not) available on this Castle account/i.test(body)) {
|
|
65
|
+
return config("model-not-allowed");
|
|
66
|
+
}
|
|
67
|
+
if (/Castle AI spending limit reached/i.test(body)) {
|
|
68
|
+
return {
|
|
69
|
+
kind: "limit",
|
|
70
|
+
detail,
|
|
71
|
+
model,
|
|
72
|
+
verbose: `HTTP ${status}: ${body}`,
|
|
73
|
+
castleSpendLimit: true,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
50
76
|
return config("flagged");
|
|
77
|
+
}
|
|
51
78
|
if (status === 429 || status >= 500) {
|
|
52
79
|
return { kind: "transient", detail, model, verbose: `HTTP ${status}: ${body}` };
|
|
53
80
|
}
|
|
@@ -178,9 +205,15 @@ export function failureCopy(opts) {
|
|
|
178
205
|
case "config":
|
|
179
206
|
return `${configCopy(opts.failure)}${tasksNote}`;
|
|
180
207
|
case "limit":
|
|
208
|
+
if (opts.failure.castleCreditsInsufficient) {
|
|
209
|
+
return `Not enough Castle AI credits for this request — get more at castle.xyz/credits${tasksNote}`;
|
|
210
|
+
}
|
|
181
211
|
if (opts.failure.castleCreditsExhausted) {
|
|
182
212
|
return `Out of Castle AI credits — get more at castle.xyz/credits${tasksNote}`;
|
|
183
213
|
}
|
|
214
|
+
if (opts.failure.castleSpendLimit) {
|
|
215
|
+
return `Castle AI spending limit reached — check your balance at castle.xyz/credits${tasksNote}`;
|
|
216
|
+
}
|
|
184
217
|
return `Daily Castle AI limit reached${resetsClause(opts.failure.resetAtMs)}. Runs on your own API key or login aren't limited.${tasksNote}`;
|
|
185
218
|
case "transient":
|
|
186
219
|
return `OpenRouter is busy right now and I couldn't get through. Send that again in a moment.${tasksNote}`;
|
package/dist/agent-prompts.js
CHANGED
|
@@ -35,6 +35,7 @@ Hard rules:
|
|
|
35
35
|
- IMPORTS ARE YOURS, and they are the one exception to the rule above. Run \`castle-web list-decks --kind kits\` (also \`mine\` and \`saved\`) to see what this deck can import, and \`castle-web add-import <deckIdOrUrl>\` to add one -- both directly, not through a task. Do NOT claim you can list or import anything until you have actually run these; do not describe imports you have not looked at. \`list-decks\` prints one deck per line starting with the id \`add-import\` takes, and marks the ones this deck already has. A pasted castle.xyz deck link works in place of an id. Everything else about a deck's files still goes to task agents.
|
|
36
36
|
- Imported files are READ-ONLY, which is what to say when the user asks whether they can change something a kit or another import gave them, or why an edit to one did not stick: the deck takes its own version of that one file -- a fork, stored beside the import and used in its place -- and that version is what runs. Forking is how their own data and code changes override an import's, and a task agent does it; the tradeoff, worth saying plainly, is that a file done this way stops picking up changes when the import itself is updated, so it stays to the few files the change actually needs. Often it does not come to a fork at all, which is the better thing to say first: a kit picks up the deck's own behaviors and systems, so new logic is usually just a new file in the deck.
|
|
37
37
|
- You are the fast lane: get to your final reply as quickly as possible. When the user reports something broken, do NOT dig into the code to diagnose it first -- spawn a task whose job is to investigate AND fix it. Only read deck files when your reply itself needs them (answering a question about the deck, grounding a claim -- never make things up); never read as pre-work before spawning a task, and never read files just to learn conventions already covered by the Quick reference.${playtest ? `\n${PLAYTEST_ROUTER_RULE}` : ""}
|
|
38
|
+
- When you point the user at a file, a line, or a tunable value, write it as a link they can tap: \`[label](game.js)\` for a file, \`[label](game.js#L108)\` for a line, \`[label](game.js#params)\` for a file's tunable values and \`[label](game.js#params:enemySpeed)\` for ONE of them (the key exactly as it is written in that file's \`PARAMS\` object -- \`player.speed\` for a nested one). The label is your own words for the thing; the path must be exact and must be a file this deck actually has, or it renders as plain text. A bare \`game.js#108\` in a sentence links too. Cite a line number ONLY when you have read that file with numbered lines -- any deck contents in this prompt carry none, so never count them yourself; link the file or its params instead when you are unsure.
|
|
38
39
|
- Launch a SET of small steps the user tests one by one -- a pipeline, never one big task they wait on, never untestable fragments. One interacting mechanic = one task (paddle + ball + bricks = one playable core, not three). First step = the smallest genuinely playable thing; later steps build it out. Match breadth to ambition ("basic" = a few steps; "go wild" = many). You're optimizing the user's taste and feedback -- more small testable steps = more points where they steer it into something theirs.
|
|
39
40
|
- The whole goal: every piece of work TESTABLE in actual gameplay ASAP. Start every task as early as possible and run them in PARALLEL. Do NOT break tasks down by which files they touch, and never add \`after:\` just to avoid two tasks editing the same file -- tasks make surgical edits and overlap fine. The only real dependency between tasks is INFORMATION: a task is blocked only when it needs a fact it does not yet have.
|
|
40
41
|
- Dissolve those information-dependencies with a CONTRACT decided up front, instead of serializing tasks. Before spawning, pin the shared names the tasks will agree on -- scene file names, behavior names, drawing file names, tags -- and give each parallel task the exact names it must CREATE or REFERENCE. Pinned names must be EXACT filenames, extension included -- \`drawings/ship.sprite\` in a kit deck (the kit's CLAUDE.md names the format), \`drawings/ship.svg\` in a bare one. A bare deck has no kit to define a default art format or loader, so its contract must also state the format and loading approach every consumer will use. Then they all run at once and converge to a working game with NO follow-up wiring task: the scene task places actors referencing behaviors and drawings by their agreed names before those files even exist; the behavior tasks create those behavior names; the drawing tasks create those drawing files. (Art: pin an exact drawing filename like \`drawings/ship.sprite\` up front -> the scene task points an actor at it while another task draws it, in parallel; no place-it-later task.) Fall back to a separate waiting wire-in task (\`after:\` the maker, on the least possible) ONLY when a task truly needs another's actual produced output, not just its agreed name. Working out this breakdown -- what to parallelize, which names to pin up front -- is the core of your job and differs per game.
|
|
@@ -992,7 +993,7 @@ phase: wiring the paddle to touch
|
|
|
992
993
|
|
|
993
994
|
- \`avatar\` -- what you're doing RIGHT NOW, exactly one of: \`thinking\` (figuring out the plan), \`reading\` (looking through files), \`building\` (writing game logic), \`painting\` (making or editing art/visuals), \`playing\` (trying it in the game).
|
|
994
995
|
- \`phase\` -- a SHORT, plain-language line for this moment ("wiring the paddle to touch", "drawing the flag"). No file or code names. Update \`avatar\` and \`phase\` together each time your stage changes -- a few times across the task, not every turn. (You may also include \`progress: NN\` here, but the progress file above is the primary progress channel.)
|
|
995
|
-
- Before finishing, write ${opts.notesPath}: a tiny test guide for the PLAYER -- AT MOST 2-3 bullets (markdown \`- \` lines), each ONE short phrase: what to try and what should happen. NO code, NO API or "integration contract" detail, and no tour of how you built it -- that part is for you, not them. But DO name a file or a value where the user could act on it: a number they might want changed is worth naming, and worth naming the way they would find it
|
|
996
|
+
- Before finishing, write ${opts.notesPath}: a tiny test guide for the PLAYER -- AT MOST 2-3 bullets (markdown \`- \` lines), each ONE short phrase: what to try and what should happen. NO code, NO API or "integration contract" detail, and no tour of how you built it -- that part is for you, not them. But DO name a file or a value where the user could act on it: a number they might want changed is worth naming, and worth naming the way they would find it, as a link they can tap: "enemy speed -- [spawnRate](game.js#params:spawnRate)" when it is an entry in that file's \`PARAMS\` object, "[the spawn loop](game.js#L42)" for a place in the code (a line number only from a numbered read of the file). Occasionally one bullet may run a little longer, but default to terse. Mention a blocker only if you hit one. The user reads this verbatim to check your work.${wrapUp}
|
|
996
997
|
- If you are truly blocked, write the blocker to the notes file and stop rather than guessing wildly.
|
|
997
998
|
- Never touch files under .castle/ other than ${opts.plan ? "those three paths" : "those two paths"}. (The version store lives there too, but you never edit it by hand -- \`castle-web save-version\` writes it for you.)`;
|
|
998
999
|
}
|
package/dist/agent.js
CHANGED
|
@@ -31,7 +31,7 @@ import { applyPlanOps, buildRouterPromptParts, buildTaskPrompt, parsePlanOps, pl
|
|
|
31
31
|
import { readCastleJson } from './castleJson.js';
|
|
32
32
|
import { checkOpenrouterKey, checkOpenrouterModel, openrouterCatalogEntry, primeOpenrouterCatalog, } from './openrouter-catalog.js';
|
|
33
33
|
import { classifyProviderError, failureCopy, setReaderTimeZone, } from './agent-failures.js';
|
|
34
|
-
import { castleCreditsExhausted, fetchAiCredits, fetchBudget, meteringHeaders, newAgentSessionId, reportCursorRun, spendableMicros, withCustomHeaders, } from './metering.js';
|
|
34
|
+
import { castleCreditsExhausted, createRefreshQueue, fetchAiCredits, fetchBudget, meteringHeaders, newAgentSessionId, reportCursorRun, spendableMicros, withCustomHeaders, } from './metering.js';
|
|
35
35
|
import { anthropicKeyHelperCommand, claudeHasSavedLogin, cursorAuthPath, cursorHasUserLogin, resolveAnthropicAuth, userKey, ANTHROPIC_CREDENTIAL_ENV, ANTHROPIC_PROXY_ENV, CASTLE_USER_KEYS_PATH, } from './byo-auth.js';
|
|
36
36
|
import { accountsSnapshot, loginProviderFor, watchCredentials, writeCredential, } from './byo-accounts.js';
|
|
37
37
|
import { cancelLogin, logout, startLogin, submitLoginCode } from './byo-login.js';
|
|
@@ -2101,19 +2101,24 @@ function createUsageFeed(opts) {
|
|
|
2101
2101
|
let latest = null;
|
|
2102
2102
|
let credits = null;
|
|
2103
2103
|
let budgetPolls = 0;
|
|
2104
|
-
async function
|
|
2104
|
+
async function readUsage(refreshCredits = false) {
|
|
2105
2105
|
if (!opts.castlePaid()) {
|
|
2106
2106
|
credits = null;
|
|
2107
|
-
|
|
2108
|
-
return;
|
|
2107
|
+
return null;
|
|
2109
2108
|
}
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2109
|
+
// Budget is the authoritative spendable amount and its read can force a
|
|
2110
|
+
// stale proxy gate to sync after a purchase. Read credits second so the
|
|
2111
|
+
// descriptive fields (plan, rate, reload state) describe that same or a
|
|
2112
|
+
// newer account snapshot instead of racing ahead of the gate refresh.
|
|
2113
|
+
const budget = await fetchBudget();
|
|
2114
|
+
if (refreshCredits) {
|
|
2115
|
+
const nextCredits = await fetchAiCredits();
|
|
2116
|
+
// Once the richer field has loaded, a transient GraphQL failure should
|
|
2117
|
+
// not erase it and fall the UI back to the legacy daily presentation.
|
|
2118
|
+
if (nextCredits)
|
|
2119
|
+
credits = nextCredits;
|
|
2120
|
+
}
|
|
2121
|
+
return usageFrame(budget, credits);
|
|
2117
2122
|
}
|
|
2118
2123
|
function publishUsage(next) {
|
|
2119
2124
|
if (JSON.stringify(next ?? null) === JSON.stringify(latest ?? null))
|
|
@@ -2121,13 +2126,18 @@ function createUsageFeed(opts) {
|
|
|
2121
2126
|
latest = next;
|
|
2122
2127
|
opts.broadcast({ type: 'usage', usage: next });
|
|
2123
2128
|
}
|
|
2124
|
-
|
|
2129
|
+
// Focus, host relay, popover-open and run-finished can all arrive together.
|
|
2130
|
+
const refreshQueue = createRefreshQueue({ read: readUsage, publish: publishUsage });
|
|
2131
|
+
const requestRefresh = (withCredits) => {
|
|
2132
|
+
void refreshQueue.request(withCredits);
|
|
2133
|
+
};
|
|
2134
|
+
const refresh = () => requestRefresh(true);
|
|
2125
2135
|
const stopRunWatch = onAgentRunFinished(refresh);
|
|
2126
2136
|
const timer = setInterval(() => {
|
|
2127
2137
|
if (!opts.hasClients())
|
|
2128
2138
|
return;
|
|
2129
2139
|
budgetPolls += 1;
|
|
2130
|
-
|
|
2140
|
+
requestRefresh(budgetPolls % 5 === 0);
|
|
2131
2141
|
}, USAGE_POLL_MS);
|
|
2132
2142
|
timer.unref?.();
|
|
2133
2143
|
refresh();
|
package/dist/ide.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import * as http from 'http';
|
|
2
2
|
import { Duplex } from 'stream';
|
|
3
3
|
import { COVER_FILE } from './localPaths.js';
|
|
4
|
+
/** Cache only Vite's content-fingerprinted executable, style, and font output. */
|
|
5
|
+
export declare function shellAssetCacheControl(asset: string): string;
|
|
4
6
|
export declare const IDE_ASSET_PREFIX = "/__castle/ide/";
|
|
5
7
|
export declare const PTY_WS_PATH = "/__castle/pty";
|
|
6
8
|
export declare const VENDOR_PREFIX = "/__castle/vendor/";
|
package/dist/ide.js
CHANGED
|
@@ -51,6 +51,13 @@ const SHELL_MIME = {
|
|
|
51
51
|
'.ttf': 'font/ttf',
|
|
52
52
|
'.map': 'application/json; charset=utf-8',
|
|
53
53
|
};
|
|
54
|
+
const IMMUTABLE_SHELL_ASSET = /^assets\/[^/\\]+-[A-Za-z0-9_-]{8}\.(?:css|js|woff2?|ttf)$/;
|
|
55
|
+
/** Cache only Vite's content-fingerprinted executable, style, and font output. */
|
|
56
|
+
export function shellAssetCacheControl(asset) {
|
|
57
|
+
return IMMUTABLE_SHELL_ASSET.test(asset)
|
|
58
|
+
? 'public, max-age=31536000, immutable'
|
|
59
|
+
: 'no-store';
|
|
60
|
+
}
|
|
54
61
|
// Does the deck serve this root-level file itself? Vite serves both the deck
|
|
55
62
|
// root and its `public/` dir at `/`, so either location counts.
|
|
56
63
|
function deckHasFile(deckDir, name) {
|
|
@@ -97,7 +104,10 @@ function serveShellFile(res, asset) {
|
|
|
97
104
|
}
|
|
98
105
|
res.writeHead(200, {
|
|
99
106
|
'content-type': SHELL_MIME[path.extname(filePath)] ?? 'application/octet-stream',
|
|
100
|
-
|
|
107
|
+
// These built assets are platform-global bytes. The shell HTML and all
|
|
108
|
+
// unversioned files stay no-store because only the Vite hash makes a URL
|
|
109
|
+
// safe to retain across CLI updates.
|
|
110
|
+
'cache-control': shellAssetCacheControl(asset),
|
|
101
111
|
});
|
|
102
112
|
fs.createReadStream(filePath).pipe(res);
|
|
103
113
|
return true;
|
|
@@ -366,6 +376,49 @@ function filterImportedFiles(deckDir, imported) {
|
|
|
366
376
|
}
|
|
367
377
|
return out;
|
|
368
378
|
}
|
|
379
|
+
// The deck's files as the Files panel sees them. Each deck curates its own: the
|
|
380
|
+
// importing deck's visible/hidden paths name its own dirs (a kit's name scenes/,
|
|
381
|
+
// drawings/ ...), so applying them to imports would hide every import by
|
|
382
|
+
// omission -- an import is instead filtered by the config of the deck it came
|
|
383
|
+
// from, which is the deck that knows which of its files are worth showing.
|
|
384
|
+
function curatedDeckFiles(deckDir) {
|
|
385
|
+
const listed = listDeckFiles(deckDir);
|
|
386
|
+
const own = listed.filter((f) => !isImportPath(f));
|
|
387
|
+
return [
|
|
388
|
+
...filterOwnFiles(own, readEditorConfig(deckDir)),
|
|
389
|
+
...filterImportedFiles(deckDir, listed.filter(isImportPath)),
|
|
390
|
+
].sort((a, b) => a.localeCompare(b));
|
|
391
|
+
}
|
|
392
|
+
// The source extensions a `PARAMS` object can be read out of -- the same ones
|
|
393
|
+
// the shell parses as JS/TS. Not a kit's vocabulary: `export const PARAMS` is a
|
|
394
|
+
// JavaScript declaration, not a file type somebody declared.
|
|
395
|
+
const PARAMS_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx']);
|
|
396
|
+
// Deliberately looser than the shell's parser, which also accepts a `const
|
|
397
|
+
// PARAMS` that is never exported. A prefilter that were TIGHTER than the parser
|
|
398
|
+
// would drop files the panel can actually edit; the shell re-parses each
|
|
399
|
+
// candidate and discards the ones that only look like a match.
|
|
400
|
+
const PARAMS_DECLARATION = /\bconst\s+PARAMS\b/;
|
|
401
|
+
// Which of the deck's files declare params. Scanning here rather than in the
|
|
402
|
+
// browser because a physics-2d deck carries a hundred-plus files under
|
|
403
|
+
// `imports/`, and reading each one over HTTP to open a panel is the wrong cost.
|
|
404
|
+
function filesDeclaringParams(deckDir) {
|
|
405
|
+
const out = [];
|
|
406
|
+
for (const rel of curatedDeckFiles(deckDir)) {
|
|
407
|
+
if (!PARAMS_EXTS.has(path.extname(rel).toLowerCase()))
|
|
408
|
+
continue;
|
|
409
|
+
const resolved = resolveDeckPath(deckDir, rel);
|
|
410
|
+
if (!resolved.ok)
|
|
411
|
+
continue;
|
|
412
|
+
try {
|
|
413
|
+
if (PARAMS_DECLARATION.test(fs.readFileSync(resolved.abs, 'utf8')))
|
|
414
|
+
out.push(rel);
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
/* raced a delete, or not readable -- it simply has no params to offer */
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return out;
|
|
421
|
+
}
|
|
369
422
|
// Read a raw (binary) request body, refusing anything over `limit`. Uploads are
|
|
370
423
|
// arbitrary device files, so the cap is enforced as bytes arrive rather than
|
|
371
424
|
// after buffering the whole thing.
|
|
@@ -859,21 +912,12 @@ function handleFilesApi(deckDir, req, res, reqPath, restart, invalidate) {
|
|
|
859
912
|
// toggle) -- still minus the always-ignored dirs (node_modules/.castle/...),
|
|
860
913
|
// just without the deck's visible/hidden path curation.
|
|
861
914
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
862
|
-
const
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
// import is instead filtered by the config of the deck it came from, which
|
|
869
|
-
// is the deck that knows which of its files are worth showing.
|
|
870
|
-
const own = listed.filter((f) => !isImportPath(f));
|
|
871
|
-
files = [
|
|
872
|
-
...filterOwnFiles(own, readEditorConfig(deckDir)),
|
|
873
|
-
...filterImportedFiles(deckDir, listed.filter(isImportPath)),
|
|
874
|
-
].sort((a, b) => a.localeCompare(b));
|
|
875
|
-
}
|
|
876
|
-
sendJson(res, 200, { files });
|
|
915
|
+
const all = url.searchParams.get('all') === '1';
|
|
916
|
+
sendJson(res, 200, { files: all ? listDeckFiles(deckDir) : curatedDeckFiles(deckDir) });
|
|
917
|
+
return true;
|
|
918
|
+
}
|
|
919
|
+
if (action === 'params') {
|
|
920
|
+
sendJson(res, 200, { files: filesDeclaringParams(deckDir) });
|
|
877
921
|
return true;
|
|
878
922
|
}
|
|
879
923
|
if (action === 'read') {
|
package/dist/metering.d.ts
CHANGED
|
@@ -66,6 +66,17 @@ export interface AiCredits {
|
|
|
66
66
|
};
|
|
67
67
|
}
|
|
68
68
|
export declare function spendableMicros(budget: CastleBudget): number | null;
|
|
69
|
+
/**
|
|
70
|
+
* Coalesce asynchronous refresh triggers without letting an older read finish
|
|
71
|
+
* after and overwrite a newer one. `withFullState` is sticky across queued
|
|
72
|
+
* calls: a cheap poll cannot downgrade a pending user-requested full refresh.
|
|
73
|
+
*/
|
|
74
|
+
export declare function createRefreshQueue<T>(opts: {
|
|
75
|
+
read: (withFullState: boolean) => Promise<T>;
|
|
76
|
+
publish: (value: T) => void;
|
|
77
|
+
}): {
|
|
78
|
+
request: (withFullState: boolean) => Promise<void>;
|
|
79
|
+
};
|
|
69
80
|
export declare function castleCreditsExhausted(budget: CastleBudget, credits: AiCredits | null): boolean;
|
|
70
81
|
/** Rich credit state, or null when ghost/proxy cannot provide the new field. */
|
|
71
82
|
export declare function fetchAiCredits(): Promise<AiCredits | null>;
|
package/dist/metering.js
CHANGED
|
@@ -120,6 +120,39 @@ export function spendableMicros(budget) {
|
|
|
120
120
|
return null;
|
|
121
121
|
return Math.max(0, budget.limitMicros - budget.usedMicros);
|
|
122
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* Coalesce asynchronous refresh triggers without letting an older read finish
|
|
125
|
+
* after and overwrite a newer one. `withFullState` is sticky across queued
|
|
126
|
+
* calls: a cheap poll cannot downgrade a pending user-requested full refresh.
|
|
127
|
+
*/
|
|
128
|
+
export function createRefreshQueue(opts) {
|
|
129
|
+
let queued = false;
|
|
130
|
+
let queuedFullState = false;
|
|
131
|
+
let active = null;
|
|
132
|
+
const request = (withFullState) => {
|
|
133
|
+
queued = true;
|
|
134
|
+
queuedFullState ||= withFullState;
|
|
135
|
+
if (active)
|
|
136
|
+
return active;
|
|
137
|
+
active = (async () => {
|
|
138
|
+
while (queued) {
|
|
139
|
+
const fullState = queuedFullState;
|
|
140
|
+
queued = false;
|
|
141
|
+
queuedFullState = false;
|
|
142
|
+
const value = await opts.read(fullState);
|
|
143
|
+
// If another trigger arrived while this read was in flight, its result
|
|
144
|
+
// is already the one the caller wants. Skip the older intermediate
|
|
145
|
+
// frame instead of flashing it before the trailing refresh completes.
|
|
146
|
+
if (!queued)
|
|
147
|
+
opts.publish(value);
|
|
148
|
+
}
|
|
149
|
+
})().finally(() => {
|
|
150
|
+
active = null;
|
|
151
|
+
});
|
|
152
|
+
return active;
|
|
153
|
+
};
|
|
154
|
+
return { request };
|
|
155
|
+
}
|
|
123
156
|
const EXHAUSTED_BALANCE_CREDITS = 50;
|
|
124
157
|
export function castleCreditsExhausted(budget, credits) {
|
|
125
158
|
if (!budget.blocked || credits?.plan !== "credits")
|
package/dist/platformDoc.js
CHANGED
|
@@ -53,7 +53,7 @@ export function everyDeckBullets(deckDir) {
|
|
|
53
53
|
"- Do not reload the deck after an edit. Nothing auto-reloads: the person applies your changes when they are ready, from the Play panel's restart button or a panel's own reload control. `npm run restart` reloads every open panel at once and breaks their flow — only when asked.",
|
|
54
54
|
"- The deck can define its own editors: declare a file type in `castle.json` (`editor.fileTypes`: ext, label, icon, and an `editors/` module) and the Files panel opens that type in it — whole recipe under `## Editors` in the deck's guide (its kit's `CLAUDE.md` under `imports/`). Editors under `imports/` are read-only; add or fork, don't edit.",
|
|
55
55
|
'- An editor is for content FILES the creator keeps coming back to (catalogs, dialogue, level tables) that nothing already opens. One-off or generated data stays code.',
|
|
56
|
-
"- **Put the values someone would tune while playtesting in a `PARAMS` object**: `export const PARAMS = { jumpHeight: 4.2, coyoteTime: 0.08 };` near the top of the file that uses them.
|
|
56
|
+
"- **Put the values someone would tune while playtesting in a `PARAMS` object**: `export const PARAMS = { jumpHeight: 4.2, coyoteTime: 0.08 };` near the top of the file that uses them. The Parameters panel lists every file in the deck that has one and renders their values as fields -- a number scrubs, a boolean toggles, a `'#rrggbb'` string opens a color picker -- and writes the edit back into the file. It edits every file's params in one place, so nobody has to remember which file a setting lives in, and a file that has params says so above its code with a way in. That is how a creator tunes a game on a phone without putting a cursor inside an expression. Nested one level deep (`player: { speed: 5 }`) renders as a group; a value that is an expression (`speed: BASE * 2`) still shows, read-only.",
|
|
57
57
|
'- **This is for the handful of numbers someone would actually reach for between playtests, not every constant in the file.** A `PARAMS` with sixty keys is a wall nobody can scan, which is how a creator stops opening the pane at all. A magic number that only ever has one right value is not a param.',
|
|
58
58
|
"- **Read them through the object where they are used** -- `PARAMS.jumpHeight`, not `const { jumpHeight } = PARAMS` at module load. A destructured copy is taken once when the module loads and can never track a later change, so keeping the read at the use site is what leaves a tuned value able to reach the running deck.",
|
|
59
59
|
MODE_BULLET,
|
package/dist/serve.js
CHANGED
|
@@ -266,6 +266,7 @@ const EDIT_CONTENT_READ_ENDPOINTS = new Set([
|
|
|
266
266
|
'/__castle/files/read',
|
|
267
267
|
'/__castle/files/info',
|
|
268
268
|
'/__castle/files/list',
|
|
269
|
+
'/__castle/files/params',
|
|
269
270
|
'/__castle/files/forks',
|
|
270
271
|
]);
|
|
271
272
|
export function isAllowedContentFilesRequest(role, method, reqPath) {
|
|
@@ -596,6 +597,15 @@ function lockExistingImports(projectDir) {
|
|
|
596
597
|
}
|
|
597
598
|
}
|
|
598
599
|
}
|
|
600
|
+
function importPinsFingerprint(projectDir) {
|
|
601
|
+
try {
|
|
602
|
+
const config = JSON.parse(fs.readFileSync(path.join(projectDir, 'castle.json'), 'utf8'));
|
|
603
|
+
return JSON.stringify(config.imports ?? {});
|
|
604
|
+
}
|
|
605
|
+
catch {
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
599
609
|
export async function serve(dir, options = {}) {
|
|
600
610
|
const projectDir = path.resolve(dir);
|
|
601
611
|
if (!fs.existsSync(projectDir)) {
|
|
@@ -642,11 +652,18 @@ export async function serve(dir, options = {}) {
|
|
|
642
652
|
//
|
|
643
653
|
// Also skipped when CASTLE_NO_AUTO_UPDATE is set (local kit development:
|
|
644
654
|
// keep hand-synced imports/ instead of pulling published kits).
|
|
655
|
+
let importsChangedAtStartup = false;
|
|
645
656
|
if (!existingServe(projectDir)) {
|
|
657
|
+
const importsBeforeUpdate = importPinsFingerprint(projectDir);
|
|
646
658
|
// A failure to check must never keep the deck from opening.
|
|
647
|
-
await autoUpdateImports(projectDir).catch((e) => {
|
|
659
|
+
const updated = await autoUpdateImports(projectDir).catch((e) => {
|
|
648
660
|
console.warn(`auto-update: skipped -- ${e instanceof Error ? e.message : String(e)}`);
|
|
661
|
+
return [];
|
|
649
662
|
});
|
|
663
|
+
// Comparing pins as well as the returned list preserves the invalidation
|
|
664
|
+
// if a later step throws after one or more imported trees were replaced.
|
|
665
|
+
importsChangedAtStartup =
|
|
666
|
+
updated.length > 0 || importsBeforeUpdate !== importPinsFingerprint(projectDir);
|
|
650
667
|
}
|
|
651
668
|
// Start pulling the headless browser now, in the background.
|
|
652
669
|
//
|
|
@@ -815,6 +832,13 @@ export async function serve(dir, options = {}) {
|
|
|
815
832
|
playtestHolder.run = (req) => agentServer.runPlaytestFor(req);
|
|
816
833
|
const vite = await createViteServer({
|
|
817
834
|
root: projectDir,
|
|
835
|
+
// Vite's cached optimizer metadata keys package/lock/config inputs, not the
|
|
836
|
+
// source of an imported deck. A serve-start auto-update can therefore add
|
|
837
|
+
// bare imports while leaving Vite convinced its old pre-bundle is current.
|
|
838
|
+
// Force only that startup's cold optimization: unchanged serves keep the
|
|
839
|
+
// warm cache, while the first page after replaced import source never has
|
|
840
|
+
// to recover from an outdated optimized-dependency response (HMR is off).
|
|
841
|
+
...(importsChangedAtStartup ? { optimizeDeps: { force: true } } : {}),
|
|
818
842
|
plugins: [
|
|
819
843
|
castlePlugin(security, ideServer, agentServer, options.host),
|
|
820
844
|
forksOverlayPlugin(),
|