picopilot 0.0.0 → 0.1.0
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/LICENSE +661 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +4 -0
- package/dist/bin.js.map +1 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +59 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/audio.d.ts +17 -0
- package/dist/commands/audio.d.ts.map +1 -0
- package/dist/commands/audio.js +315 -0
- package/dist/commands/audio.js.map +1 -0
- package/dist/commands/export.d.ts +36 -0
- package/dist/commands/export.d.ts.map +1 -0
- package/dist/commands/export.js +257 -0
- package/dist/commands/export.js.map +1 -0
- package/dist/commands/gfx.d.ts +28 -0
- package/dist/commands/gfx.d.ts.map +1 -0
- package/dist/commands/gfx.js +521 -0
- package/dist/commands/gfx.js.map +1 -0
- package/dist/commands/init.d.ts +34 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +151 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/lint.d.ts +20 -0
- package/dist/commands/lint.d.ts.map +1 -0
- package/dist/commands/lint.js +90 -0
- package/dist/commands/lint.js.map +1 -0
- package/dist/commands/minify.d.ts +20 -0
- package/dist/commands/minify.d.ts.map +1 -0
- package/dist/commands/minify.js +129 -0
- package/dist/commands/minify.js.map +1 -0
- package/dist/commands/music.d.ts +13 -0
- package/dist/commands/music.d.ts.map +1 -0
- package/dist/commands/music.js +241 -0
- package/dist/commands/music.js.map +1 -0
- package/dist/commands/playtest.d.ts +29 -0
- package/dist/commands/playtest.d.ts.map +1 -0
- package/dist/commands/playtest.js +631 -0
- package/dist/commands/playtest.js.map +1 -0
- package/dist/commands/run.d.ts +30 -0
- package/dist/commands/run.d.ts.map +1 -0
- package/dist/commands/run.js +202 -0
- package/dist/commands/run.js.map +1 -0
- package/dist/commands/serve.d.ts +50 -0
- package/dist/commands/serve.d.ts.map +1 -0
- package/dist/commands/serve.js +213 -0
- package/dist/commands/serve.js.map +1 -0
- package/dist/commands/sfx.d.ts +13 -0
- package/dist/commands/sfx.d.ts.map +1 -0
- package/dist/commands/sfx.js +254 -0
- package/dist/commands/sfx.js.map +1 -0
- package/dist/commands/tokens.d.ts +36 -0
- package/dist/commands/tokens.d.ts.map +1 -0
- package/dist/commands/tokens.js +129 -0
- package/dist/commands/tokens.js.map +1 -0
- package/dist/commands/verify.d.ts +57 -0
- package/dist/commands/verify.d.ts.map +1 -0
- package/dist/commands/verify.js +213 -0
- package/dist/commands/verify.js.map +1 -0
- package/dist/commands/version.d.ts +12 -0
- package/dist/commands/version.d.ts.map +1 -0
- package/dist/commands/version.js +24 -0
- package/dist/commands/version.js.map +1 -0
- package/dist/engine/audio/index.d.ts +14 -0
- package/dist/engine/audio/index.d.ts.map +1 -0
- package/dist/engine/audio/index.js +14 -0
- package/dist/engine/audio/index.js.map +1 -0
- package/dist/engine/audio/music.d.ts +116 -0
- package/dist/engine/audio/music.d.ts.map +1 -0
- package/dist/engine/audio/music.js +138 -0
- package/dist/engine/audio/music.js.map +1 -0
- package/dist/engine/audio/section.d.ts +34 -0
- package/dist/engine/audio/section.d.ts.map +1 -0
- package/dist/engine/audio/section.js +67 -0
- package/dist/engine/audio/section.js.map +1 -0
- package/dist/engine/audio/sfx.d.ts +141 -0
- package/dist/engine/audio/sfx.d.ts.map +1 -0
- package/dist/engine/audio/sfx.js +506 -0
- package/dist/engine/audio/sfx.js.map +1 -0
- package/dist/engine/cart/cart.d.ts +75 -0
- package/dist/engine/cart/cart.d.ts.map +1 -0
- package/dist/engine/cart/cart.js +153 -0
- package/dist/engine/cart/cart.js.map +1 -0
- package/dist/engine/cart/errors.d.ts +29 -0
- package/dist/engine/cart/errors.d.ts.map +1 -0
- package/dist/engine/cart/errors.js +26 -0
- package/dist/engine/cart/errors.js.map +1 -0
- package/dist/engine/cart/gfx.d.ts +79 -0
- package/dist/engine/cart/gfx.d.ts.map +1 -0
- package/dist/engine/cart/gfx.js +171 -0
- package/dist/engine/cart/gfx.js.map +1 -0
- package/dist/engine/cart/index.d.ts +15 -0
- package/dist/engine/cart/index.d.ts.map +1 -0
- package/dist/engine/cart/index.js +15 -0
- package/dist/engine/cart/index.js.map +1 -0
- package/dist/engine/cart/map.d.ts +46 -0
- package/dist/engine/cart/map.d.ts.map +1 -0
- package/dist/engine/cart/map.js +118 -0
- package/dist/engine/cart/map.js.map +1 -0
- package/dist/engine/config/config.d.ts +74 -0
- package/dist/engine/config/config.d.ts.map +1 -0
- package/dist/engine/config/config.js +103 -0
- package/dist/engine/config/config.js.map +1 -0
- package/dist/engine/config/index.d.ts +7 -0
- package/dist/engine/config/index.d.ts.map +1 -0
- package/dist/engine/config/index.js +7 -0
- package/dist/engine/config/index.js.map +1 -0
- package/dist/engine/gfx/grid.d.ts +51 -0
- package/dist/engine/gfx/grid.d.ts.map +1 -0
- package/dist/engine/gfx/grid.js +116 -0
- package/dist/engine/gfx/grid.js.map +1 -0
- package/dist/engine/gfx/index.d.ts +17 -0
- package/dist/engine/gfx/index.d.ts.map +1 -0
- package/dist/engine/gfx/index.js +17 -0
- package/dist/engine/gfx/index.js.map +1 -0
- package/dist/engine/gfx/label.d.ts +34 -0
- package/dist/engine/gfx/label.d.ts.map +1 -0
- package/dist/engine/gfx/label.js +190 -0
- package/dist/engine/gfx/label.js.map +1 -0
- package/dist/engine/gfx/overlap.d.ts +42 -0
- package/dist/engine/gfx/overlap.d.ts.map +1 -0
- package/dist/engine/gfx/overlap.js +57 -0
- package/dist/engine/gfx/overlap.js.map +1 -0
- package/dist/engine/gfx/palette.d.ts +29 -0
- package/dist/engine/gfx/palette.d.ts.map +1 -0
- package/dist/engine/gfx/palette.js +46 -0
- package/dist/engine/gfx/palette.js.map +1 -0
- package/dist/engine/gfx/png.d.ts +31 -0
- package/dist/engine/gfx/png.d.ts.map +1 -0
- package/dist/engine/gfx/png.js +165 -0
- package/dist/engine/gfx/png.js.map +1 -0
- package/dist/engine/gfx/render.d.ts +42 -0
- package/dist/engine/gfx/render.d.ts.map +1 -0
- package/dist/engine/gfx/render.js +72 -0
- package/dist/engine/gfx/render.js.map +1 -0
- package/dist/engine/index.d.ts +22 -0
- package/dist/engine/index.d.ts.map +1 -0
- package/dist/engine/index.js +22 -0
- package/dist/engine/index.js.map +1 -0
- package/dist/engine/pico8/adapter.d.ts +267 -0
- package/dist/engine/pico8/adapter.d.ts.map +1 -0
- package/dist/engine/pico8/adapter.js +37 -0
- package/dist/engine/pico8/adapter.js.map +1 -0
- package/dist/engine/pico8/drive.d.ts +308 -0
- package/dist/engine/pico8/drive.d.ts.map +1 -0
- package/dist/engine/pico8/drive.js +581 -0
- package/dist/engine/pico8/drive.js.map +1 -0
- package/dist/engine/pico8/harness.d.ts +146 -0
- package/dist/engine/pico8/harness.d.ts.map +1 -0
- package/dist/engine/pico8/harness.js +251 -0
- package/dist/engine/pico8/harness.js.map +1 -0
- package/dist/engine/pico8/index.d.ts +21 -0
- package/dist/engine/pico8/index.d.ts.map +1 -0
- package/dist/engine/pico8/index.js +21 -0
- package/dist/engine/pico8/index.js.map +1 -0
- package/dist/engine/pico8/sentinel.d.ts +63 -0
- package/dist/engine/pico8/sentinel.d.ts.map +1 -0
- package/dist/engine/pico8/sentinel.js +86 -0
- package/dist/engine/pico8/sentinel.js.map +1 -0
- package/dist/engine/pico8/session-daemon-entry.d.ts +10 -0
- package/dist/engine/pico8/session-daemon-entry.d.ts.map +1 -0
- package/dist/engine/pico8/session-daemon-entry.js +11 -0
- package/dist/engine/pico8/session-daemon-entry.js.map +1 -0
- package/dist/engine/pico8/session-daemon-main.d.ts +44 -0
- package/dist/engine/pico8/session-daemon-main.d.ts.map +1 -0
- package/dist/engine/pico8/session-daemon-main.js +71 -0
- package/dist/engine/pico8/session-daemon-main.js.map +1 -0
- package/dist/engine/pico8/session-daemon.d.ts +68 -0
- package/dist/engine/pico8/session-daemon.d.ts.map +1 -0
- package/dist/engine/pico8/session-daemon.js +264 -0
- package/dist/engine/pico8/session-daemon.js.map +1 -0
- package/dist/engine/pico8/session.d.ts +140 -0
- package/dist/engine/pico8/session.d.ts.map +1 -0
- package/dist/engine/pico8/session.js +250 -0
- package/dist/engine/pico8/session.js.map +1 -0
- package/dist/engine/pico8/shell.d.ts +103 -0
- package/dist/engine/pico8/shell.d.ts.map +1 -0
- package/dist/engine/pico8/shell.js +324 -0
- package/dist/engine/pico8/shell.js.map +1 -0
- package/dist/engine/pico8/supervisor.d.ts +137 -0
- package/dist/engine/pico8/supervisor.d.ts.map +1 -0
- package/dist/engine/pico8/supervisor.js +140 -0
- package/dist/engine/pico8/supervisor.js.map +1 -0
- package/dist/engine/scaffold/index.d.ts +8 -0
- package/dist/engine/scaffold/index.d.ts.map +1 -0
- package/dist/engine/scaffold/index.js +8 -0
- package/dist/engine/scaffold/index.js.map +1 -0
- package/dist/engine/scaffold/scaffold.d.ts +49 -0
- package/dist/engine/scaffold/scaffold.d.ts.map +1 -0
- package/dist/engine/scaffold/scaffold.js +217 -0
- package/dist/engine/scaffold/scaffold.js.map +1 -0
- package/dist/engine/shrinko/adapter.d.ts +167 -0
- package/dist/engine/shrinko/adapter.d.ts.map +1 -0
- package/dist/engine/shrinko/adapter.js +107 -0
- package/dist/engine/shrinko/adapter.js.map +1 -0
- package/dist/engine/shrinko/index.d.ts +13 -0
- package/dist/engine/shrinko/index.d.ts.map +1 -0
- package/dist/engine/shrinko/index.js +13 -0
- package/dist/engine/shrinko/index.js.map +1 -0
- package/dist/engine/shrinko/shell.d.ts +69 -0
- package/dist/engine/shrinko/shell.d.ts.map +1 -0
- package/dist/engine/shrinko/shell.js +156 -0
- package/dist/engine/shrinko/shell.js.map +1 -0
- package/dist/engine/skills/index.d.ts +8 -0
- package/dist/engine/skills/index.d.ts.map +1 -0
- package/dist/engine/skills/index.js +8 -0
- package/dist/engine/skills/index.js.map +1 -0
- package/dist/engine/skills/skills.d.ts +104 -0
- package/dist/engine/skills/skills.d.ts.map +1 -0
- package/dist/engine/skills/skills.js +149 -0
- package/dist/engine/skills/skills.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +19 -0
- package/dist/version.js.map +1 -0
- package/package.json +48 -2
- package/src/bin.ts +4 -0
- package/src/cli.ts +61 -0
- package/src/commands/audio.test.ts +280 -0
- package/src/commands/audio.ts +410 -0
- package/src/commands/export.test.ts +310 -0
- package/src/commands/export.ts +316 -0
- package/src/commands/gfx-io.test.ts +178 -0
- package/src/commands/gfx.test.ts +470 -0
- package/src/commands/gfx.ts +645 -0
- package/src/commands/init.test.ts +460 -0
- package/src/commands/init.ts +193 -0
- package/src/commands/lint.test.ts +137 -0
- package/src/commands/lint.ts +117 -0
- package/src/commands/minify.test.ts +166 -0
- package/src/commands/minify.ts +156 -0
- package/src/commands/music.test.ts +272 -0
- package/src/commands/music.ts +288 -0
- package/src/commands/playtest.test.ts +317 -0
- package/src/commands/playtest.ts +773 -0
- package/src/commands/run.test.ts +238 -0
- package/src/commands/run.ts +259 -0
- package/src/commands/serve.test.ts +218 -0
- package/src/commands/serve.ts +272 -0
- package/src/commands/sfx.test.ts +349 -0
- package/src/commands/sfx.ts +300 -0
- package/src/commands/tokens.test.ts +212 -0
- package/src/commands/tokens.ts +168 -0
- package/src/commands/verify.test.ts +238 -0
- package/src/commands/verify.ts +256 -0
- package/src/commands/version.test.ts +70 -0
- package/src/commands/version.ts +24 -0
- package/src/engine/audio/index.ts +47 -0
- package/src/engine/audio/music.test.ts +188 -0
- package/src/engine/audio/music.ts +218 -0
- package/src/engine/audio/section.test.ts +124 -0
- package/src/engine/audio/section.ts +79 -0
- package/src/engine/audio/sfx.test.ts +410 -0
- package/src/engine/audio/sfx.ts +694 -0
- package/src/engine/cart/cart.test.ts +304 -0
- package/src/engine/cart/cart.ts +211 -0
- package/src/engine/cart/errors.ts +39 -0
- package/src/engine/cart/fixtures/crlf.p8 +6 -0
- package/src/engine/cart/fixtures/full.p8 +174 -0
- package/src/engine/cart/fixtures/header-only.p8 +2 -0
- package/src/engine/cart/fixtures/malformed-no-header.p8 +2 -0
- package/src/engine/cart/fixtures/minimal.p8 +10 -0
- package/src/engine/cart/fixtures/no-trailing-newline.p8 +6 -0
- package/src/engine/cart/gfx.ts +195 -0
- package/src/engine/cart/index.ts +27 -0
- package/src/engine/cart/map.ts +134 -0
- package/src/engine/config/config.ts +115 -0
- package/src/engine/config/index.ts +12 -0
- package/src/engine/gfx/grid.test.ts +128 -0
- package/src/engine/gfx/grid.ts +136 -0
- package/src/engine/gfx/index.ts +34 -0
- package/src/engine/gfx/label.test.ts +105 -0
- package/src/engine/gfx/label.ts +229 -0
- package/src/engine/gfx/overlap.test.ts +71 -0
- package/src/engine/gfx/overlap.ts +66 -0
- package/src/engine/gfx/palette.test.ts +35 -0
- package/src/engine/gfx/palette.ts +54 -0
- package/src/engine/gfx/png.test.ts +111 -0
- package/src/engine/gfx/png.ts +196 -0
- package/src/engine/gfx/render.test.ts +180 -0
- package/src/engine/gfx/render.ts +106 -0
- package/src/engine/index.ts +21 -0
- package/src/engine/pico8/adapter.ts +303 -0
- package/src/engine/pico8/drive.test.ts +415 -0
- package/src/engine/pico8/drive.ts +729 -0
- package/src/engine/pico8/harness.test.ts +181 -0
- package/src/engine/pico8/harness.ts +354 -0
- package/src/engine/pico8/index.ts +20 -0
- package/src/engine/pico8/sentinel.test.ts +80 -0
- package/src/engine/pico8/sentinel.ts +93 -0
- package/src/engine/pico8/session-daemon-entry.ts +12 -0
- package/src/engine/pico8/session-daemon-main.ts +98 -0
- package/src/engine/pico8/session-daemon.test.ts +212 -0
- package/src/engine/pico8/session-daemon.ts +306 -0
- package/src/engine/pico8/session.test.ts +239 -0
- package/src/engine/pico8/session.ts +321 -0
- package/src/engine/pico8/shell.test.ts +583 -0
- package/src/engine/pico8/shell.ts +412 -0
- package/src/engine/pico8/supervisor.test.ts +105 -0
- package/src/engine/pico8/supervisor.ts +197 -0
- package/src/engine/scaffold/index.ts +15 -0
- package/src/engine/scaffold/scaffold.ts +236 -0
- package/src/engine/shrinko/adapter.ts +241 -0
- package/src/engine/shrinko/fixtures/count-no-compressed.txt +2 -0
- package/src/engine/shrinko/fixtures/count-over-budget.txt +3 -0
- package/src/engine/shrinko/fixtures/count-under-budget.txt +3 -0
- package/src/engine/shrinko/index.ts +33 -0
- package/src/engine/shrinko/shell.ts +224 -0
- package/src/engine/shrinko/shrinko.test.ts +182 -0
- package/src/engine/skills/index.ts +14 -0
- package/src/engine/skills/skills.test.ts +376 -0
- package/src/engine/skills/skills.ts +205 -0
- package/src/index.ts +13 -0
- package/src/skills/game-design-reference/SKILL.md +70 -0
- package/src/skills/game-jam/SKILL.md +45 -0
- package/src/skills/picopilot-art/SKILL.md +62 -0
- package/src/skills/picopilot-audio/SKILL.md +194 -0
- package/src/skills/picopilot-code/SKILL.md +97 -0
- package/src/skills/picopilot-code/reference/README.md +42 -0
- package/src/skills/picopilot-code/reference/mode7-racing.md +95 -0
- package/src/skills/picopilot-code/reference/platformer.md +119 -0
- package/src/skills/picopilot-code/reference/puzzle-grid.md +106 -0
- package/src/skills/picopilot-code/reference/rpg-menus-dialog.md +122 -0
- package/src/skills/picopilot-code/reference/top-down-adventure.md +117 -0
- package/src/skills/picopilot-code/reference/twin-stick-arcade.md +128 -0
- package/src/skills/picopilot-debug/SKILL.md +195 -0
- package/src/skills/picopilot-overview/SKILL.md +52 -0
- package/src/version.ts +22 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The playtest SESSION SUPERVISOR (ADR-0011, prd US #6): the daemon that keeps a
|
|
3
|
+
* driven PICO-8 process ALIVE + PAUSED across the agent's separate CLI turns, and
|
|
4
|
+
* the client the per-verb sub-invocations use to address it BY ID.
|
|
5
|
+
*
|
|
6
|
+
* The problem this solves: `playtest step <id>` / `input <id>` / `shot <id>` are
|
|
7
|
+
* SEPARATE process invocations, but the driven PICO-8 must survive between them
|
|
8
|
+
* (staying alive + paused). A one-shot CLI process cannot hold another process's
|
|
9
|
+
* pipe, so `start` spawns a small DETACHED daemon that owns the live process + its
|
|
10
|
+
* stdin/stdout (via {@link DriveSession}) and listens on a per-session Unix domain
|
|
11
|
+
* socket; each later verb CONNECTS to that socket, sends its request, and gets the
|
|
12
|
+
* structured result back. The session is addressed by an id = the socket + a
|
|
13
|
+
* session dir under a controlled temp base (never `~/Desktop`, never the carts
|
|
14
|
+
* root).
|
|
15
|
+
*
|
|
16
|
+
* Lifecycle robustness (US #6 acceptance):
|
|
17
|
+
* - ORPHAN reaping: the daemon self-reaps after an IDLE timeout (no verb within
|
|
18
|
+
* the window), so a session whose agent never `stop`s it does not leak a live
|
|
19
|
+
* PICO-8 forever. `stop <id>` tears it down promptly.
|
|
20
|
+
* - PICO-8 dying mid-session surfaces as a STRUCTURED error (via
|
|
21
|
+
* {@link SessionError}), not a hang; the next verb reports `process-dead`.
|
|
22
|
+
* - `start` with PICO-8 ABSENT returns the same structured `pico8-not-found`
|
|
23
|
+
* boundary as `run`/`audio render`.
|
|
24
|
+
*
|
|
25
|
+
* ONE session per id (the id IS the socket). Multiple concurrent sessions ARE
|
|
26
|
+
* allowed (distinct ids/dirs/daemons); the tool does not serialize them, an agent
|
|
27
|
+
* that wants isolation uses distinct ids (the default id is random, so parallel
|
|
28
|
+
* `start`s never collide).
|
|
29
|
+
*
|
|
30
|
+
* The pure, CI-testable pieces (the id/path registry, the request/response codec)
|
|
31
|
+
* live here alongside the IO daemon; the live daemon end-to-end is the
|
|
32
|
+
* manual/opt-in tier. The handshake core it drives ({@link DriveSession}) is
|
|
33
|
+
* separately unit-tested against a fake process.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import {randomBytes} from 'node:crypto';
|
|
37
|
+
import {existsSync, mkdirSync, readdirSync, rmSync, statSync} from 'node:fs';
|
|
38
|
+
import {tmpdir} from 'node:os';
|
|
39
|
+
import {join} from 'node:path';
|
|
40
|
+
|
|
41
|
+
/** The controlled base dir all session dirs live under (never `~/Desktop`). */
|
|
42
|
+
export const SESSION_BASE_DIRNAME = 'picopilot-playtest-sessions' as const;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The default IDLE-reap window: a daemon with no verb for this long tears its
|
|
46
|
+
* session (and the live PICO-8) down, so an orphaned session (the agent walked
|
|
47
|
+
* away without `stop`) cannot leak a process indefinitely. Every verb resets it.
|
|
48
|
+
*/
|
|
49
|
+
export const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60_000;
|
|
50
|
+
|
|
51
|
+
/** The session verbs a client can send the daemon over the socket. */
|
|
52
|
+
export type SessionVerb = 'input' | 'step' | 'shot' | 'stop' | 'status';
|
|
53
|
+
|
|
54
|
+
/** A request sent from a verb sub-invocation to the session daemon. */
|
|
55
|
+
export type SessionRequest =
|
|
56
|
+
| {readonly verb: 'input'; readonly held: number}
|
|
57
|
+
| {readonly verb: 'step'; readonly frames: number}
|
|
58
|
+
| {readonly verb: 'shot'}
|
|
59
|
+
| {readonly verb: 'stop'}
|
|
60
|
+
| {readonly verb: 'status'};
|
|
61
|
+
|
|
62
|
+
/** A structured daemon response: either an ok payload or a structured error. */
|
|
63
|
+
export type SessionResponse =
|
|
64
|
+
| {readonly ok: true; readonly value: SessionResponseValue}
|
|
65
|
+
| {readonly ok: false; readonly code: string; readonly message: string};
|
|
66
|
+
|
|
67
|
+
/** The ok payload shape (a superset; each verb fills the relevant fields). */
|
|
68
|
+
export interface SessionResponseValue {
|
|
69
|
+
/** The session id. */
|
|
70
|
+
readonly id: string;
|
|
71
|
+
/** Total frames advanced so far. */
|
|
72
|
+
readonly frame: number;
|
|
73
|
+
/** The ACK line the cart printed for this command (settled-frame proof). */
|
|
74
|
+
readonly ack?: string;
|
|
75
|
+
/** For `shot`: the absolute screenshot path the cart just wrote. */
|
|
76
|
+
readonly screenshot?: string;
|
|
77
|
+
/** For `shot`: the basename the cart named the capture. */
|
|
78
|
+
readonly shotName?: string;
|
|
79
|
+
/** Whether the session is still alive after this verb. */
|
|
80
|
+
readonly alive: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Encodes a request/response as a single newline-terminated JSON frame (the
|
|
85
|
+
* socket protocol). Newline-framed so a reader can split a stream into whole
|
|
86
|
+
* messages; kept pure so the codec is unit-tested without a socket.
|
|
87
|
+
*/
|
|
88
|
+
export function encodeFrame(msg: SessionRequest | SessionResponse): string {
|
|
89
|
+
return `${JSON.stringify(msg)}\n`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A stateful frame decoder mirroring {@link encodeFrame}: feed it raw socket
|
|
94
|
+
* chunks, it yields whole parsed messages (buffering partial lines). Typed loose
|
|
95
|
+
* (`T`) because a client decodes responses and the daemon decodes requests.
|
|
96
|
+
*/
|
|
97
|
+
export class FrameDecoder<T> {
|
|
98
|
+
private buffer = '';
|
|
99
|
+
push(chunk: string): T[] {
|
|
100
|
+
this.buffer += chunk;
|
|
101
|
+
const out: T[] = [];
|
|
102
|
+
let nl: number;
|
|
103
|
+
while ((nl = this.buffer.indexOf('\n')) !== -1) {
|
|
104
|
+
const line = this.buffer.slice(0, nl);
|
|
105
|
+
this.buffer = this.buffer.slice(nl + 1);
|
|
106
|
+
if (line.trim().length > 0) out.push(JSON.parse(line) as T);
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A resolved session's on-disk layout (all under the controlled base). */
|
|
113
|
+
export interface SessionPaths {
|
|
114
|
+
/** The session id (also the dir name under the base). */
|
|
115
|
+
readonly id: string;
|
|
116
|
+
/** The session dir: `<base>/<id>`. */
|
|
117
|
+
readonly dir: string;
|
|
118
|
+
/** The Unix domain socket the daemon listens on. */
|
|
119
|
+
readonly socket: string;
|
|
120
|
+
/** The `-desktop` dir PICO-8 writes SHOT screenshots into. */
|
|
121
|
+
readonly shotDir: string;
|
|
122
|
+
/** The pidfile written by the daemon (for reap/liveness checks). */
|
|
123
|
+
readonly pidfile: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The session REGISTRY: the pure id/path logic mapping a session id to its
|
|
128
|
+
* on-disk layout under a controlled base dir. No IO beyond the base-dir join, so
|
|
129
|
+
* the id validation + path derivation are unit-tested. The daemon and the client
|
|
130
|
+
* both resolve a session THROUGH this, so they agree on where the socket is.
|
|
131
|
+
*/
|
|
132
|
+
export class SessionRegistry {
|
|
133
|
+
readonly base: string;
|
|
134
|
+
|
|
135
|
+
constructor(base?: string) {
|
|
136
|
+
this.base = base ?? join(tmpdir(), SESSION_BASE_DIRNAME);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A fresh random session id (short, filesystem-safe, collision-free enough). */
|
|
140
|
+
static freshId(): string {
|
|
141
|
+
return `s-${randomBytes(6).toString('hex')}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Validates a caller-supplied id: filesystem-safe, no path traversal, so an id
|
|
146
|
+
* can never escape the controlled base. Returns the id or throws a plain Error
|
|
147
|
+
* (the command maps it to a structured refusal).
|
|
148
|
+
*/
|
|
149
|
+
static validateId(id: string): string {
|
|
150
|
+
if (!/^[A-Za-z0-9._-]{1,64}$/.test(id) || id === '.' || id === '..') {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`invalid session id "${id}": use 1-64 chars of [A-Za-z0-9._-]`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
return id;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Resolves a session id to its {@link SessionPaths} under the base. */
|
|
159
|
+
resolve(id: string): SessionPaths {
|
|
160
|
+
SessionRegistry.validateId(id);
|
|
161
|
+
const dir = join(this.base, id);
|
|
162
|
+
return {
|
|
163
|
+
id,
|
|
164
|
+
dir,
|
|
165
|
+
socket: join(dir, 'sock'),
|
|
166
|
+
shotDir: join(dir, 'shots'),
|
|
167
|
+
pidfile: join(dir, 'pid'),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Creates the session dir + shot dir (idempotent), returning the paths. */
|
|
172
|
+
create(id: string): SessionPaths {
|
|
173
|
+
const paths = this.resolve(id);
|
|
174
|
+
mkdirSync(paths.shotDir, {recursive: true});
|
|
175
|
+
return paths;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The ids of all sessions with a dir present under the base (for listing/reap). */
|
|
179
|
+
list(): string[] {
|
|
180
|
+
if (!existsSync(this.base)) return [];
|
|
181
|
+
return readdirSync(this.base)
|
|
182
|
+
.filter((name) => {
|
|
183
|
+
try {
|
|
184
|
+
return statSync(join(this.base, name)).isDirectory();
|
|
185
|
+
} catch {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
.sort();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Removes a session's whole dir (the reap/cleanup, after the daemon exits). */
|
|
193
|
+
remove(id: string): void {
|
|
194
|
+
const {dir} = this.resolve(id);
|
|
195
|
+
rmSync(dir, {recursive: true, force: true});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The static content `picopilot init` scaffolds: the `#include`-discipline
|
|
3
|
+
* `main.p8` (built through the cart model), the editable `main.lua`, the curated
|
|
4
|
+
* `AGENTS.md` PICO-8 reference, and the `picopilot.json` config. See
|
|
5
|
+
* `scaffold.ts` for the single scaffolding model and why these are the files.
|
|
6
|
+
*/
|
|
7
|
+
export {
|
|
8
|
+
agentsMd,
|
|
9
|
+
LUA_INCLUDE_LINE,
|
|
10
|
+
mainLua,
|
|
11
|
+
mainP8,
|
|
12
|
+
picopilotJson,
|
|
13
|
+
type ScaffoldFile,
|
|
14
|
+
scaffoldFiles,
|
|
15
|
+
} from './scaffold.js';
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import {Cart} from '../cart/index.js';
|
|
2
|
+
import {defaultConfigFile} from '../config/index.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The static content `picopilot init` writes into a fresh cart folder.
|
|
6
|
+
*
|
|
7
|
+
* The single scaffolding model (US #1): the `.p8`'s `__lua__` section is exactly
|
|
8
|
+
* `#include main.lua`, and the plain-Lua `main.lua` is the file the agent edits,
|
|
9
|
+
* so the agent never hand-writes the cart's binary hex sections. `main.p8` is
|
|
10
|
+
* built THROUGH the cart model (not a hand-written string) so init and every
|
|
11
|
+
* later cart-touching command share one serializer and the output is guaranteed
|
|
12
|
+
* to round-trip.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** The PICO-8 cart format version init emits (the current stable text-format version). */
|
|
16
|
+
const CART_VERSION = 42;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The one line of code in the scaffolded cart. PICO-8's `#include` pulls the
|
|
20
|
+
* external Lua file in at load time; keeping it the ONLY thing in `__lua__` is
|
|
21
|
+
* the `#include` discipline the whole toolchain relies on.
|
|
22
|
+
*/
|
|
23
|
+
export const LUA_INCLUDE_LINE = '#include main.lua';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Builds the scaffolded `main.p8` via the cart model. `__lua__` holds only the
|
|
27
|
+
* `#include`; the cart carries no binary sections yet (a fresh cart has no gfx /
|
|
28
|
+
* map / sfx / music); those are added later by the gfx/audio commands.
|
|
29
|
+
*/
|
|
30
|
+
export function mainP8(): string {
|
|
31
|
+
const cart = Cart.parse(
|
|
32
|
+
`pico-8 cartridge // http://www.pico-8.com\nversion ${CART_VERSION}\n__lua__\n`,
|
|
33
|
+
);
|
|
34
|
+
cart.setSection('lua', `${LUA_INCLUDE_LINE}\n`);
|
|
35
|
+
return cart.serialize();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The starter `main.lua`: a minimal but RUNNABLE cart (the `_init/_update/_draw`
|
|
40
|
+
* loop the API reference calls out as load-bearing) so a fresh folder boots
|
|
41
|
+
* instead of being inert. Intentionally tiny: it is a starting point the agent
|
|
42
|
+
* replaces, not a demo.
|
|
43
|
+
*/
|
|
44
|
+
export function mainLua(): string {
|
|
45
|
+
return `-- main.lua, the plain-Lua source picopilot's main.p8 #includes.
|
|
46
|
+
-- Edit THIS file; never hand-write main.p8's binary sections.
|
|
47
|
+
|
|
48
|
+
function _init()
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
function _update()
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
function _draw()
|
|
55
|
+
cls()
|
|
56
|
+
print("hello picopilot", 36, 60, 7)
|
|
57
|
+
end
|
|
58
|
+
`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The scaffolded `picopilot.json` (incur config: precedence argv > config > defaults). */
|
|
62
|
+
export function picopilotJson(): string {
|
|
63
|
+
return defaultConfigFile();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The curated PICO-8 reference written to `AGENTS.md`.
|
|
68
|
+
*
|
|
69
|
+
* This is CONTEXT an agent loads every turn, so it is CURATED from
|
|
70
|
+
* `work/notes/findings/pico8-api-reference.md`, not a dump of the manual: the
|
|
71
|
+
* hard specs, the `_init/_update/_draw` loop, the fixed palette, the memory map
|
|
72
|
+
* + the gfx/map overlap WARNING, the grouped most-used API, and the token
|
|
73
|
+
* discipline, the things models get wrong without a reference.
|
|
74
|
+
*/
|
|
75
|
+
export function agentsMd(): string {
|
|
76
|
+
return `# PICO-8 cart context (generated by \`picopilot init\`)
|
|
77
|
+
|
|
78
|
+
You are editing a PICO-8 cart with picopilot. This file is your always-loaded
|
|
79
|
+
reference: the constraints, the loop, the palette, the memory map (+ the overlap
|
|
80
|
+
warning), the most-used API, and how to stay under the token budget. It is
|
|
81
|
+
curated from PICO-8's manual for the things models get wrong without a reminder.
|
|
82
|
+
|
|
83
|
+
## The \`#include\` discipline (how this cart is laid out)
|
|
84
|
+
|
|
85
|
+
- \`main.p8\` is the cart. Its \`__lua__\` section is a single line: \`#include main.lua\`.
|
|
86
|
+
- \`main.lua\` is the plain Lua you EDIT. Never hand-write \`main.p8\`'s binary
|
|
87
|
+
sections (\`__gfx__\`, \`__map__\`, \`__sfx__\`, \`__music__\`), use picopilot's
|
|
88
|
+
\`gfx\` / audio commands, which edit them safely and round-trippably.
|
|
89
|
+
|
|
90
|
+
## Hard specs (the constraints that shape everything)
|
|
91
|
+
|
|
92
|
+
- Display: **128x128**, a **fixed 16-colour palette** (indices 0..15).
|
|
93
|
+
- Code: **Lua**, max **8192 tokens** (the #1 thing to watch). Run \`picopilot tokens\`.
|
|
94
|
+
- Sprites: 256 8x8 sprites in a base bank (0..127) plus a shared bank (128..255).
|
|
95
|
+
- Map: 128x32 tiles + a shared half that ALIASES sprites 128..255 (see below).
|
|
96
|
+
- Sound: 4 channels, 64 SFX slots; music is patterns of up to 4 SFX references.
|
|
97
|
+
- Numbers: 16.16 fixed point, range \`-32768.0 .. 32767.99\`.
|
|
98
|
+
|
|
99
|
+
## The execution loop (every cart)
|
|
100
|
+
|
|
101
|
+
- \`_init()\`: runs once at startup.
|
|
102
|
+
- \`_update()\`: runs every update tick at 30fps (\`_update60()\` for 60fps).
|
|
103
|
+
- \`_draw()\`: runs once per visible frame.
|
|
104
|
+
|
|
105
|
+
A cart with none of these is inert. Keep them defined.
|
|
106
|
+
|
|
107
|
+
## The fixed 16-colour palette (index → RGB)
|
|
108
|
+
|
|
109
|
+
In a char grid, \`.\` = transparent and \`0-F\` = these indices.
|
|
110
|
+
|
|
111
|
+
| idx | hex | name | R | G | B |
|
|
112
|
+
|-----|-----|-------------|-----|-----|-----|
|
|
113
|
+
| 0 | 0 | black | 0 | 0 | 0 |
|
|
114
|
+
| 1 | 1 | dark-blue | 29 | 43 | 83 |
|
|
115
|
+
| 2 | 2 | dark-purple | 126 | 37 | 83 |
|
|
116
|
+
| 3 | 3 | dark-green | 0 | 135 | 81 |
|
|
117
|
+
| 4 | 4 | brown | 171 | 82 | 54 |
|
|
118
|
+
| 5 | 5 | dark-grey | 95 | 87 | 79 |
|
|
119
|
+
| 6 | 6 | light-grey | 194 | 195 | 199 |
|
|
120
|
+
| 7 | 7 | white | 255 | 241 | 232 |
|
|
121
|
+
| 8 | 8 | red | 255 | 0 | 77 |
|
|
122
|
+
| 9 | 9 | orange | 255 | 163 | 0 |
|
|
123
|
+
| 10 | a | yellow | 255 | 236 | 39 |
|
|
124
|
+
| 11 | b | green | 0 | 228 | 54 |
|
|
125
|
+
| 12 | c | blue | 41 | 173 | 255 |
|
|
126
|
+
| 13 | d | lavender | 131 | 118 | 156 |
|
|
127
|
+
| 14 | e | pink | 255 | 119 | 168 |
|
|
128
|
+
| 15 | f | peach | 255 | 204 | 170 |
|
|
129
|
+
|
|
130
|
+
## Memory map + the gfx/map overlap warning
|
|
131
|
+
|
|
132
|
+
\`\`\`
|
|
133
|
+
0x0000 gfx (spritesheet, sprites 0..127)
|
|
134
|
+
0x1000 shared gfx / map2 <-- sprites 128..255 ALIAS the bottom half of the map
|
|
135
|
+
0x2000 map
|
|
136
|
+
0x3000 gfx flags
|
|
137
|
+
0x3100 music
|
|
138
|
+
0x3200 sfx
|
|
139
|
+
0x4300 user data
|
|
140
|
+
0x5e00 persistent cart data (256 bytes)
|
|
141
|
+
0x5f00 draw state
|
|
142
|
+
0x5f40 hardware state
|
|
143
|
+
0x5f80 gpio pins
|
|
144
|
+
0x6000 screen (8k)
|
|
145
|
+
\`\`\`
|
|
146
|
+
|
|
147
|
+
**Overlap warning:** sprites 128..255 (\`0x1000\`-\`0x1fff\`) occupy the SAME memory
|
|
148
|
+
as \`__map__\` rows 32..63. Writing a sprite in that range clobbers the map tiles
|
|
149
|
+
there. \`picopilot gfx set\` smart-refuses such a write when real map tiles are at
|
|
150
|
+
risk and nothing authorised the loss (\`--allow-map-overlap\` or \`allowMapOverlap\`
|
|
151
|
+
in \`picopilot.json\`), so you never silently destroy map data you cannot see.
|
|
152
|
+
|
|
153
|
+
## Most-used API (grouped)
|
|
154
|
+
|
|
155
|
+
- **Graphics:** \`cls([col])\`, \`spr(n,x,y,[w,h],[fx],[fy])\`, \`sspr(...)\`,
|
|
156
|
+
\`map(cx,cy,sx,sy,cw,ch,[layer])\`, \`pset/pget\`, \`sget/sset\`,
|
|
157
|
+
\`print(str,[x,y,[col]])\`, \`rect/rectfill\`, \`circ/circfill\`, \`oval/ovalfill\`,
|
|
158
|
+
\`line\`, \`pal(c0,c1,[p])\`, \`palt(col,t)\`, \`camera([x,y])\`, \`clip([x,y,w,h])\`,
|
|
159
|
+
\`color(col)\`, \`fillp(mask)\`, \`tline(...)\`, \`flip()\`.
|
|
160
|
+
- **Map / flags:** \`mget(x,y)\`, \`mset(x,y,v)\`, \`fget(n,[f])\`, \`fset(n,[f],v)\`.
|
|
161
|
+
- **Input:** \`btn([i,[p]])\`, \`btnp([i,[p]])\` (buttons 0..5 = left,right,up,down,O/Z,X).
|
|
162
|
+
- **Audio:** \`sfx(n,[channel,[offset]])\`, \`music([n,[fade,[mask]]])\`.
|
|
163
|
+
- **Math:** \`abs\`, \`flr\` (\`-flr(-x)\` = ceil), \`min\`, \`max\`, \`mid\`, \`rnd(x)\`,
|
|
164
|
+
\`srand(x)\`, \`sgn\`, \`sqrt\`, \`sin(x)\`/\`cos(x)\` (range 0..1, sin inverted),
|
|
165
|
+
\`atan2(x,y)\`.
|
|
166
|
+
- **Tables:** \`add\`, \`del\`, \`deli\`, \`count\`, \`all(t)\`, \`foreach(t,f)\`, \`pairs\`, \`ipairs\`.
|
|
167
|
+
- **Memory:** \`peek\`, \`poke\`, \`memcpy\`, \`memset\`, \`cstore\`, \`reload\`.
|
|
168
|
+
- **Cart data:** \`cartdata(id)\`, \`dget(i)\`, \`dset(i,v)\`.
|
|
169
|
+
- **Debug:** \`printh(str)\` (to host stdout, \`picopilot run\` captures it), \`stat(n)\`.
|
|
170
|
+
|
|
171
|
+
## Gotchas that silently break carts (these differ from normal Lua/math)
|
|
172
|
+
|
|
173
|
+
These compile and run but behave WRONG. They are the traps models hit most:
|
|
174
|
+
|
|
175
|
+
- \`sin(t)\`/\`cos(t)\` take TURNS (0..1), NOT radians, and \`sin\` is INVERTED for
|
|
176
|
+
screen space (+y is down). A full circle is \`t: 0->1\`. Do not multiply by
|
|
177
|
+
\`2*pi\`; do not re-negate \`sin\`.
|
|
178
|
+
- \`/\` does NOT truncate (\`5/2\`=\`2.5\`). Integer divide is \`\\\` (\`5\\2\`=\`2\`), which
|
|
179
|
+
floors toward negative infinity (\`-5\\2\`=\`-3\`).
|
|
180
|
+
- \`rnd(x)\` returns a FLOAT in [0,x). Use \`flr(rnd(x))\` for an integer;
|
|
181
|
+
\`rnd(table)\` returns a random element.
|
|
182
|
+
- Tables are 1-BASED. \`del(t,val)\` removes by VALUE, \`deli(t,i)\` by INDEX.
|
|
183
|
+
Deleting the current item inside \`foreach\` is safe; inside an ascending
|
|
184
|
+
\`for i=1,#t\` it ERRORS once the list shrinks (iterate descending, or foreach).
|
|
185
|
+
- Draw state PERSISTS across frames: \`camera\`, \`pal\`, \`palt\`, \`clip\`, \`color\`,
|
|
186
|
+
\`fillp\` stay set until reset (no-arg form: \`camera()\`, \`pal()\`). Reset before
|
|
187
|
+
the HUD or offsets/palette swaps leak.
|
|
188
|
+
- Numbers are 16.16 fixed point; overflow WRAPS at ~32767. Drawing coords are
|
|
189
|
+
floored to integer pixels (keep true position as a float, let draw floor it).
|
|
190
|
+
- Variables are GLOBAL unless \`local\`. The \`x = cond and a or b\` ternary returns
|
|
191
|
+
\`b\` when \`a\` is falsy (\`false\`/\`nil\`); use an explicit \`if\` then.
|
|
192
|
+
|
|
193
|
+
(The \`picopilot-code\` skill expands these, and carries per-genre code references:
|
|
194
|
+
platformer / puzzle-grid / twin-stick-arcade / top-down-adventure and more.)
|
|
195
|
+
|
|
196
|
+
## Token discipline (stay under 8192)
|
|
197
|
+
|
|
198
|
+
Models blow the budget by writing verbose Lua. Prefer PICO-8 shorthands:
|
|
199
|
+
|
|
200
|
+
- \`?\` for \`print\`.
|
|
201
|
+
- inline if: \`if(c) x=1\`.
|
|
202
|
+
- compound assignment: \`+=\`, \`-=\`, \`*=\`, \`/=\`.
|
|
203
|
+
- \`\\\` for integer divide.
|
|
204
|
+
- reuse locals; avoid needless globals.
|
|
205
|
+
|
|
206
|
+
Run \`picopilot tokens\` each iteration to see the count and where it goes; when
|
|
207
|
+
over budget, \`picopilot minify\` (safe-only) reclaims tokens without changing
|
|
208
|
+
behaviour.
|
|
209
|
+
`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* One scaffolded file: its cart-relative name and its exact bytes. `init`
|
|
214
|
+
* iterates these, so adding a file is a one-line change here and the tests
|
|
215
|
+
* assert against this list (the "exactly these files" contract).
|
|
216
|
+
*/
|
|
217
|
+
export interface ScaffoldFile {
|
|
218
|
+
readonly name: string;
|
|
219
|
+
readonly content: string;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The complete set of files `picopilot init` writes into a fresh cart folder,
|
|
224
|
+
* in a stable order: the cart, its editable Lua, the agent-context reference,
|
|
225
|
+
* and the config. Exactly FOUR files (the acceptance contract). `AGENTS.md` is
|
|
226
|
+
* the cross-agent context filename; Claude Code, Codex, and others read it, so
|
|
227
|
+
* one portable file beats duplicating identical content into `CLAUDE.md`.
|
|
228
|
+
*/
|
|
229
|
+
export function scaffoldFiles(): ScaffoldFile[] {
|
|
230
|
+
return [
|
|
231
|
+
{name: 'main.p8', content: mainP8()},
|
|
232
|
+
{name: 'main.lua', content: mainLua()},
|
|
233
|
+
{name: 'AGENTS.md', content: agentsMd()},
|
|
234
|
+
{name: 'picopilot.json', content: picopilotJson()},
|
|
235
|
+
];
|
|
236
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shrinko adapter SEAM: a typed interface every shrinko-backed command
|
|
3
|
+
* (`tokens`, and later `lint`, `minify`, `gfx import/export`, `verify`) talks
|
|
4
|
+
* to, plus the discriminated result shape they all return.
|
|
5
|
+
*
|
|
6
|
+
* The seam exists so the command layer never knows HOW shrinko is reached. The
|
|
7
|
+
* v1 implementation shells out to a user-installed Python `shrinko8` (see
|
|
8
|
+
* `shell.ts` and ADR-0001); a future native-TS implementation can implement the
|
|
9
|
+
* SAME {@link ShrinkoAdapter} interface and drop in without touching a single
|
|
10
|
+
* command. The command-facing contract is entirely in this file.
|
|
11
|
+
*
|
|
12
|
+
* The two-tier capability contract (ADR-0002) lives in the RESULT type: every
|
|
13
|
+
* adapter method returns a {@link ShrinkoResult}, a discriminated union of a
|
|
14
|
+
* success value OR the structured {@link ShrinkoNotFound} failure. Absent
|
|
15
|
+
* shrinko is therefore NOT an exception a command must try/catch, it is a
|
|
16
|
+
* first-class `{ok:false, reason:'shrinko-not-found', ...}` value a command maps
|
|
17
|
+
* onto incur's error envelope with a nonzero exit. Never a crash, never a hollow
|
|
18
|
+
* success.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** The PyPI package name in the remedy: `uv pip install shrinko` (NOT `shrinko8`). */
|
|
22
|
+
export const SHRINKO_REMEDY = 'uv pip install shrinko' as const;
|
|
23
|
+
|
|
24
|
+
/** What shrinko needs to run: Python 3.8+ (the module is `shrinko8`). */
|
|
25
|
+
export const SHRINKO_NEEDS = ['python>=3.8'] as const;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The structured "shrinko is not installed" failure (ADR-0002). Its shape is
|
|
29
|
+
* fixed by the prd's two-tier contract (US #17): `ok:false`, a machine-readable
|
|
30
|
+
* `reason`, the EXACT `remedy` string, and the `needs` list. A command turns
|
|
31
|
+
* this into an incur `error({ code: reason, ... })` with a nonzero exit.
|
|
32
|
+
*/
|
|
33
|
+
export interface ShrinkoNotFound {
|
|
34
|
+
readonly ok: false;
|
|
35
|
+
readonly reason: 'shrinko-not-found';
|
|
36
|
+
/** The EXACT remedy: `uv pip install shrinko` (package is `shrinko`, module `shrinko8`). */
|
|
37
|
+
readonly remedy: typeof SHRINKO_REMEDY;
|
|
38
|
+
/** Prerequisites the remedy assumes are present. */
|
|
39
|
+
readonly needs: readonly string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A successful adapter call carrying the parsed value. */
|
|
43
|
+
export interface ShrinkoOk<T> {
|
|
44
|
+
readonly ok: true;
|
|
45
|
+
readonly value: T;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The result every {@link ShrinkoAdapter} method returns: either a parsed value
|
|
50
|
+
* or the structured {@link ShrinkoNotFound}. Absence is a value, not a throw, so
|
|
51
|
+
* the two-tier contract is total and type-checked at every call site.
|
|
52
|
+
*/
|
|
53
|
+
export type ShrinkoResult<T> = ShrinkoOk<T> | ShrinkoNotFound;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The parsed `--count` report (US #3). `tokens`/`chars`/`compressed` are the
|
|
57
|
+
* raw counts shrinko prints; `pct` fields are shrinko's own reported
|
|
58
|
+
* percentages (its `--count` prints `tokens: 8053 98%`, i.e. against PICO-8's
|
|
59
|
+
* limits), preserved verbatim so a command can show them without recomputing.
|
|
60
|
+
* `compressed`/`compressedPct` are optional because shrinko omits the
|
|
61
|
+
* compressed line for input formats where it does not apply.
|
|
62
|
+
*/
|
|
63
|
+
export interface CountReport {
|
|
64
|
+
readonly tokens: number;
|
|
65
|
+
readonly tokensPct: number;
|
|
66
|
+
readonly chars: number;
|
|
67
|
+
readonly charsPct: number;
|
|
68
|
+
readonly compressed: number | undefined;
|
|
69
|
+
readonly compressedPct: number | undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The shrinko capability seam. One method per shrinko-backed operation; only
|
|
74
|
+
* `count` exists in this task, the rest (lint, minify, convert) land with their
|
|
75
|
+
* commands and extend THIS interface rather than forking a new one.
|
|
76
|
+
*
|
|
77
|
+
* Every method is async (the shell-out is async) and returns a
|
|
78
|
+
* {@link ShrinkoResult}, so absence is handled uniformly at each seam.
|
|
79
|
+
*/
|
|
80
|
+
export interface ShrinkoAdapter {
|
|
81
|
+
/**
|
|
82
|
+
* Runs shrinko's `--count` on the cart at `cartPath` and parses the
|
|
83
|
+
* `tokens/chars/compressed` report. Returns {@link ShrinkoNotFound} when
|
|
84
|
+
* shrinko is not installed.
|
|
85
|
+
*/
|
|
86
|
+
count(cartPath: string): Promise<ShrinkoResult<CountReport>>;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Runs shrinko's `--lint` on the cart at `cartPath` and parses the warnings
|
|
90
|
+
* (undefined globals, unused/duplicate locals, etc.) into structured
|
|
91
|
+
* {@link LintFinding}s. Lint warnings are DATA, not a failure: the report is
|
|
92
|
+
* returned with `findings` (possibly empty), not a throw. Returns
|
|
93
|
+
* {@link ShrinkoNotFound} when shrinko is not installed.
|
|
94
|
+
*/
|
|
95
|
+
lint(cartPath: string): Promise<ShrinkoResult<LintReport>>;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Runs shrinko's SAFE minification on `cartPath` and writes the result to
|
|
99
|
+
* `outPath` (a SEPARATE artifact; the source is never touched, see ADR-0007).
|
|
100
|
+
* Returns the before/after token counts. Returns {@link ShrinkoNotFound} when
|
|
101
|
+
* shrinko is not installed.
|
|
102
|
+
*/
|
|
103
|
+
minify(
|
|
104
|
+
cartPath: string,
|
|
105
|
+
outPath: string,
|
|
106
|
+
): Promise<ShrinkoResult<MinifyReport>>;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Exports the cart's spritesheet as a 128x128 PNG to `pngPath` (a raw
|
|
110
|
+
* round-trip image for external tools, distinct from `gfx render`'s upscaled
|
|
111
|
+
* JUDGE image). Returns {@link ShrinkoNotFound} when shrinko is not installed.
|
|
112
|
+
*/
|
|
113
|
+
exportSpritesheet(
|
|
114
|
+
cartPath: string,
|
|
115
|
+
pngPath: string,
|
|
116
|
+
): Promise<ShrinkoResult<void>>;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Imports a 128x128 spritesheet PNG into `srcCartPath`'s `__gfx__`, MERGING
|
|
120
|
+
* (only the gfx section is replaced; code/map/sfx/music are preserved) and
|
|
121
|
+
* writing the result to `outCartPath`. Returns {@link ShrinkoNotFound} when
|
|
122
|
+
* shrinko is not installed.
|
|
123
|
+
*/
|
|
124
|
+
importSpritesheet(
|
|
125
|
+
srcCartPath: string,
|
|
126
|
+
pngPath: string,
|
|
127
|
+
outCartPath: string,
|
|
128
|
+
): Promise<ShrinkoResult<void>>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** One shrinko lint warning, parsed from a `file:line:col: message` line. */
|
|
132
|
+
export interface LintFinding {
|
|
133
|
+
readonly line: number;
|
|
134
|
+
readonly col: number;
|
|
135
|
+
readonly message: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The parsed `--lint` result: every warning shrinko reported (empty when clean). */
|
|
139
|
+
export interface LintReport {
|
|
140
|
+
readonly findings: readonly LintFinding[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The parsed `minify` result: token counts before and after (ADR-0007's delta report). */
|
|
144
|
+
export interface MinifyReport {
|
|
145
|
+
/** Tokens in the source cart. */
|
|
146
|
+
readonly beforeTokens: number;
|
|
147
|
+
/** Tokens in the minified artifact. */
|
|
148
|
+
readonly afterTokens: number;
|
|
149
|
+
/** Absolute path of the written minified cart. */
|
|
150
|
+
readonly outPath: string;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The frozen {@link ShrinkoNotFound} value, so every call site returns the same shape. */
|
|
154
|
+
export function shrinkoNotFound(): ShrinkoNotFound {
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
reason: 'shrinko-not-found',
|
|
158
|
+
remedy: SHRINKO_REMEDY,
|
|
159
|
+
needs: [...SHRINKO_NEEDS],
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Parses shrinko's `--count` output into a {@link CountReport}.
|
|
165
|
+
*
|
|
166
|
+
* shrinko prints lines like:
|
|
167
|
+
*
|
|
168
|
+
* ```
|
|
169
|
+
* tokens: 8053 98%
|
|
170
|
+
* chars: 30320 46%
|
|
171
|
+
* compressed: 12176 77%
|
|
172
|
+
* ```
|
|
173
|
+
*
|
|
174
|
+
* (the compressed line is absent for some input formats). Parsing is
|
|
175
|
+
* line-oriented and tolerant of surrounding output/whitespace: it scans for the
|
|
176
|
+
* `tokens:`/`chars:`/`compressed:` labels anywhere in the combined stdout+stderr
|
|
177
|
+
* and pulls the count and percentage off each. Throws if the required
|
|
178
|
+
* `tokens:`/`chars:` lines are missing, so a shrinko that ran but produced
|
|
179
|
+
* unparseable output surfaces as a real error rather than a silent zero.
|
|
180
|
+
*/
|
|
181
|
+
export function parseCount(output: string): CountReport {
|
|
182
|
+
const line = (label: string): {n: number; pct: number} | undefined => {
|
|
183
|
+
// `label: <count> <pct>%` (count and percent may be separated by any run
|
|
184
|
+
// of spaces; the `%` is optional-tolerant but shrinko always prints it).
|
|
185
|
+
const re = new RegExp(`^\\s*${label}:\\s*(\\d+)\\s+(\\d+)\\s*%`, 'm');
|
|
186
|
+
const m = re.exec(output);
|
|
187
|
+
if (m === null) return undefined;
|
|
188
|
+
return {n: Number(m[1]), pct: Number(m[2])};
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const tokens = line('tokens');
|
|
192
|
+
const chars = line('chars');
|
|
193
|
+
if (tokens === undefined || chars === undefined) {
|
|
194
|
+
throw new ShrinkoParseError(
|
|
195
|
+
`could not parse shrinko --count output (missing tokens/chars lines):\n${output}`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
const compressed = line('compressed');
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
tokens: tokens.n,
|
|
202
|
+
tokensPct: tokens.pct,
|
|
203
|
+
chars: chars.n,
|
|
204
|
+
charsPct: chars.pct,
|
|
205
|
+
compressed: compressed?.n,
|
|
206
|
+
compressedPct: compressed?.pct,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Parses shrinko's `--lint` output into {@link LintFinding}s. shrinko prints a
|
|
212
|
+
* `Lint warnings:` header then one `file:line:col: message` per warning; a clean
|
|
213
|
+
* cart prints nothing. Parsing scans the combined stdout+stderr for the
|
|
214
|
+
* `:line:col: message` shape (tolerant of the filename prefix, which we drop),
|
|
215
|
+
* so an empty result is a CLEAN cart, not an error. Unlike parseCount this never
|
|
216
|
+
* throws: no recognisable lines simply means zero findings.
|
|
217
|
+
*/
|
|
218
|
+
export function parseLint(output: string): LintReport {
|
|
219
|
+
const findings: LintFinding[] = [];
|
|
220
|
+
// Match `<anything>:<line>:<col>: <message>` (the filename may itself contain
|
|
221
|
+
// colons/paths, so anchor on the two trailing numeric groups before the msg).
|
|
222
|
+
const re = /:(\d+):(\d+):\s*(.+?)\s*$/gm;
|
|
223
|
+
for (const m of output.matchAll(re)) {
|
|
224
|
+
findings.push({
|
|
225
|
+
line: Number(m[1]),
|
|
226
|
+
col: Number(m[2]),
|
|
227
|
+
message: m[3] ?? '',
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
return {findings};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Thrown when shrinko RAN but its output could not be parsed (a shrinko
|
|
235
|
+
* version/format drift), distinct from shrinko being ABSENT (which is the
|
|
236
|
+
* structured {@link ShrinkoNotFound} value, not a throw). Commands surface this
|
|
237
|
+
* as a `shrinko-failed` error, separate from `shrinko-not-found`.
|
|
238
|
+
*/
|
|
239
|
+
export class ShrinkoParseError extends Error {
|
|
240
|
+
override readonly name = 'ShrinkoParseError';
|
|
241
|
+
}
|