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,93 @@
1
+ import {DONE_SENTINEL} from './adapter.js';
2
+
3
+ /**
4
+ * The pure sentinel-watch core (ADR-0006): scan a stdout stream line by line and
5
+ * decide WHEN to kill PICO-8. A cart cannot self-quit the app, so it signals
6
+ * "done" by printing a sentinel line via `printh`; the launcher kills the moment
7
+ * it matches. This is strictly better than a blind timeout (no guessing the
8
+ * duration), which stays only as a backstop in the shell layer.
9
+ *
10
+ * Kept PURE and stream-shaped on purpose: it is the load-bearing logic and the
11
+ * one piece testable in CI WITHOUT the paid PICO-8 binary. The shell layer feeds
12
+ * it real child stdout; a test feeds it a fake array of lines.
13
+ */
14
+
15
+ /**
16
+ * Splits a raw stdout chunk into complete lines plus a trailing remainder,
17
+ * so sentinel matching works on whole lines even when the OS delivers stdout in
18
+ * arbitrary chunks. The remainder (an unterminated final line) is carried to the
19
+ * next chunk by the caller.
20
+ *
21
+ * Handles both `\n` and `\r\n`; the `\r` is stripped so a sentinel matches
22
+ * regardless of platform line endings.
23
+ */
24
+ export function splitLines(chunk: string): {lines: string[]; rest: string} {
25
+ const parts = chunk.split('\n');
26
+ const rest = parts.pop() ?? '';
27
+ return {
28
+ lines: parts.map((l) => (l.endsWith('\r') ? l.slice(0, -1) : l)),
29
+ rest,
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Scans `lines` for the `sentinel` and reports whether it appeared and at which
35
+ * index. A match means the caller should kill PICO-8 now (exit reason
36
+ * `sentinel`); no match means keep streaming until the backstop or a natural
37
+ * exit. The comparison is an EXACT line match (the cart prints the sentinel on
38
+ * its own line), so incidental substrings in other output never trip it.
39
+ */
40
+ export function findSentinel(
41
+ lines: readonly string[],
42
+ sentinel: string = DONE_SENTINEL,
43
+ ): {matched: boolean; index: number} {
44
+ const index = lines.findIndex((l) => l === sentinel);
45
+ return {matched: index !== -1, index};
46
+ }
47
+
48
+ /**
49
+ * A stateful line accumulator for a streaming run: feed it raw stdout chunks;
50
+ * it buffers partial lines, exposes the complete lines seen so far, and reports
51
+ * the moment the sentinel is matched. This is the exact glue the shell adapter
52
+ * drives (feed chunks, stop on `sentinelSeen`), and a test drives it with
53
+ * synthetic chunks to assert prompt-kill vs. no-match without a real process.
54
+ */
55
+ export class SentinelWatcher {
56
+ private buffer = '';
57
+ private readonly linesSeen: string[] = [];
58
+ private matched = false;
59
+
60
+ constructor(private readonly sentinel: string = DONE_SENTINEL) {}
61
+
62
+ /**
63
+ * Feeds one raw stdout chunk. Returns `true` the first time the sentinel is
64
+ * matched (the caller should kill PICO-8 then); once matched it stays matched.
65
+ */
66
+ push(chunk: string): boolean {
67
+ const {lines, rest} = splitLines(this.buffer + chunk);
68
+ this.buffer = rest;
69
+ for (const line of lines) {
70
+ this.linesSeen.push(line);
71
+ if (line === this.sentinel) this.matched = true;
72
+ }
73
+ return this.matched;
74
+ }
75
+
76
+ /** Whether the sentinel has been seen on a complete line so far. */
77
+ get sentinelSeen(): boolean {
78
+ return this.matched;
79
+ }
80
+
81
+ /**
82
+ * The full captured stdout as text (complete lines plus any unterminated
83
+ * trailing buffer), for the run report's `printh`. The sentinel line itself
84
+ * is KEPT (it is real cart output); the command decides whether to show it.
85
+ */
86
+ get text(): string {
87
+ const all =
88
+ this.buffer.length > 0
89
+ ? [...this.linesSeen, this.buffer]
90
+ : this.linesSeen;
91
+ return all.join('\n');
92
+ }
93
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The detached daemon process ENTRY (ADR-0011, prd US #6). `playtest start`
3
+ * spawns this file (via `process.execPath [+ execArgv] <this> <configJson>`, so
4
+ * it re-uses the exact loader the parent ran under, dev `tsx` or built `dist`) as
5
+ * a DETACHED background process that owns the live driven PICO-8 for the session's
6
+ * lifetime. It just decodes its JSON config and hands off to {@link daemonMain};
7
+ * all the logic lives in `session-daemon-main.ts` (so it stays unit-testable).
8
+ */
9
+
10
+ import {daemonMain} from './session-daemon-main.js';
11
+
12
+ daemonMain(process.argv.slice(2));
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The playtest session DAEMON ENTRYPOINT (ADR-0011, prd US #6): the small
3
+ * detached process `playtest start` spawns to OWN a live driven PICO-8 for a
4
+ * session's lifetime. It is invoked via the hidden `playtest __daemon` verb (so it
5
+ * re-uses the exact same executable/loader the parent ran under, dev `tsx` or
6
+ * built `dist`), reads its config from JSON on argv, spawns PICO-8 on the
7
+ * throwaway driven cart with a LIVE stdin, and hands the process to
8
+ * {@link serveSession} to answer verbs over the session socket.
9
+ *
10
+ * PICO-8 ABSENT: the spawn ENOENTs, so the daemon writes a `pico8-not-found`
11
+ * marker file into the session dir and exits; the `start` client polls for either
12
+ * the socket (ready) or that marker (absent) and maps the marker to the same
13
+ * structured `pico8-not-found` boundary as `run`/`audio render`. This keeps the
14
+ * absent path structured WITHOUT the parent ever launching PICO-8 itself (the
15
+ * `pico8 --help` footgun is never touched).
16
+ */
17
+
18
+ import {writeFileSync} from 'node:fs';
19
+ import {join} from 'node:path';
20
+ import {pico8NotFound} from './adapter.js';
21
+ import {serveSession} from './session-daemon.js';
22
+ import {spawnRunner, pico8Candidates, withPico8Home} from './shell.js';
23
+ import type {SessionPaths} from './supervisor.js';
24
+
25
+ /** The marker file the daemon writes into the session dir when PICO-8 is absent. */
26
+ export const PICO8_NOT_FOUND_MARKER = 'pico8-not-found' as const;
27
+
28
+ /** The daemon's launch config, passed as a single JSON argument. */
29
+ export interface DaemonConfig {
30
+ readonly id: string;
31
+ readonly paths: SessionPaths;
32
+ /** Absolute path to the throwaway driven cart to run live. */
33
+ readonly cartPath: string;
34
+ /** The screenshot basename the driven cart names each SHOT. */
35
+ readonly shotBasename: string;
36
+ /** Idle-reap window in ms. */
37
+ readonly idleTimeoutMs: number;
38
+ /** Per-command ACK deadline in ms. */
39
+ readonly ackTimeoutMs: number;
40
+ }
41
+
42
+ /**
43
+ * Runs the daemon from its JSON config: spawns PICO-8 live on the driven cart and
44
+ * serves the session, or writes the pico8-not-found marker and exits on ENOENT.
45
+ * Exported so a test can drive it with an injected spawner (no real binary).
46
+ */
47
+ export function runDaemon(
48
+ config: DaemonConfig,
49
+ env: NodeJS.ProcessEnv = process.env,
50
+ spawn = spawnRunner,
51
+ ): void {
52
+ const [file] = pico8Candidates(env) as [string, ...string[]];
53
+ // Live session: `-desktop <shotDir>` for SHOT captures, `-x` headless, stdin
54
+ // piped so the daemon can send fixed-size command blocks per verb.
55
+ const args = ['-desktop', config.paths.shotDir, '-x', config.cartPath];
56
+ // `-home <isolated>` so PICO-8's config/data tree lands in a throwaway dir, not
57
+ // the caller's CWD (matches the shell adapter's spawn sites).
58
+ const proc = spawn(file, withPico8Home(args), env, {stdin: true});
59
+
60
+ // A spawn ENOENT means PICO-8 is absent (a bad PICO8_PATH surfaces the same
61
+ // way): leave the structured marker the `start` client polls for, then exit.
62
+ // This can only happen at spawn time (a launched binary does not ENOENT later),
63
+ // so it is unambiguously the absent boundary, not a mid-session fault.
64
+ proc.onError((err) => {
65
+ if (err.code === 'ENOENT') {
66
+ writeFileSync(
67
+ join(config.paths.dir, PICO8_NOT_FOUND_MARKER),
68
+ JSON.stringify(pico8NotFound()),
69
+ );
70
+ process.exit(0);
71
+ }
72
+ process.exit(1);
73
+ });
74
+
75
+ serveSession({
76
+ id: config.id,
77
+ paths: config.paths,
78
+ process: proc,
79
+ shotBasename: config.shotBasename,
80
+ idleTimeoutMs: config.idleTimeoutMs,
81
+ ackTimeoutMs: config.ackTimeoutMs,
82
+ onExit: () => process.exit(0),
83
+ });
84
+ }
85
+
86
+ /**
87
+ * The daemon process main: reads the JSON config from `argv[0]` and runs. Called
88
+ * by the hidden `playtest __daemon <configJson>` verb in a detached child.
89
+ */
90
+ export function daemonMain(argv: readonly string[]): void {
91
+ const raw = argv[0];
92
+ if (raw === undefined) {
93
+ process.stderr.write('playtest daemon: missing config\n');
94
+ process.exit(2);
95
+ }
96
+ const config = JSON.parse(raw) as DaemonConfig;
97
+ runDaemon(config);
98
+ }
@@ -0,0 +1,212 @@
1
+ import {existsSync, mkdtempSync, readFileSync, rmSync} from 'node:fs';
2
+ import {tmpdir} from 'node:os';
3
+ import {join} from 'node:path';
4
+ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
5
+ import {ACK, OPCODE} from './drive.js';
6
+ import {runDaemon, PICO8_NOT_FOUND_MARKER} from './session-daemon-main.js';
7
+ import {SessionClient, serveSession, sessionExists} from './session-daemon.js';
8
+ import type {Pico8Process, SpawnRunner} from './shell.js';
9
+ import {SessionRegistry, type SessionPaths} from './supervisor.js';
10
+
11
+ /**
12
+ * The session DAEMON dispatch is CI-tested over a REAL Unix socket but a FAKE
13
+ * driven process (no paid binary): a client verb round-trips through the daemon
14
+ * to the handshake and back. The pico8-not-found marker path (start with PICO-8
15
+ * absent) and the idle-reap are asserted too. A live end-to-end session (real
16
+ * PICO-8) is the manual/opt-in tier.
17
+ */
18
+
19
+ /** A fake driven process the test drives via ACK emission. */
20
+ class FakeDriven implements Pico8Process {
21
+ private stdoutCb: (c: string) => void = () => {};
22
+ private closeCb: (code: number | null) => void = () => {};
23
+ private errorCb: (e: NodeJS.ErrnoException) => void = () => {};
24
+ killed = false;
25
+ writes: Uint8Array[] = [];
26
+ onStdout(cb: (c: string) => void): void {
27
+ this.stdoutCb = cb;
28
+ }
29
+ onClose(cb: (code: number | null) => void): void {
30
+ this.closeCb = cb;
31
+ }
32
+ onError(cb: (e: NodeJS.ErrnoException) => void): void {
33
+ this.errorCb = cb;
34
+ }
35
+ writeStdin(bytes: Uint8Array): void {
36
+ // Auto-ACK: the fake cart acks the command it was just handed, so the daemon
37
+ // round-trip completes deterministically (the ack CONTENT is the contract).
38
+ this.writes.push(bytes);
39
+ const op = bytes[0];
40
+ queueMicrotask(() => {
41
+ if (op === OPCODE.input) this.stdoutCb(`${ACK.input}\n`);
42
+ else if (op === OPCODE.step) this.stdoutCb(`${ACK.stepDone}\n`);
43
+ else if (op === OPCODE.shot) this.stdoutCb(`${ACK.shot}\n`);
44
+ });
45
+ }
46
+ kill(): void {
47
+ this.killed = true;
48
+ }
49
+ /** Simulate PICO-8 exiting mid-session. */
50
+ die(): void {
51
+ this.closeCb(0);
52
+ }
53
+ }
54
+
55
+ let base: string;
56
+ let registry: SessionRegistry;
57
+ let paths: SessionPaths;
58
+ const daemons: {close: () => void}[] = [];
59
+
60
+ beforeEach(() => {
61
+ base = mkdtempSync(join(tmpdir(), 'pp-daemon-'));
62
+ registry = new SessionRegistry(base);
63
+ paths = registry.create('run1');
64
+ });
65
+
66
+ afterEach(() => {
67
+ for (const d of daemons.splice(0)) d.close();
68
+ rmSync(base, {recursive: true, force: true});
69
+ });
70
+
71
+ function startFakeDaemon(proc: FakeDriven, idleTimeoutMs?: number) {
72
+ const d = serveSession({
73
+ id: 'run1',
74
+ paths,
75
+ process: proc,
76
+ shotBasename: 'play',
77
+ idleTimeoutMs,
78
+ });
79
+ daemons.push(d);
80
+ return d;
81
+ }
82
+
83
+ /** Waits until the daemon's socket is listening (its readiness signal). */
84
+ async function waitSocket(): Promise<void> {
85
+ for (let i = 0; i < 200; i++) {
86
+ if (sessionExists(paths)) return;
87
+ await new Promise((r) => setTimeout(r, 5));
88
+ }
89
+ throw new Error('socket never appeared');
90
+ }
91
+
92
+ describe('session daemon: verb round-trip over the socket (fake process)', () => {
93
+ it('step advances frames and returns the structured result', async () => {
94
+ startFakeDaemon(new FakeDriven());
95
+ await waitSocket();
96
+ const client = new SessionClient(paths.socket);
97
+ const res = await client.send({verb: 'step', frames: 5});
98
+ expect(res.ok).toBe(true);
99
+ if (res.ok) {
100
+ expect(res.value.frame).toBe(5);
101
+ expect(res.value.ack).toBe(ACK.stepDone);
102
+ }
103
+ });
104
+
105
+ it('a large step is issued as repeated <=255 sub-steps (exact + lossless)', async () => {
106
+ const proc = new FakeDriven();
107
+ startFakeDaemon(proc);
108
+ await waitSocket();
109
+ const res = await new SessionClient(paths.socket).send({
110
+ verb: 'step',
111
+ frames: 600,
112
+ });
113
+ expect(res.ok && res.value.frame).toBe(600);
114
+ // 600 = 255 + 255 + 90 -> three STEP blocks written.
115
+ expect(proc.writes.length).toBe(3);
116
+ });
117
+
118
+ it('status reports alive + the frame count without stepping', async () => {
119
+ startFakeDaemon(new FakeDriven());
120
+ await waitSocket();
121
+ const res = await new SessionClient(paths.socket).send({verb: 'status'});
122
+ expect(res.ok && res.value.alive).toBe(true);
123
+ expect(res.ok && res.value.frame).toBe(0);
124
+ });
125
+
126
+ it('stop returns a clean final response then reaps the socket', async () => {
127
+ startFakeDaemon(new FakeDriven());
128
+ await waitSocket();
129
+ const res = await new SessionClient(paths.socket).send({verb: 'stop'});
130
+ expect(res.ok && res.value.alive).toBe(false);
131
+ // After stop the socket is gone (the session is torn down).
132
+ for (let i = 0; i < 100 && sessionExists(paths); i++) {
133
+ await new Promise((r) => setTimeout(r, 5));
134
+ }
135
+ expect(sessionExists(paths)).toBe(false);
136
+ });
137
+
138
+ it('a dead process surfaces process-dead on the next verb (not a hang)', async () => {
139
+ const proc = new FakeDriven();
140
+ startFakeDaemon(proc);
141
+ await waitSocket();
142
+ // Kill the fake mid-life: emit a close so the session marks itself dead.
143
+ proc.die();
144
+ const res = await new SessionClient(paths.socket).send({
145
+ verb: 'step',
146
+ frames: 1,
147
+ });
148
+ expect(res.ok).toBe(false);
149
+ if (!res.ok) expect(res.code).toBe('playtest-session-process-dead');
150
+ });
151
+ });
152
+
153
+ describe('session daemon: orphan idle-reap', () => {
154
+ it('tears the session down after the idle window with no verbs', async () => {
155
+ const proc = new FakeDriven();
156
+ startFakeDaemon(proc, 40); // tiny idle window
157
+ await waitSocket();
158
+ for (let i = 0; i < 100 && sessionExists(paths); i++) {
159
+ await new Promise((r) => setTimeout(r, 5));
160
+ }
161
+ expect(sessionExists(paths)).toBe(false);
162
+ expect(proc.killed).toBe(true); // the live process was reaped
163
+ });
164
+ });
165
+
166
+ describe('runDaemon: PICO-8 absent -> structured marker (start absent path)', () => {
167
+ it('writes the pico8-not-found marker on spawn ENOENT (no hang)', async () => {
168
+ let exited = false;
169
+ const realExit = process.exit;
170
+ // runDaemon calls process.exit on ENOENT; stub it so the test survives.
171
+ (process as {exit: unknown}).exit = ((): never => {
172
+ exited = true;
173
+ return undefined as never;
174
+ }) as typeof process.exit;
175
+
176
+ const enoentSpawn: SpawnRunner = () => {
177
+ const proc: Pico8Process = {
178
+ onStdout() {},
179
+ onClose() {},
180
+ onError(cb) {
181
+ const err = new Error('enoent') as NodeJS.ErrnoException;
182
+ err.code = 'ENOENT';
183
+ queueMicrotask(() => cb(err));
184
+ },
185
+ kill() {},
186
+ writeStdin() {},
187
+ };
188
+ return proc;
189
+ };
190
+ try {
191
+ runDaemon(
192
+ {
193
+ id: 'run1',
194
+ paths,
195
+ cartPath: join(paths.dir, 'driven.p8'),
196
+ shotBasename: 'play',
197
+ idleTimeoutMs: 1000,
198
+ ackTimeoutMs: 1000,
199
+ },
200
+ {},
201
+ enoentSpawn,
202
+ );
203
+ await new Promise((r) => setTimeout(r, 20));
204
+ const markerPath = join(paths.dir, PICO8_NOT_FOUND_MARKER);
205
+ expect(existsSync(markerPath)).toBe(true);
206
+ expect(readFileSync(markerPath, 'utf8')).toContain('pico8-not-found');
207
+ expect(exited).toBe(true);
208
+ } finally {
209
+ (process as {exit: unknown}).exit = realExit;
210
+ }
211
+ });
212
+ });