castle-web-cli 0.4.76 → 0.4.77
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-prompts.d.ts +9 -0
- package/dist/agent-prompts.js +27 -9
- package/dist/agent.d.ts +29 -0
- package/dist/agent.js +718 -244
- package/dist/commonInstructions.d.ts +1 -1
- package/dist/commonInstructions.js +7 -1
- package/dist/filesChanged.d.ts +25 -0
- package/dist/filesChanged.js +140 -0
- package/dist/init.js +1 -1
- package/dist/serve.js +18 -2
- package/dist/shell/assets/{index-DNWEQd4R.js → index-CvHiGhAV.js} +22 -22
- package/dist/shell/assets/{index-DuKq-Grp.css → index-QteLRDnK.css} +1 -1
- package/dist/shell/index.html +2 -2
- package/kits/basic-2d/editors/PlayOnly.jsx +8 -3
- package/kits/basic-2d/editors/SingleEditor.jsx +24 -8
- package/kits/basic-2d/engine/liveReload.js +88 -0
- package/package.json +1 -1
- package/kits/basic-2d/pnpm-workspace.yaml +0 -3
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const COMMON_INSTRUCTIONS = "## Touch controls (every deck)\n\n- **Playable on a touchscreen, with only the controls the game actually needs.** Castle decks are played on phones, so whatever input a game does use must work by touch \u2014 direct tap/drag on the game itself wherever possible, and on-screen buttons only where the mechanics genuinely call for them. Do NOT add controls a game doesn't need: never drop in a generic d-pad or movement overlay by default. Prefer touching the game directly over an overlay that just mirrors keyboard keys. Keyboard input is fine to support on top for desktop play. Match the controls to the actual mechanics \u2014 a game with no directional movement should have no movement controls at all.\n\n## Fit the card (every deck)\n\n- **The deck plays inside a fixed 5:7 portrait card, not the full window.** The card is sized to fit the screen (at most about 450x630px), clips overflow, and does not scroll. Design the whole layout to fit inside that portrait box: size UI relative to the card with percentages, flex/grid, `min()`, `clamp()`, or viewport-relative units instead of fixed tall panels. Let playfields scale down on smaller cards rather than overflowing; anything outside the card edges is cut off. The SDK exports `CARD_RATIO` (= 5 / 7) if you need the exact ratio.\n";
|
|
1
|
+
export declare const COMMON_INSTRUCTIONS = "## Assets (every deck)\n\n- **Load static assets (drawings, audio, etc.) through the bundler \u2014 never runtime-`fetch` a loose file path.** Use a static `import`, `import.meta.glob('./drawings/*.svg', { eager: true, import: 'default' })`, or inline the asset directly. The dev serve happens to serve loose files over HTTP, so `fetch('drawings/qb.svg')` looks like it works locally \u2014 but `save-deck` bundles the whole deck into a single file, loose files are no longer served, and the fetch silently fails on every platform. Kit decks: use the kit's own drawing/asset-loading APIs instead of a raw `fetch`.\n\n## Touch controls (every deck)\n\n- **Playable on a touchscreen, with only the controls the game actually needs.** Castle decks are played on phones, so whatever input a game does use must work by touch \u2014 direct tap/drag on the game itself wherever possible, and on-screen buttons only where the mechanics genuinely call for them. Do NOT add controls a game doesn't need: never drop in a generic d-pad or movement overlay by default. Prefer touching the game directly over an overlay that just mirrors keyboard keys. Keyboard input is fine to support on top for desktop play. Match the controls to the actual mechanics \u2014 a game with no directional movement should have no movement controls at all.\n\n## Fit the card (every deck)\n\n- **The deck plays inside a fixed 5:7 portrait card, not the full window.** The card is sized to fit the screen (at most about 450x630px), clips overflow, and does not scroll. Design the whole layout to fit inside that portrait box: size UI relative to the card with percentages, flex/grid, `min()`, `clamp()`, or viewport-relative units instead of fixed tall panels. Let playfields scale down on smaller cards rather than overflowing; anything outside the card edges is cut off. The SDK exports `CARD_RATIO` (= 5 / 7) if you need the exact ratio.\n- **Hand-rolled `<canvas>` elements must account for devicePixelRatio, or the game looks blurry on phones.** Size the backing store to the CSS layout size times `devicePixelRatio` (e.g. `canvas.width = rect.width * dpr`), keep the CSS width/height as the layout size, and scale the 2D context (`ctx.scale(dpr, dpr)`) so drawing code stays in CSS units \u2014 re-apply on resize. Kit decks don't need to do this by hand; the kit's engine already configures its canvas for DPR.\n - Exception: deliberate pixel art wants a fixed low-resolution backing store with `image-rendering: pixelated` CSS instead \u2014 don't DPR-scale that; the crisp chunky look is the point.\n";
|
|
@@ -2,11 +2,17 @@
|
|
|
2
2
|
// deck's CLAUDE.md, regardless of kit (or no kit). Single source of truth —
|
|
3
3
|
// edit here, not in the kits. Keep it truly kit-agnostic; kit-specific rules
|
|
4
4
|
// (e.g. Space being reserved for play/stop) live in each kit's own CLAUDE.md.
|
|
5
|
-
export const COMMON_INSTRUCTIONS = `##
|
|
5
|
+
export const COMMON_INSTRUCTIONS = `## Assets (every deck)
|
|
6
|
+
|
|
7
|
+
- **Load static assets (drawings, audio, etc.) through the bundler — never runtime-\`fetch\` a loose file path.** Use a static \`import\`, \`import.meta.glob('./drawings/*.svg', { eager: true, import: 'default' })\`, or inline the asset directly. The dev serve happens to serve loose files over HTTP, so \`fetch('drawings/qb.svg')\` looks like it works locally — but \`save-deck\` bundles the whole deck into a single file, loose files are no longer served, and the fetch silently fails on every platform. Kit decks: use the kit's own drawing/asset-loading APIs instead of a raw \`fetch\`.
|
|
8
|
+
|
|
9
|
+
## Touch controls (every deck)
|
|
6
10
|
|
|
7
11
|
- **Playable on a touchscreen, with only the controls the game actually needs.** Castle decks are played on phones, so whatever input a game does use must work by touch — direct tap/drag on the game itself wherever possible, and on-screen buttons only where the mechanics genuinely call for them. Do NOT add controls a game doesn't need: never drop in a generic d-pad or movement overlay by default. Prefer touching the game directly over an overlay that just mirrors keyboard keys. Keyboard input is fine to support on top for desktop play. Match the controls to the actual mechanics — a game with no directional movement should have no movement controls at all.
|
|
8
12
|
|
|
9
13
|
## Fit the card (every deck)
|
|
10
14
|
|
|
11
15
|
- **The deck plays inside a fixed 5:7 portrait card, not the full window.** The card is sized to fit the screen (at most about 450x630px), clips overflow, and does not scroll. Design the whole layout to fit inside that portrait box: size UI relative to the card with percentages, flex/grid, \`min()\`, \`clamp()\`, or viewport-relative units instead of fixed tall panels. Let playfields scale down on smaller cards rather than overflowing; anything outside the card edges is cut off. The SDK exports \`CARD_RATIO\` (= 5 / 7) if you need the exact ratio.
|
|
16
|
+
- **Hand-rolled \`<canvas>\` elements must account for devicePixelRatio, or the game looks blurry on phones.** Size the backing store to the CSS layout size times \`devicePixelRatio\` (e.g. \`canvas.width = rect.width * dpr\`), keep the CSS width/height as the layout size, and scale the 2D context (\`ctx.scale(dpr, dpr)\`) so drawing code stays in CSS units — re-apply on resize. Kit decks don't need to do this by hand; the kit's engine already configures its canvas for DPR.
|
|
17
|
+
- Exception: deliberate pixel art wants a fixed low-resolution backing store with \`image-rendering: pixelated\` CSS instead — don't DPR-scale that; the crisp chunky look is the point.
|
|
12
18
|
`;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ViteDevServer } from 'vite';
|
|
2
|
+
export interface FileChangeEntry {
|
|
3
|
+
/** Deck-relative POSIX path. */
|
|
4
|
+
path: string;
|
|
5
|
+
event: 'add' | 'change' | 'delete';
|
|
6
|
+
/**
|
|
7
|
+
* Deck-relative paths of modules affected via the Vite import graph: the
|
|
8
|
+
* changed file itself (when it's part of the module graph) plus every
|
|
9
|
+
* transitive importer. Empty when the file isn't imported by anything.
|
|
10
|
+
*/
|
|
11
|
+
affected: string[];
|
|
12
|
+
}
|
|
13
|
+
export interface FilesChangedMessage {
|
|
14
|
+
type: 'files_changed';
|
|
15
|
+
changes: FileChangeEntry[];
|
|
16
|
+
/** Union of every change's path + affected set, deduped. */
|
|
17
|
+
affected: string[];
|
|
18
|
+
}
|
|
19
|
+
export declare function installFilesChangedWatcher(opts: {
|
|
20
|
+
vite: ViteDevServer;
|
|
21
|
+
projectDir: string;
|
|
22
|
+
broadcast: (msg: FilesChangedMessage) => void;
|
|
23
|
+
/** Drop Vite transform caches (so `import.meta.glob` rescans new files). */
|
|
24
|
+
invalidate: () => void;
|
|
25
|
+
}): void;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Serve-side files-changed broadcast. Watches the deck dir through Vite's own
|
|
2
|
+
// chokidar watcher (one watcher for everything), coalesces bursts of fs events
|
|
3
|
+
// into a single `files_changed` message, and folds in the Vite module graph:
|
|
4
|
+
// each change carries the transitive importer closure of the changed file, so
|
|
5
|
+
// consumers can tell "code my bundle depends on changed" apart from "a loose
|
|
6
|
+
// file changed". Vite's own HMR stays fully OFF (`server.hmr: false` in
|
|
7
|
+
// serve.ts) — reload decisions belong to consumers (the kit engine, editors,
|
|
8
|
+
// the shell), not to Vite.
|
|
9
|
+
import * as path from 'path';
|
|
10
|
+
const IGNORED_SEGMENTS = new Set(['node_modules', '.castle', '.git']);
|
|
11
|
+
// Coalescing: a burst of writes (agent editing several files, an editor's
|
|
12
|
+
// debounced saves landing together) becomes one message. DEBOUNCE resets per
|
|
13
|
+
// event; MAX bounds total latency under a sustained stream.
|
|
14
|
+
const DEBOUNCE_MS = 100;
|
|
15
|
+
const MAX_WAIT_MS = 500;
|
|
16
|
+
export function installFilesChangedWatcher(opts) {
|
|
17
|
+
const { vite, projectDir, broadcast, invalidate } = opts;
|
|
18
|
+
const pending = new Map();
|
|
19
|
+
let debounceTimer = null;
|
|
20
|
+
let maxTimer = null;
|
|
21
|
+
const flush = () => {
|
|
22
|
+
if (debounceTimer)
|
|
23
|
+
clearTimeout(debounceTimer);
|
|
24
|
+
if (maxTimer)
|
|
25
|
+
clearTimeout(maxTimer);
|
|
26
|
+
debounceTimer = null;
|
|
27
|
+
maxTimer = null;
|
|
28
|
+
if (pending.size === 0)
|
|
29
|
+
return;
|
|
30
|
+
const changes = [...pending.entries()]
|
|
31
|
+
.map(([relPath, change]) => ({
|
|
32
|
+
path: relPath,
|
|
33
|
+
event: change.event,
|
|
34
|
+
affected: [...change.affected].sort(),
|
|
35
|
+
}))
|
|
36
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
37
|
+
pending.clear();
|
|
38
|
+
// New/deleted files can be picked up by `import.meta.glob` importers whose
|
|
39
|
+
// cached transforms wouldn't rescan; drop caches like `restart` does.
|
|
40
|
+
if (changes.some((c) => c.event !== 'change'))
|
|
41
|
+
invalidate();
|
|
42
|
+
const affected = new Set();
|
|
43
|
+
for (const change of changes) {
|
|
44
|
+
affected.add(change.path);
|
|
45
|
+
for (const p of change.affected)
|
|
46
|
+
affected.add(p);
|
|
47
|
+
}
|
|
48
|
+
broadcast({ type: 'files_changed', changes, affected: [...affected].sort() });
|
|
49
|
+
};
|
|
50
|
+
const schedule = () => {
|
|
51
|
+
if (debounceTimer)
|
|
52
|
+
clearTimeout(debounceTimer);
|
|
53
|
+
debounceTimer = setTimeout(flush, DEBOUNCE_MS);
|
|
54
|
+
if (!maxTimer)
|
|
55
|
+
maxTimer = setTimeout(flush, MAX_WAIT_MS);
|
|
56
|
+
};
|
|
57
|
+
vite.watcher.on('all', (watchEvent, absFile) => {
|
|
58
|
+
const event = watchEvent === 'add' || watchEvent === 'change'
|
|
59
|
+
? watchEvent
|
|
60
|
+
: watchEvent === 'unlink'
|
|
61
|
+
? 'delete'
|
|
62
|
+
: null;
|
|
63
|
+
if (!event)
|
|
64
|
+
return; // addDir / unlinkDir
|
|
65
|
+
const relPath = deckRelative(projectDir, absFile);
|
|
66
|
+
if (!relPath)
|
|
67
|
+
return;
|
|
68
|
+
// Compute the importer closure at event time: for deletes, Vite prunes the
|
|
69
|
+
// file's graph entry shortly after the event, so waiting until flush would
|
|
70
|
+
// lose the edges.
|
|
71
|
+
const affected = importerClosure(vite, projectDir, absFile);
|
|
72
|
+
const existing = pending.get(relPath);
|
|
73
|
+
if (existing) {
|
|
74
|
+
// Merge events: a file created then edited within the window is still an
|
|
75
|
+
// 'add' for consumers; otherwise the newest event wins.
|
|
76
|
+
if (!(existing.event === 'add' && event === 'change'))
|
|
77
|
+
existing.event = event;
|
|
78
|
+
for (const p of affected)
|
|
79
|
+
existing.affected.add(p);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
pending.set(relPath, { event, affected: new Set(affected) });
|
|
83
|
+
}
|
|
84
|
+
schedule();
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
// Deck-relative POSIX path, or null when the file is outside the deck or under
|
|
88
|
+
// an ignored directory.
|
|
89
|
+
function deckRelative(projectDir, absFile) {
|
|
90
|
+
const rel = path.relative(projectDir, absFile);
|
|
91
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel))
|
|
92
|
+
return null;
|
|
93
|
+
const posix = rel.split(path.sep).join('/');
|
|
94
|
+
if (posix.split('/').some((segment) => IGNORED_SEGMENTS.has(segment)))
|
|
95
|
+
return null;
|
|
96
|
+
return posix;
|
|
97
|
+
}
|
|
98
|
+
function clientModuleGraph(vite) {
|
|
99
|
+
try {
|
|
100
|
+
const graph = vite
|
|
101
|
+
.environments?.client?.moduleGraph ?? vite.moduleGraph;
|
|
102
|
+
return graph ?? null;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// Deck-relative paths of the changed file's modules plus all transitive
|
|
109
|
+
// importers. Empty when the file isn't part of the module graph.
|
|
110
|
+
function importerClosure(vite, projectDir, absFile) {
|
|
111
|
+
const graph = clientModuleGraph(vite);
|
|
112
|
+
if (!graph)
|
|
113
|
+
return [];
|
|
114
|
+
let start;
|
|
115
|
+
try {
|
|
116
|
+
start = graph.getModulesByFile(absFile);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
if (!start || start.size === 0)
|
|
122
|
+
return [];
|
|
123
|
+
const seen = new Set();
|
|
124
|
+
const files = new Set();
|
|
125
|
+
const queue = [...start];
|
|
126
|
+
while (queue.length > 0) {
|
|
127
|
+
const mod = queue.pop();
|
|
128
|
+
if (seen.has(mod))
|
|
129
|
+
continue;
|
|
130
|
+
seen.add(mod);
|
|
131
|
+
if (mod.file) {
|
|
132
|
+
const rel = deckRelative(projectDir, mod.file);
|
|
133
|
+
if (rel)
|
|
134
|
+
files.add(rel);
|
|
135
|
+
}
|
|
136
|
+
for (const importer of mod.importers ?? [])
|
|
137
|
+
queue.push(importer);
|
|
138
|
+
}
|
|
139
|
+
return [...files];
|
|
140
|
+
}
|
package/dist/init.js
CHANGED
|
@@ -35,7 +35,7 @@ const DEFAULT_KIT = "basic-2d";
|
|
|
35
35
|
// Registry version of castle-web-sdk to inject when scaffolding from a
|
|
36
36
|
// globally-installed castle-web (not from inside the workspace). Bumped
|
|
37
37
|
// alongside cli/sdk version bumps.
|
|
38
|
-
const PUBLISHED_SDK_VERSION = "0.4.
|
|
38
|
+
const PUBLISHED_SDK_VERSION = "0.4.9";
|
|
39
39
|
// Never copied into a fresh deck: build/dependency junk. castle.json IS copied
|
|
40
40
|
// (the kit ships a config-only one with the editor layout / file filters), but
|
|
41
41
|
// `scaffoldFromKit` strips any identity fields off it first -- a fresh deck has
|
package/dist/serve.js
CHANGED
|
@@ -7,6 +7,7 @@ import { WebSocketServer, WebSocket } from 'ws';
|
|
|
7
7
|
import { createIdeServer } from './ide.js';
|
|
8
8
|
import { createAgentServer } from './agent.js';
|
|
9
9
|
import { sceneFilesPlugin } from './vitePlugins.js';
|
|
10
|
+
import { installFilesChangedWatcher } from './filesChanged.js';
|
|
10
11
|
import * as config from './config.js';
|
|
11
12
|
import { graphql as castleGraphql } from './api.js';
|
|
12
13
|
import { executeCommand } from './castle-host/host.js';
|
|
@@ -240,7 +241,7 @@ export async function serve(dir, options = {}) {
|
|
|
240
241
|
// The WS server forwards `restart` to the browser, but it also needs the
|
|
241
242
|
// Vite instance so it can drop transform caches first (see invalidateModuleCaches).
|
|
242
243
|
const viteHolder = { vite: null };
|
|
243
|
-
startWSServer(wsPort, projectDir, logFile, screenshotsDir, viteHolder);
|
|
244
|
+
const { broadcast } = startWSServer(wsPort, projectDir, logFile, screenshotsDir, viteHolder);
|
|
244
245
|
// The deck root `/` serves a split-view shell: the deck in an iframe plus a
|
|
245
246
|
// toggle button for an xterm.js terminal. The terminal's PTY is lazy -- it
|
|
246
247
|
// spawns only when a browser first opens the PTY WebSocket.
|
|
@@ -279,6 +280,14 @@ export async function serve(dir, options = {}) {
|
|
|
279
280
|
logLevel: 'info',
|
|
280
281
|
});
|
|
281
282
|
viteHolder.vite = vite;
|
|
283
|
+
// File changes from ANY source (editors, agents, terminal) become one
|
|
284
|
+
// coalesced `files_changed` broadcast; consumers decide what to reload.
|
|
285
|
+
installFilesChangedWatcher({
|
|
286
|
+
vite,
|
|
287
|
+
projectDir,
|
|
288
|
+
broadcast,
|
|
289
|
+
invalidate: () => invalidateModuleCaches(vite),
|
|
290
|
+
});
|
|
282
291
|
await vite.listen();
|
|
283
292
|
// The PTY WebSocket upgrade is handled directly on Vite's HTTP server rather
|
|
284
293
|
// than through Vite's proxy (Vite's ws proxy didn't forward this path).
|
|
@@ -374,6 +383,13 @@ async function serveDetached(projectDir, options) {
|
|
|
374
383
|
function startWSServer(port, projectDir, logFile, screenshotsDir, viteHolder) {
|
|
375
384
|
const wss = new WebSocketServer({ port });
|
|
376
385
|
const clients = new Set();
|
|
386
|
+
const broadcast = (msg) => {
|
|
387
|
+
const data = JSON.stringify(msg);
|
|
388
|
+
for (const c of clients) {
|
|
389
|
+
if (c.readyState === WebSocket.OPEN)
|
|
390
|
+
c.send(data);
|
|
391
|
+
}
|
|
392
|
+
};
|
|
377
393
|
// For round-trip requests (e.g. screenshot), remember which socket issued a
|
|
378
394
|
// given requestId so the matching response is routed back to just that
|
|
379
395
|
// client instead of broadcast to every tab/CLI. Mirrors how `write_file`
|
|
@@ -491,7 +507,7 @@ function startWSServer(port, projectDir, logFile, screenshotsDir, viteHolder) {
|
|
|
491
507
|
}
|
|
492
508
|
});
|
|
493
509
|
});
|
|
494
|
-
return wss;
|
|
510
|
+
return { wss, broadcast };
|
|
495
511
|
}
|
|
496
512
|
function writeProjectFile(projectDir, requestedPath, contents) {
|
|
497
513
|
if (typeof requestedPath !== 'string' || requestedPath.trim() === '') {
|