picopilot 0.0.0 → 0.1.1
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 +224 -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 +259 -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 +482 -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 +264 -0
- package/src/commands/run.ts +285 -0
- package/src/commands/serve.test.ts +227 -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 +278 -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,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The RESUMABLE playtest SESSION handshake core (ADR-0011, prd US #6): the pure,
|
|
3
|
+
* CI-testable half of the "A" live model. It keeps a single driven PICO-8 process
|
|
4
|
+
* ALIVE and PAUSED between the agent's turns and drives it one command at a time
|
|
5
|
+
* over the verified transport: write a FIXED-SIZE command block, WAIT for the
|
|
6
|
+
* cart's ACK line on stdout, then return. The handshake (not wall-clock spacing)
|
|
7
|
+
* is what makes stepping deterministic and lossless, so the agent always acts on
|
|
8
|
+
* a SETTLED, known frame.
|
|
9
|
+
*
|
|
10
|
+
* This module owns ONLY the send -> wait-ack -> return logic against the
|
|
11
|
+
* {@link Pico8Process} seam (the same seam the one-shot `drive` uses), so every
|
|
12
|
+
* path (a completed step, a lost/late ack, the process dying mid-command) is
|
|
13
|
+
* unit-tested against a FAKE process WITHOUT the paid binary. The daemon that
|
|
14
|
+
* spawns the real process and exposes the session to separate CLI invocations by
|
|
15
|
+
* id lives in `supervisor.ts`; a live multi-turn session is the manual/opt-in
|
|
16
|
+
* tier. The cart-side transform is reused UNCHANGED from `drive.ts`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {ACK, BLOCK_SIZE, encodeCommand} from './drive.js';
|
|
20
|
+
import type {Pico8Process} from './shell.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A structured reason a session command could not complete (a boundary, not a
|
|
24
|
+
* crash). Surfaced by the supervisor/command as incur's `error({code,...})`.
|
|
25
|
+
*/
|
|
26
|
+
export type SessionErrorCode =
|
|
27
|
+
/** No ACK arrived within the deadline (the lost/late-ack path). */
|
|
28
|
+
| 'playtest-session-ack-timeout'
|
|
29
|
+
/** PICO-8 exited (crashed/quit) while a command was in flight or between them. */
|
|
30
|
+
| 'playtest-session-process-dead'
|
|
31
|
+
/** A command was issued after the session was already stopped/closed. */
|
|
32
|
+
| 'playtest-session-closed';
|
|
33
|
+
|
|
34
|
+
/** A structured session-command failure (never a raw throw across the seam). */
|
|
35
|
+
export class SessionError extends Error {
|
|
36
|
+
readonly code: SessionErrorCode;
|
|
37
|
+
constructor(code: SessionErrorCode, message: string) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = 'SessionError';
|
|
40
|
+
this.code = code;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* How long a single command waits for its ACK before it is declared lost (the
|
|
46
|
+
* lost/late-ack path). Generous: a STEP of many frames takes real time at 30fps,
|
|
47
|
+
* plus PICO-8 scheduling jitter, but a hung/dead cart must not wall the session
|
|
48
|
+
* forever. The supervisor's process-death signal (see {@link DriveSession}) ends
|
|
49
|
+
* a command EARLY when the process dies, so this only fires for a truly stuck
|
|
50
|
+
* cart.
|
|
51
|
+
*/
|
|
52
|
+
export const DEFAULT_ACK_TIMEOUT_MS = 15_000;
|
|
53
|
+
|
|
54
|
+
/** The result of one completed session command (the structured per-verb envelope). */
|
|
55
|
+
export interface StepResult {
|
|
56
|
+
/** The total frames advanced across the session's lifetime so far. */
|
|
57
|
+
readonly frame: number;
|
|
58
|
+
/** The ACK line the cart printed for this command (the settled-frame proof). */
|
|
59
|
+
readonly ack: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The result of a `shot` command: the frame + the basename the cart named it. */
|
|
63
|
+
export interface ShotResult extends StepResult {
|
|
64
|
+
/** The screenshot basename the cart queued (`<basename><n>`), for collection. */
|
|
65
|
+
readonly shotName: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A stateful line reader over a PICO-8 stdout stream: the cart's `printh` ACKs
|
|
70
|
+
* and the done-sentinel land line-by-line, but the OS delivers stdout in
|
|
71
|
+
* arbitrary chunks. This buffers partial lines and lets a waiter await the NEXT
|
|
72
|
+
* line matching a predicate, so the handshake is exact (an ACK is a whole line,
|
|
73
|
+
* never a substring of unrelated output). Mirrors `SentinelWatcher`'s line
|
|
74
|
+
* discipline; kept separate because the session awaits a SPECIFIC ack, not a
|
|
75
|
+
* single terminal sentinel.
|
|
76
|
+
*/
|
|
77
|
+
class AckLineReader {
|
|
78
|
+
private buffer = '';
|
|
79
|
+
private readonly pending: {
|
|
80
|
+
match: (line: string) => boolean;
|
|
81
|
+
resolve: (line: string) => void;
|
|
82
|
+
}[] = [];
|
|
83
|
+
/** Lines seen but not yet consumed by a waiter (an ACK that raced ahead). */
|
|
84
|
+
private readonly queued: string[] = [];
|
|
85
|
+
|
|
86
|
+
/** Feeds a raw stdout chunk; resolves any waiter its lines satisfy. */
|
|
87
|
+
push(chunk: string): void {
|
|
88
|
+
this.buffer += chunk;
|
|
89
|
+
let nl: number;
|
|
90
|
+
while ((nl = this.buffer.indexOf('\n')) !== -1) {
|
|
91
|
+
let line = this.buffer.slice(0, nl);
|
|
92
|
+
if (line.endsWith('\r')) line = line.slice(0, -1);
|
|
93
|
+
this.buffer = this.buffer.slice(nl + 1);
|
|
94
|
+
this.deliver(line);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private deliver(line: string): void {
|
|
99
|
+
// A waiter already parked for this line consumes it; else queue it so a
|
|
100
|
+
// waiter that parks AFTER the cart printed can still find it.
|
|
101
|
+
const idx = this.pending.findIndex((p) => p.match(line));
|
|
102
|
+
if (idx !== -1) {
|
|
103
|
+
const [waiter] = this.pending.splice(idx, 1);
|
|
104
|
+
waiter!.resolve(line);
|
|
105
|
+
} else {
|
|
106
|
+
this.queued.push(line);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Awaits the next line satisfying `match`. If such a line was already seen
|
|
112
|
+
* (queued) it resolves synchronously-soon; otherwise it parks until one
|
|
113
|
+
* arrives. The caller races this against a timeout / process-death signal.
|
|
114
|
+
*/
|
|
115
|
+
waitFor(match: (line: string) => boolean): Promise<string> {
|
|
116
|
+
const idx = this.queued.findIndex(match);
|
|
117
|
+
if (idx !== -1) {
|
|
118
|
+
const [line] = this.queued.splice(idx, 1);
|
|
119
|
+
return Promise.resolve(line!);
|
|
120
|
+
}
|
|
121
|
+
return new Promise<string>((resolve) => {
|
|
122
|
+
this.pending.push({match, resolve});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Options for a {@link DriveSession}. */
|
|
128
|
+
export interface DriveSessionOptions {
|
|
129
|
+
/** The already-spawned live driven process (owned by the caller/supervisor). */
|
|
130
|
+
readonly process: Pico8Process;
|
|
131
|
+
/** Per-command ACK deadline (defaults to {@link DEFAULT_ACK_TIMEOUT_MS}). */
|
|
132
|
+
readonly ackTimeoutMs?: number;
|
|
133
|
+
/**
|
|
134
|
+
* The screenshot basename the driven cart's SHOT names each capture (matches
|
|
135
|
+
* the transform's `shotBasename`); the session reports `<basename><n>`.
|
|
136
|
+
*/
|
|
137
|
+
readonly shotBasename: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The live-session handshake engine (ADR-0011, US #6). Wraps ONE driven
|
|
142
|
+
* {@link Pico8Process} and exposes the agent's verbs: {@link input} (set the
|
|
143
|
+
* held-buttons byte), {@link step} (advance exactly N frames), {@link shot}
|
|
144
|
+
* (capture the frozen current frame), {@link stop} (tear the session down). Each
|
|
145
|
+
* verb WRITES a single fixed-size command block and AWAITS the cart's ACK before
|
|
146
|
+
* resolving, so the process stays alive + paused between verbs and the caller
|
|
147
|
+
* always acts on a settled frame. Between the ACK and the next verb the game is
|
|
148
|
+
* frozen (its callbacks skipped, a stable framebuffer), so a {@link shot} always
|
|
149
|
+
* captures exactly the intended moment.
|
|
150
|
+
*
|
|
151
|
+
* The engine is transport-agnostic: it talks to the {@link Pico8Process} seam,
|
|
152
|
+
* so the send/wait/return + lost-ack + process-death paths are unit-tested
|
|
153
|
+
* against a FAKE process with no real binary. The supervisor supplies a REAL
|
|
154
|
+
* spawned process for a live session (the manual/opt-in tier).
|
|
155
|
+
*/
|
|
156
|
+
export class DriveSession {
|
|
157
|
+
private readonly proc: Pico8Process;
|
|
158
|
+
private readonly reader = new AckLineReader();
|
|
159
|
+
private readonly ackTimeoutMs: number;
|
|
160
|
+
private readonly shotBasename: string;
|
|
161
|
+
private frames = 0;
|
|
162
|
+
private shotIndex = 0;
|
|
163
|
+
/** Set once the process dies; every in-flight + future command rejects. */
|
|
164
|
+
private dead = false;
|
|
165
|
+
private closed = false;
|
|
166
|
+
private readonly deathWaiters: (() => void)[] = [];
|
|
167
|
+
|
|
168
|
+
constructor(options: DriveSessionOptions) {
|
|
169
|
+
this.proc = options.process;
|
|
170
|
+
this.ackTimeoutMs = options.ackTimeoutMs ?? DEFAULT_ACK_TIMEOUT_MS;
|
|
171
|
+
this.shotBasename = options.shotBasename;
|
|
172
|
+
this.proc.onStdout((chunk) => this.reader.push(chunk));
|
|
173
|
+
this.proc.onClose(() => this.markDead());
|
|
174
|
+
this.proc.onError(() => this.markDead());
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Total frames the session has advanced so far. */
|
|
178
|
+
get frame(): number {
|
|
179
|
+
return this.frames;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Whether the underlying process has died (crashed/quit). */
|
|
183
|
+
get isDead(): boolean {
|
|
184
|
+
return this.dead;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private markDead(): void {
|
|
188
|
+
if (this.dead) return;
|
|
189
|
+
this.dead = true;
|
|
190
|
+
for (const w of this.deathWaiters.splice(0)) w();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Sets the per-frame HELD-buttons byte for the upcoming steps (US #6: inject
|
|
195
|
+
* input between turns). Writes an INPUT block and awaits its ACK. The held
|
|
196
|
+
* level persists until the next INPUT (the cart holds it), and `btnp` edges
|
|
197
|
+
* are reconstructed cart-side from the held sequence.
|
|
198
|
+
*/
|
|
199
|
+
async input(held: number): Promise<StepResult> {
|
|
200
|
+
await this.send({op: 'input', held: held & 0xff}, ACK.input);
|
|
201
|
+
return {frame: this.frames, ack: ACK.input};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Advances EXACTLY `frames` frames then pauses (US #6, US #4). Writes a STEP
|
|
206
|
+
* block and awaits the STEP-DONE ack, which the cart prints only when the
|
|
207
|
+
* frame budget reaches 0, so the returned result means the N frames ACTUALLY
|
|
208
|
+
* ran and the game is now paused on a settled frame. `frames` must fit one
|
|
209
|
+
* byte (the block's arg); larger spans are issued as repeated steps by the
|
|
210
|
+
* caller.
|
|
211
|
+
*/
|
|
212
|
+
async step(frames: number): Promise<StepResult> {
|
|
213
|
+
if (frames <= 0) return {frame: this.frames, ack: ACK.stepDone};
|
|
214
|
+
await this.send({op: 'step', frames: frames & 0xff}, ACK.stepDone);
|
|
215
|
+
this.frames += frames;
|
|
216
|
+
return {frame: this.frames, ack: ACK.stepDone};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Captures the current (paused, frozen) frame (US #6, US #5). Writes a SHOT
|
|
221
|
+
* block and awaits its ACK; the cart renders the frozen state once then
|
|
222
|
+
* screenshots it, so the capture is exactly the intended moment. Returns the
|
|
223
|
+
* `<basename><n>` the cart named it, which the supervisor collects from the
|
|
224
|
+
* `-desktop` dir.
|
|
225
|
+
*/
|
|
226
|
+
async shot(): Promise<ShotResult> {
|
|
227
|
+
await this.send({op: 'shot'}, ACK.shot);
|
|
228
|
+
const shotName = `${this.shotBasename}${this.shotIndex}`;
|
|
229
|
+
this.shotIndex += 1;
|
|
230
|
+
return {frame: this.frames, ack: ACK.shot, shotName};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Tears the session down: writes a QUIT block (the cart prints the sentinel so
|
|
235
|
+
* the launcher kills PICO-8), then kills the process as the backstop. Does NOT
|
|
236
|
+
* await an ACK (QUIT ends the cart), and is idempotent + safe on a dead
|
|
237
|
+
* process. After `stop`, every verb rejects with `playtest-session-closed`.
|
|
238
|
+
*/
|
|
239
|
+
stop(): void {
|
|
240
|
+
this.closed = true;
|
|
241
|
+
if (!this.dead) {
|
|
242
|
+
try {
|
|
243
|
+
this.proc.writeStdin?.(encodeCommand({op: 'quit'}));
|
|
244
|
+
} catch {
|
|
245
|
+
// A closed stdin (already exiting) is fine; the kill below finishes it.
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
this.proc.kill();
|
|
249
|
+
this.markDead();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The core handshake: write one command block, then race the ACK line against
|
|
254
|
+
* the timeout and the process-death signal. A missing ACK within the deadline
|
|
255
|
+
* is the structured lost/late-ack error; a process that dies mid-command is the
|
|
256
|
+
* structured process-dead error. Never a hang, never a silent success.
|
|
257
|
+
*/
|
|
258
|
+
private async send(
|
|
259
|
+
cmd: Parameters<typeof encodeCommand>[0],
|
|
260
|
+
ackLine: string,
|
|
261
|
+
): Promise<void> {
|
|
262
|
+
if (this.closed) {
|
|
263
|
+
throw new SessionError(
|
|
264
|
+
'playtest-session-closed',
|
|
265
|
+
'the playtest session is stopped; start a new one',
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
if (this.dead) {
|
|
269
|
+
throw new SessionError(
|
|
270
|
+
'playtest-session-process-dead',
|
|
271
|
+
'PICO-8 exited; the playtest session is dead (start a new one)',
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const block = encodeCommand(cmd);
|
|
276
|
+
if (block.length !== BLOCK_SIZE) {
|
|
277
|
+
// Defensive: the transport is fixed-size by construction; a mismatch is a
|
|
278
|
+
// codec bug, surfaced loudly rather than sent as a short (coalescing) write.
|
|
279
|
+
throw new SessionError(
|
|
280
|
+
'playtest-session-closed',
|
|
281
|
+
`encoded command block is ${block.length} bytes, expected ${BLOCK_SIZE}`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Park the ACK waiter BEFORE writing, so an ACK the cart prints promptly is
|
|
286
|
+
// never missed between the write and the await.
|
|
287
|
+
const ackP = this.reader.waitFor((line) => line === ackLine);
|
|
288
|
+
this.proc.writeStdin?.(block);
|
|
289
|
+
|
|
290
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
291
|
+
const timeoutP = new Promise<'timeout'>((resolve) => {
|
|
292
|
+
timer = setTimeout(() => resolve('timeout'), this.ackTimeoutMs);
|
|
293
|
+
});
|
|
294
|
+
const deathP = new Promise<'dead'>((resolve) => {
|
|
295
|
+
if (this.dead) resolve('dead');
|
|
296
|
+
else this.deathWaiters.push(() => resolve('dead'));
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
const outcome = await Promise.race([
|
|
301
|
+
ackP.then(() => 'ack' as const),
|
|
302
|
+
timeoutP,
|
|
303
|
+
deathP,
|
|
304
|
+
]);
|
|
305
|
+
if (outcome === 'timeout') {
|
|
306
|
+
throw new SessionError(
|
|
307
|
+
'playtest-session-ack-timeout',
|
|
308
|
+
`no ACK "${ackLine}" within ${this.ackTimeoutMs}ms (the cart may have hung or faulted)`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
if (outcome === 'dead') {
|
|
312
|
+
throw new SessionError(
|
|
313
|
+
'playtest-session-process-dead',
|
|
314
|
+
`PICO-8 exited before ACK "${ackLine}" (the session is dead)`,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
} finally {
|
|
318
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|