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.
Files changed (334) hide show
  1. package/LICENSE +661 -0
  2. package/dist/bin.d.ts +3 -0
  3. package/dist/bin.d.ts.map +1 -0
  4. package/dist/bin.js +4 -0
  5. package/dist/bin.js.map +1 -0
  6. package/dist/cli.d.ts +15 -0
  7. package/dist/cli.d.ts.map +1 -0
  8. package/dist/cli.js +59 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/commands/audio.d.ts +17 -0
  11. package/dist/commands/audio.d.ts.map +1 -0
  12. package/dist/commands/audio.js +315 -0
  13. package/dist/commands/audio.js.map +1 -0
  14. package/dist/commands/export.d.ts +36 -0
  15. package/dist/commands/export.d.ts.map +1 -0
  16. package/dist/commands/export.js +257 -0
  17. package/dist/commands/export.js.map +1 -0
  18. package/dist/commands/gfx.d.ts +28 -0
  19. package/dist/commands/gfx.d.ts.map +1 -0
  20. package/dist/commands/gfx.js +521 -0
  21. package/dist/commands/gfx.js.map +1 -0
  22. package/dist/commands/init.d.ts +34 -0
  23. package/dist/commands/init.d.ts.map +1 -0
  24. package/dist/commands/init.js +151 -0
  25. package/dist/commands/init.js.map +1 -0
  26. package/dist/commands/lint.d.ts +20 -0
  27. package/dist/commands/lint.d.ts.map +1 -0
  28. package/dist/commands/lint.js +90 -0
  29. package/dist/commands/lint.js.map +1 -0
  30. package/dist/commands/minify.d.ts +20 -0
  31. package/dist/commands/minify.d.ts.map +1 -0
  32. package/dist/commands/minify.js +129 -0
  33. package/dist/commands/minify.js.map +1 -0
  34. package/dist/commands/music.d.ts +13 -0
  35. package/dist/commands/music.d.ts.map +1 -0
  36. package/dist/commands/music.js +241 -0
  37. package/dist/commands/music.js.map +1 -0
  38. package/dist/commands/playtest.d.ts +29 -0
  39. package/dist/commands/playtest.d.ts.map +1 -0
  40. package/dist/commands/playtest.js +631 -0
  41. package/dist/commands/playtest.js.map +1 -0
  42. package/dist/commands/run.d.ts +30 -0
  43. package/dist/commands/run.d.ts.map +1 -0
  44. package/dist/commands/run.js +202 -0
  45. package/dist/commands/run.js.map +1 -0
  46. package/dist/commands/serve.d.ts +50 -0
  47. package/dist/commands/serve.d.ts.map +1 -0
  48. package/dist/commands/serve.js +213 -0
  49. package/dist/commands/serve.js.map +1 -0
  50. package/dist/commands/sfx.d.ts +13 -0
  51. package/dist/commands/sfx.d.ts.map +1 -0
  52. package/dist/commands/sfx.js +254 -0
  53. package/dist/commands/sfx.js.map +1 -0
  54. package/dist/commands/tokens.d.ts +36 -0
  55. package/dist/commands/tokens.d.ts.map +1 -0
  56. package/dist/commands/tokens.js +129 -0
  57. package/dist/commands/tokens.js.map +1 -0
  58. package/dist/commands/verify.d.ts +57 -0
  59. package/dist/commands/verify.d.ts.map +1 -0
  60. package/dist/commands/verify.js +213 -0
  61. package/dist/commands/verify.js.map +1 -0
  62. package/dist/commands/version.d.ts +12 -0
  63. package/dist/commands/version.d.ts.map +1 -0
  64. package/dist/commands/version.js +24 -0
  65. package/dist/commands/version.js.map +1 -0
  66. package/dist/engine/audio/index.d.ts +14 -0
  67. package/dist/engine/audio/index.d.ts.map +1 -0
  68. package/dist/engine/audio/index.js +14 -0
  69. package/dist/engine/audio/index.js.map +1 -0
  70. package/dist/engine/audio/music.d.ts +116 -0
  71. package/dist/engine/audio/music.d.ts.map +1 -0
  72. package/dist/engine/audio/music.js +138 -0
  73. package/dist/engine/audio/music.js.map +1 -0
  74. package/dist/engine/audio/section.d.ts +34 -0
  75. package/dist/engine/audio/section.d.ts.map +1 -0
  76. package/dist/engine/audio/section.js +67 -0
  77. package/dist/engine/audio/section.js.map +1 -0
  78. package/dist/engine/audio/sfx.d.ts +141 -0
  79. package/dist/engine/audio/sfx.d.ts.map +1 -0
  80. package/dist/engine/audio/sfx.js +506 -0
  81. package/dist/engine/audio/sfx.js.map +1 -0
  82. package/dist/engine/cart/cart.d.ts +75 -0
  83. package/dist/engine/cart/cart.d.ts.map +1 -0
  84. package/dist/engine/cart/cart.js +153 -0
  85. package/dist/engine/cart/cart.js.map +1 -0
  86. package/dist/engine/cart/errors.d.ts +29 -0
  87. package/dist/engine/cart/errors.d.ts.map +1 -0
  88. package/dist/engine/cart/errors.js +26 -0
  89. package/dist/engine/cart/errors.js.map +1 -0
  90. package/dist/engine/cart/gfx.d.ts +79 -0
  91. package/dist/engine/cart/gfx.d.ts.map +1 -0
  92. package/dist/engine/cart/gfx.js +171 -0
  93. package/dist/engine/cart/gfx.js.map +1 -0
  94. package/dist/engine/cart/index.d.ts +15 -0
  95. package/dist/engine/cart/index.d.ts.map +1 -0
  96. package/dist/engine/cart/index.js +15 -0
  97. package/dist/engine/cart/index.js.map +1 -0
  98. package/dist/engine/cart/map.d.ts +46 -0
  99. package/dist/engine/cart/map.d.ts.map +1 -0
  100. package/dist/engine/cart/map.js +118 -0
  101. package/dist/engine/cart/map.js.map +1 -0
  102. package/dist/engine/config/config.d.ts +74 -0
  103. package/dist/engine/config/config.d.ts.map +1 -0
  104. package/dist/engine/config/config.js +103 -0
  105. package/dist/engine/config/config.js.map +1 -0
  106. package/dist/engine/config/index.d.ts +7 -0
  107. package/dist/engine/config/index.d.ts.map +1 -0
  108. package/dist/engine/config/index.js +7 -0
  109. package/dist/engine/config/index.js.map +1 -0
  110. package/dist/engine/gfx/grid.d.ts +51 -0
  111. package/dist/engine/gfx/grid.d.ts.map +1 -0
  112. package/dist/engine/gfx/grid.js +116 -0
  113. package/dist/engine/gfx/grid.js.map +1 -0
  114. package/dist/engine/gfx/index.d.ts +17 -0
  115. package/dist/engine/gfx/index.d.ts.map +1 -0
  116. package/dist/engine/gfx/index.js +17 -0
  117. package/dist/engine/gfx/index.js.map +1 -0
  118. package/dist/engine/gfx/label.d.ts +34 -0
  119. package/dist/engine/gfx/label.d.ts.map +1 -0
  120. package/dist/engine/gfx/label.js +190 -0
  121. package/dist/engine/gfx/label.js.map +1 -0
  122. package/dist/engine/gfx/overlap.d.ts +42 -0
  123. package/dist/engine/gfx/overlap.d.ts.map +1 -0
  124. package/dist/engine/gfx/overlap.js +57 -0
  125. package/dist/engine/gfx/overlap.js.map +1 -0
  126. package/dist/engine/gfx/palette.d.ts +29 -0
  127. package/dist/engine/gfx/palette.d.ts.map +1 -0
  128. package/dist/engine/gfx/palette.js +46 -0
  129. package/dist/engine/gfx/palette.js.map +1 -0
  130. package/dist/engine/gfx/png.d.ts +31 -0
  131. package/dist/engine/gfx/png.d.ts.map +1 -0
  132. package/dist/engine/gfx/png.js +165 -0
  133. package/dist/engine/gfx/png.js.map +1 -0
  134. package/dist/engine/gfx/render.d.ts +42 -0
  135. package/dist/engine/gfx/render.d.ts.map +1 -0
  136. package/dist/engine/gfx/render.js +72 -0
  137. package/dist/engine/gfx/render.js.map +1 -0
  138. package/dist/engine/index.d.ts +22 -0
  139. package/dist/engine/index.d.ts.map +1 -0
  140. package/dist/engine/index.js +22 -0
  141. package/dist/engine/index.js.map +1 -0
  142. package/dist/engine/pico8/adapter.d.ts +267 -0
  143. package/dist/engine/pico8/adapter.d.ts.map +1 -0
  144. package/dist/engine/pico8/adapter.js +37 -0
  145. package/dist/engine/pico8/adapter.js.map +1 -0
  146. package/dist/engine/pico8/drive.d.ts +308 -0
  147. package/dist/engine/pico8/drive.d.ts.map +1 -0
  148. package/dist/engine/pico8/drive.js +581 -0
  149. package/dist/engine/pico8/drive.js.map +1 -0
  150. package/dist/engine/pico8/harness.d.ts +146 -0
  151. package/dist/engine/pico8/harness.d.ts.map +1 -0
  152. package/dist/engine/pico8/harness.js +251 -0
  153. package/dist/engine/pico8/harness.js.map +1 -0
  154. package/dist/engine/pico8/index.d.ts +21 -0
  155. package/dist/engine/pico8/index.d.ts.map +1 -0
  156. package/dist/engine/pico8/index.js +21 -0
  157. package/dist/engine/pico8/index.js.map +1 -0
  158. package/dist/engine/pico8/sentinel.d.ts +63 -0
  159. package/dist/engine/pico8/sentinel.d.ts.map +1 -0
  160. package/dist/engine/pico8/sentinel.js +86 -0
  161. package/dist/engine/pico8/sentinel.js.map +1 -0
  162. package/dist/engine/pico8/session-daemon-entry.d.ts +10 -0
  163. package/dist/engine/pico8/session-daemon-entry.d.ts.map +1 -0
  164. package/dist/engine/pico8/session-daemon-entry.js +11 -0
  165. package/dist/engine/pico8/session-daemon-entry.js.map +1 -0
  166. package/dist/engine/pico8/session-daemon-main.d.ts +44 -0
  167. package/dist/engine/pico8/session-daemon-main.d.ts.map +1 -0
  168. package/dist/engine/pico8/session-daemon-main.js +71 -0
  169. package/dist/engine/pico8/session-daemon-main.js.map +1 -0
  170. package/dist/engine/pico8/session-daemon.d.ts +68 -0
  171. package/dist/engine/pico8/session-daemon.d.ts.map +1 -0
  172. package/dist/engine/pico8/session-daemon.js +264 -0
  173. package/dist/engine/pico8/session-daemon.js.map +1 -0
  174. package/dist/engine/pico8/session.d.ts +140 -0
  175. package/dist/engine/pico8/session.d.ts.map +1 -0
  176. package/dist/engine/pico8/session.js +250 -0
  177. package/dist/engine/pico8/session.js.map +1 -0
  178. package/dist/engine/pico8/shell.d.ts +103 -0
  179. package/dist/engine/pico8/shell.d.ts.map +1 -0
  180. package/dist/engine/pico8/shell.js +324 -0
  181. package/dist/engine/pico8/shell.js.map +1 -0
  182. package/dist/engine/pico8/supervisor.d.ts +137 -0
  183. package/dist/engine/pico8/supervisor.d.ts.map +1 -0
  184. package/dist/engine/pico8/supervisor.js +140 -0
  185. package/dist/engine/pico8/supervisor.js.map +1 -0
  186. package/dist/engine/scaffold/index.d.ts +8 -0
  187. package/dist/engine/scaffold/index.d.ts.map +1 -0
  188. package/dist/engine/scaffold/index.js +8 -0
  189. package/dist/engine/scaffold/index.js.map +1 -0
  190. package/dist/engine/scaffold/scaffold.d.ts +49 -0
  191. package/dist/engine/scaffold/scaffold.d.ts.map +1 -0
  192. package/dist/engine/scaffold/scaffold.js +217 -0
  193. package/dist/engine/scaffold/scaffold.js.map +1 -0
  194. package/dist/engine/shrinko/adapter.d.ts +167 -0
  195. package/dist/engine/shrinko/adapter.d.ts.map +1 -0
  196. package/dist/engine/shrinko/adapter.js +107 -0
  197. package/dist/engine/shrinko/adapter.js.map +1 -0
  198. package/dist/engine/shrinko/index.d.ts +13 -0
  199. package/dist/engine/shrinko/index.d.ts.map +1 -0
  200. package/dist/engine/shrinko/index.js +13 -0
  201. package/dist/engine/shrinko/index.js.map +1 -0
  202. package/dist/engine/shrinko/shell.d.ts +69 -0
  203. package/dist/engine/shrinko/shell.d.ts.map +1 -0
  204. package/dist/engine/shrinko/shell.js +156 -0
  205. package/dist/engine/shrinko/shell.js.map +1 -0
  206. package/dist/engine/skills/index.d.ts +8 -0
  207. package/dist/engine/skills/index.d.ts.map +1 -0
  208. package/dist/engine/skills/index.js +8 -0
  209. package/dist/engine/skills/index.js.map +1 -0
  210. package/dist/engine/skills/skills.d.ts +104 -0
  211. package/dist/engine/skills/skills.d.ts.map +1 -0
  212. package/dist/engine/skills/skills.js +149 -0
  213. package/dist/engine/skills/skills.js.map +1 -0
  214. package/dist/index.d.ts +10 -0
  215. package/dist/index.d.ts.map +1 -0
  216. package/dist/index.js +10 -0
  217. package/dist/index.js.map +1 -0
  218. package/dist/version.d.ts +2 -0
  219. package/dist/version.d.ts.map +1 -0
  220. package/dist/version.js +19 -0
  221. package/dist/version.js.map +1 -0
  222. package/package.json +48 -2
  223. package/src/bin.ts +4 -0
  224. package/src/cli.ts +61 -0
  225. package/src/commands/audio.test.ts +280 -0
  226. package/src/commands/audio.ts +410 -0
  227. package/src/commands/export.test.ts +310 -0
  228. package/src/commands/export.ts +316 -0
  229. package/src/commands/gfx-io.test.ts +178 -0
  230. package/src/commands/gfx.test.ts +470 -0
  231. package/src/commands/gfx.ts +645 -0
  232. package/src/commands/init.test.ts +460 -0
  233. package/src/commands/init.ts +193 -0
  234. package/src/commands/lint.test.ts +137 -0
  235. package/src/commands/lint.ts +117 -0
  236. package/src/commands/minify.test.ts +166 -0
  237. package/src/commands/minify.ts +156 -0
  238. package/src/commands/music.test.ts +272 -0
  239. package/src/commands/music.ts +288 -0
  240. package/src/commands/playtest.test.ts +317 -0
  241. package/src/commands/playtest.ts +773 -0
  242. package/src/commands/run.test.ts +238 -0
  243. package/src/commands/run.ts +259 -0
  244. package/src/commands/serve.test.ts +218 -0
  245. package/src/commands/serve.ts +272 -0
  246. package/src/commands/sfx.test.ts +349 -0
  247. package/src/commands/sfx.ts +300 -0
  248. package/src/commands/tokens.test.ts +212 -0
  249. package/src/commands/tokens.ts +168 -0
  250. package/src/commands/verify.test.ts +238 -0
  251. package/src/commands/verify.ts +256 -0
  252. package/src/commands/version.test.ts +70 -0
  253. package/src/commands/version.ts +24 -0
  254. package/src/engine/audio/index.ts +47 -0
  255. package/src/engine/audio/music.test.ts +188 -0
  256. package/src/engine/audio/music.ts +218 -0
  257. package/src/engine/audio/section.test.ts +124 -0
  258. package/src/engine/audio/section.ts +79 -0
  259. package/src/engine/audio/sfx.test.ts +410 -0
  260. package/src/engine/audio/sfx.ts +694 -0
  261. package/src/engine/cart/cart.test.ts +304 -0
  262. package/src/engine/cart/cart.ts +211 -0
  263. package/src/engine/cart/errors.ts +39 -0
  264. package/src/engine/cart/fixtures/crlf.p8 +6 -0
  265. package/src/engine/cart/fixtures/full.p8 +174 -0
  266. package/src/engine/cart/fixtures/header-only.p8 +2 -0
  267. package/src/engine/cart/fixtures/malformed-no-header.p8 +2 -0
  268. package/src/engine/cart/fixtures/minimal.p8 +10 -0
  269. package/src/engine/cart/fixtures/no-trailing-newline.p8 +6 -0
  270. package/src/engine/cart/gfx.ts +195 -0
  271. package/src/engine/cart/index.ts +27 -0
  272. package/src/engine/cart/map.ts +134 -0
  273. package/src/engine/config/config.ts +115 -0
  274. package/src/engine/config/index.ts +12 -0
  275. package/src/engine/gfx/grid.test.ts +128 -0
  276. package/src/engine/gfx/grid.ts +136 -0
  277. package/src/engine/gfx/index.ts +34 -0
  278. package/src/engine/gfx/label.test.ts +105 -0
  279. package/src/engine/gfx/label.ts +229 -0
  280. package/src/engine/gfx/overlap.test.ts +71 -0
  281. package/src/engine/gfx/overlap.ts +66 -0
  282. package/src/engine/gfx/palette.test.ts +35 -0
  283. package/src/engine/gfx/palette.ts +54 -0
  284. package/src/engine/gfx/png.test.ts +111 -0
  285. package/src/engine/gfx/png.ts +196 -0
  286. package/src/engine/gfx/render.test.ts +180 -0
  287. package/src/engine/gfx/render.ts +106 -0
  288. package/src/engine/index.ts +21 -0
  289. package/src/engine/pico8/adapter.ts +303 -0
  290. package/src/engine/pico8/drive.test.ts +415 -0
  291. package/src/engine/pico8/drive.ts +729 -0
  292. package/src/engine/pico8/harness.test.ts +181 -0
  293. package/src/engine/pico8/harness.ts +354 -0
  294. package/src/engine/pico8/index.ts +20 -0
  295. package/src/engine/pico8/sentinel.test.ts +80 -0
  296. package/src/engine/pico8/sentinel.ts +93 -0
  297. package/src/engine/pico8/session-daemon-entry.ts +12 -0
  298. package/src/engine/pico8/session-daemon-main.ts +98 -0
  299. package/src/engine/pico8/session-daemon.test.ts +212 -0
  300. package/src/engine/pico8/session-daemon.ts +306 -0
  301. package/src/engine/pico8/session.test.ts +239 -0
  302. package/src/engine/pico8/session.ts +321 -0
  303. package/src/engine/pico8/shell.test.ts +583 -0
  304. package/src/engine/pico8/shell.ts +412 -0
  305. package/src/engine/pico8/supervisor.test.ts +105 -0
  306. package/src/engine/pico8/supervisor.ts +197 -0
  307. package/src/engine/scaffold/index.ts +15 -0
  308. package/src/engine/scaffold/scaffold.ts +236 -0
  309. package/src/engine/shrinko/adapter.ts +241 -0
  310. package/src/engine/shrinko/fixtures/count-no-compressed.txt +2 -0
  311. package/src/engine/shrinko/fixtures/count-over-budget.txt +3 -0
  312. package/src/engine/shrinko/fixtures/count-under-budget.txt +3 -0
  313. package/src/engine/shrinko/index.ts +33 -0
  314. package/src/engine/shrinko/shell.ts +224 -0
  315. package/src/engine/shrinko/shrinko.test.ts +182 -0
  316. package/src/engine/skills/index.ts +14 -0
  317. package/src/engine/skills/skills.test.ts +376 -0
  318. package/src/engine/skills/skills.ts +205 -0
  319. package/src/index.ts +13 -0
  320. package/src/skills/game-design-reference/SKILL.md +70 -0
  321. package/src/skills/game-jam/SKILL.md +45 -0
  322. package/src/skills/picopilot-art/SKILL.md +62 -0
  323. package/src/skills/picopilot-audio/SKILL.md +194 -0
  324. package/src/skills/picopilot-code/SKILL.md +97 -0
  325. package/src/skills/picopilot-code/reference/README.md +42 -0
  326. package/src/skills/picopilot-code/reference/mode7-racing.md +95 -0
  327. package/src/skills/picopilot-code/reference/platformer.md +119 -0
  328. package/src/skills/picopilot-code/reference/puzzle-grid.md +106 -0
  329. package/src/skills/picopilot-code/reference/rpg-menus-dialog.md +122 -0
  330. package/src/skills/picopilot-code/reference/top-down-adventure.md +117 -0
  331. package/src/skills/picopilot-code/reference/twin-stick-arcade.md +128 -0
  332. package/src/skills/picopilot-debug/SKILL.md +195 -0
  333. package/src/skills/picopilot-overview/SKILL.md +52 -0
  334. 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
+ }