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.
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 +224 -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 +259 -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 +482 -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 +264 -0
  243. package/src/commands/run.ts +285 -0
  244. package/src/commands/serve.test.ts +227 -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 +278 -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,178 @@
1
+ import {execFileSync} from 'node:child_process';
2
+ import {existsSync, mkdtempSync, readFileSync, writeFileSync} from 'node:fs';
3
+ import {tmpdir} from 'node:os';
4
+ import {join} from 'node:path';
5
+ import {Cli} from 'incur';
6
+ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
7
+ import type {ShrinkoAdapter} from '../engine/shrinko/index.js';
8
+ import {shrinkoNotFound} from '../engine/shrinko/index.js';
9
+ import {type ShrinkoAdapterFactory, registerGfx} from './gfx.js';
10
+
11
+ /**
12
+ * `gfx export` / `gfx import` are the shrinko-BACKED spritesheet round-trip
13
+ * (distinct from the shrinko-free show/set/render). Driven here with a STUB
14
+ * shrinko factory that writes fake outputs and records its args, so no test
15
+ * needs the real shrinko8 binary.
16
+ */
17
+
18
+ const CART_TEXT =
19
+ 'pico-8 cartridge // http://www.pico-8.com\nversion 42\n__lua__\nprint("hi")\n__gfx__\n1234567890abcdef\n';
20
+
21
+ let dir: string;
22
+ let cartPath: string;
23
+
24
+ beforeEach(() => {
25
+ dir = mkdtempSync(join(tmpdir(), 'picopilot-gfxio-'));
26
+ cartPath = join(dir, 'main.p8');
27
+ writeFileSync(cartPath, CART_TEXT);
28
+ });
29
+ afterEach(() => {
30
+ try {
31
+ execFileSync('rm', ['-rf', dir]);
32
+ } catch {
33
+ // ignore
34
+ }
35
+ });
36
+
37
+ interface Seen {
38
+ exportArgs?: [string, string];
39
+ importArgs?: [string, string, string];
40
+ }
41
+
42
+ /**
43
+ * A stub shrinko adapter for the two spritesheet methods: records args, writes a
44
+ * fake output so on-disk assertions are real, and returns ok. `absent` makes
45
+ * both methods return the structured not-found value instead.
46
+ */
47
+ function stubFactory(seen: Seen, absent = false): ShrinkoAdapterFactory {
48
+ const adapter: ShrinkoAdapter = {
49
+ async count() {
50
+ throw new Error('not used');
51
+ },
52
+ async lint() {
53
+ throw new Error('not used');
54
+ },
55
+ async minify() {
56
+ throw new Error('not used');
57
+ },
58
+ async exportSpritesheet(cart, png) {
59
+ seen.exportArgs = [cart, png];
60
+ if (absent) return shrinkoNotFound();
61
+ writeFileSync(png, 'fake png');
62
+ return {ok: true, value: undefined};
63
+ },
64
+ async importSpritesheet(src, png, out) {
65
+ seen.importArgs = [src, png, out];
66
+ if (absent) return shrinkoNotFound();
67
+ writeFileSync(out, '-- imported cart\n');
68
+ return {ok: true, value: undefined};
69
+ },
70
+ };
71
+ return () => adapter;
72
+ }
73
+
74
+ async function runGfx(factory: ShrinkoAdapterFactory, argv: string[]) {
75
+ const cli = Cli.create('picopilot', {version: '0.0.0'});
76
+ registerGfx(cli, factory);
77
+ let stdout = '';
78
+ let exitCode = 0;
79
+ await cli.serve(argv, {
80
+ stdout(s) {
81
+ stdout += s;
82
+ },
83
+ exit(code) {
84
+ exitCode = code;
85
+ },
86
+ env: {PATH: '/x'},
87
+ });
88
+ return {stdout, exitCode};
89
+ }
90
+
91
+ describe('gfx export: raw spritesheet PNG round-trip', () => {
92
+ it('exports to <name>-sheet.png by default', async () => {
93
+ const seen: Seen = {};
94
+ const {stdout, exitCode} = await runGfx(stubFactory(seen), [
95
+ 'gfx',
96
+ 'export',
97
+ cartPath,
98
+ '--json',
99
+ ]);
100
+ expect(exitCode).toBe(0);
101
+ const out = JSON.parse(stdout);
102
+ expect(out.outPath).toBe(join(dir, 'main-sheet.png'));
103
+ expect(existsSync(join(dir, 'main-sheet.png'))).toBe(true);
104
+ expect(seen.exportArgs?.[0]).toBe(cartPath);
105
+ });
106
+
107
+ it('no-clobber: refuses an existing PNG without --force', async () => {
108
+ writeFileSync(join(dir, 'main-sheet.png'), 'existing');
109
+ const {stdout, exitCode} = await runGfx(stubFactory({}), [
110
+ 'gfx',
111
+ 'export',
112
+ cartPath,
113
+ ]);
114
+ expect(exitCode).not.toBe(0);
115
+ expect(stdout.toLowerCase()).toContain('refusing to overwrite');
116
+ });
117
+
118
+ it('shrinko absent -> structured not-found', async () => {
119
+ const {stdout, exitCode} = await runGfx(stubFactory({}, true), [
120
+ 'gfx',
121
+ 'export',
122
+ cartPath,
123
+ '--json',
124
+ ]);
125
+ expect(exitCode).not.toBe(0);
126
+ expect(stdout).toContain('uv pip install shrinko');
127
+ });
128
+ });
129
+
130
+ describe('gfx import: merge a spritesheet PNG into the cart', () => {
131
+ it('imports IN PLACE by default (writes back to the cart, like gfx set)', async () => {
132
+ const png = join(dir, 'sheet.png');
133
+ writeFileSync(png, 'png bytes');
134
+ const seen: Seen = {};
135
+ const {stdout, exitCode} = await runGfx(stubFactory(seen), [
136
+ 'gfx',
137
+ 'import',
138
+ png,
139
+ cartPath,
140
+ '--json',
141
+ ]);
142
+ expect(exitCode).toBe(0);
143
+ expect(JSON.parse(stdout).outPath).toBe(cartPath);
144
+ // src cart + png -> out cart, all three recorded; out defaults to the cart.
145
+ expect(seen.importArgs).toEqual([cartPath, png, cartPath]);
146
+ });
147
+
148
+ it('--out writes a separate cart, leaving the source untouched', async () => {
149
+ const png = join(dir, 'sheet.png');
150
+ writeFileSync(png, 'png bytes');
151
+ const outCart = join(dir, 'with-art.p8');
152
+ const before = readFileSync(cartPath, 'utf8');
153
+ const seen: Seen = {};
154
+ await runGfx(stubFactory(seen), [
155
+ 'gfx',
156
+ 'import',
157
+ png,
158
+ cartPath,
159
+ '--out',
160
+ outCart,
161
+ '--json',
162
+ ]);
163
+ expect(seen.importArgs?.[2]).toBe(outCart);
164
+ expect(readFileSync(cartPath, 'utf8')).toBe(before); // source untouched
165
+ });
166
+
167
+ it('missing PNG is a distinct error', async () => {
168
+ const {stdout, exitCode} = await runGfx(stubFactory({}), [
169
+ 'gfx',
170
+ 'import',
171
+ join(dir, 'nope.png'),
172
+ cartPath,
173
+ '--json',
174
+ ]);
175
+ expect(exitCode).not.toBe(0);
176
+ expect(stdout).toContain('no PNG at');
177
+ });
178
+ });
@@ -0,0 +1,470 @@
1
+ import {execFileSync} from 'node:child_process';
2
+ import {existsSync, mkdtempSync, readFileSync, writeFileSync} from 'node:fs';
3
+ import {tmpdir} from 'node:os';
4
+ import {join} from 'node:path';
5
+ import {inflateSync} from 'node:zlib';
6
+ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
7
+
8
+ import {GfxSheet} from '../engine/cart/index.js';
9
+ import {defaultConfigFile} from '../engine/config/index.js';
10
+ import {nibblesToGrid, PICO8_PALETTE} from '../engine/gfx/index.js';
11
+ import {createCli} from '../cli.js';
12
+
13
+ /** Decodes a PNG file into its raw RGB pixels (node's independent inflate). */
14
+ function decodePngFile(path: string): {
15
+ width: number;
16
+ height: number;
17
+ pixel(x: number, y: number): [number, number, number];
18
+ } {
19
+ const bytes = new Uint8Array(readFileSync(path));
20
+ let off = 8;
21
+ let width = 0;
22
+ let height = 0;
23
+ const idatParts: Buffer[] = [];
24
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
25
+ while (off < bytes.length) {
26
+ const len = view.getUint32(off);
27
+ const type = String.fromCharCode(
28
+ bytes[off + 4]!,
29
+ bytes[off + 5]!,
30
+ bytes[off + 6]!,
31
+ bytes[off + 7]!,
32
+ );
33
+ const data = bytes.subarray(off + 8, off + 8 + len);
34
+ if (type === 'IHDR') {
35
+ const dv = new DataView(data.buffer, data.byteOffset, data.byteLength);
36
+ width = dv.getUint32(0);
37
+ height = dv.getUint32(4);
38
+ } else if (type === 'IDAT') {
39
+ idatParts.push(Buffer.from(data));
40
+ }
41
+ off += 12 + len;
42
+ if (type === 'IEND') break;
43
+ }
44
+ const raw = new Uint8Array(inflateSync(Buffer.concat(idatParts)));
45
+ const stride = width * 3;
46
+ return {
47
+ width,
48
+ height,
49
+ pixel(x, y) {
50
+ const base = y * (stride + 1) + 1 + x * 3;
51
+ return [raw[base]!, raw[base + 1]!, raw[base + 2]!];
52
+ },
53
+ };
54
+ }
55
+
56
+ function rgbOf(index: number): [number, number, number] {
57
+ const c = PICO8_PALETTE[index]!;
58
+ return [c.r, c.g, c.b];
59
+ }
60
+
61
+ /** A minimal valid cart with an empty (all-zero) __gfx__ section. */
62
+ function cartText(gfxBody?: string): string {
63
+ const header =
64
+ 'pico-8 cartridge // http://www.pico-8.com\nversion 42\n__lua__\nprint("hi")\n';
65
+ if (gfxBody === undefined) return header;
66
+ return `${header}__gfx__\n${gfxBody}`;
67
+ }
68
+
69
+ let dir: string;
70
+ let cartPath: string;
71
+
72
+ beforeEach(() => {
73
+ dir = mkdtempSync(join(tmpdir(), 'picopilot-gfx-'));
74
+ cartPath = join(dir, 'main.p8');
75
+ });
76
+
77
+ afterEach(() => {
78
+ try {
79
+ execFileSync('rm', ['-rf', dir]);
80
+ } catch {
81
+ // ignore
82
+ }
83
+ });
84
+
85
+ /** Drives a `picopilot gfx ...` invocation through incur's serve DI. */
86
+ async function runGfx(argv: string[]) {
87
+ let stdout = '';
88
+ let exitCode = 0;
89
+ await createCli().serve(argv, {
90
+ stdout(s) {
91
+ stdout += s;
92
+ },
93
+ exit(code) {
94
+ exitCode = code;
95
+ },
96
+ env: {},
97
+ });
98
+ return {stdout, exitCode};
99
+ }
100
+
101
+ /** Writes a cart whose sprite `n` is painted from `grid` (8x8 nibbles), rest zero. */
102
+ function writeCartWithSprite(n: number, grid: number[][]): void {
103
+ const sheet = GfxSheet.fromBody(undefined);
104
+ sheet.setSprite(n, grid);
105
+ writeFileSync(cartPath, cartText(sheet.toBody()));
106
+ }
107
+
108
+ /** An 8x8 nibble grid from a generator. */
109
+ function makeGrid(fn: (r: number, c: number) => number): number[][] {
110
+ return Array.from({length: 8}, (_, r) =>
111
+ Array.from({length: 8}, (_, c) => fn(r, c)),
112
+ );
113
+ }
114
+
115
+ describe('picopilot gfx show', () => {
116
+ it('renders a painted sprite to a char grid and CTAs to `gfx set`', async () => {
117
+ const painted = makeGrid((r, c) => (r * 8 + c) % 16);
118
+ writeCartWithSprite(5, painted);
119
+
120
+ const {stdout, exitCode} = await runGfx([
121
+ 'gfx',
122
+ 'show',
123
+ '5',
124
+ cartPath,
125
+ '--json',
126
+ ]);
127
+ expect(exitCode).toBe(0);
128
+ const out = JSON.parse(stdout);
129
+ expect(out.sprite).toBe(5);
130
+ // The grid matches the codec's rendering of the painted sprite.
131
+ expect(out.grid).toBe(nibblesToGrid(painted));
132
+ expect(out.aliasesMap).toBe(false);
133
+ // CTA leads to `gfx set` for the fix step.
134
+ expect(JSON.stringify(out.cta)).toContain('gfx set 5');
135
+ });
136
+
137
+ it('flags aliasesMap = true for a 128-255 sprite', async () => {
138
+ writeFileSync(cartPath, cartText());
139
+ const {stdout} = await runGfx(['gfx', 'show', '200', cartPath, '--json']);
140
+ expect(JSON.parse(stdout).aliasesMap).toBe(true);
141
+ });
142
+ });
143
+
144
+ describe('picopilot gfx set: base-bank sprite (0-127) always succeeds', () => {
145
+ it('writes the grid back into __gfx__ and reports success', async () => {
146
+ writeFileSync(cartPath, cartText());
147
+ const grid = nibblesToGrid(makeGrid((r, c) => (r === c ? 8 : 0)));
148
+
149
+ const {stdout, exitCode} = await runGfx([
150
+ 'gfx',
151
+ 'set',
152
+ '3',
153
+ grid,
154
+ cartPath,
155
+ '--json',
156
+ ]);
157
+ expect(exitCode).toBe(0);
158
+ const out = JSON.parse(stdout);
159
+ expect(out.sprite).toBe(3);
160
+ expect(out.aliasesMap).toBe(false);
161
+ expect(out.overwroteSharedData).toBe(false);
162
+
163
+ // The pixels are actually persisted: read them back.
164
+ const sheet = GfxSheet.fromBody(
165
+ (readFileSync(cartPath, 'utf8').match(/__gfx__\n([\s\S]*)/) ?? [])[1],
166
+ );
167
+ expect(nibblesToGrid(sheet.getSprite(3))).toBe(grid);
168
+ });
169
+
170
+ it('leaves every OTHER section byte-identical (only __gfx__ changes)', async () => {
171
+ writeFileSync(cartPath, cartText());
172
+ const before = readFileSync(cartPath, 'utf8');
173
+ const grid = nibblesToGrid(makeGrid(() => 1));
174
+ await runGfx(['gfx', 'set', '0', grid, cartPath, '--json']);
175
+ const after = readFileSync(cartPath, 'utf8');
176
+ // The lua section + header are unchanged; only gfx was added/updated.
177
+ expect(after).toContain('print("hi")');
178
+ expect(before.slice(0, before.indexOf('__lua__'))).toBe(
179
+ after.slice(0, after.indexOf('__lua__')),
180
+ );
181
+ });
182
+ });
183
+
184
+ describe('picopilot gfx set: shared-bank sprite (128-255) smart-refuse (ADR-0004)', () => {
185
+ it('empty (all-zero) target → succeeds and NOTES the aliasing', async () => {
186
+ writeFileSync(cartPath, cartText());
187
+ const grid = nibblesToGrid(makeGrid(() => 2));
188
+
189
+ const {stdout, exitCode} = await runGfx([
190
+ 'gfx',
191
+ 'set',
192
+ '130',
193
+ grid,
194
+ cartPath,
195
+ '--json',
196
+ ]);
197
+ expect(exitCode).toBe(0);
198
+ const out = JSON.parse(stdout);
199
+ expect(out.aliasesMap).toBe(true);
200
+ expect(out.overwroteSharedData).toBe(false);
201
+ expect(out.note).toContain('shared map region');
202
+ // Persisted.
203
+ const sheet = GfxSheet.fromBody(
204
+ (readFileSync(cartPath, 'utf8').match(/__gfx__\n([\s\S]*)/) ?? [])[1],
205
+ );
206
+ expect(nibblesToGrid(sheet.getSprite(130))).toBe(grid);
207
+ });
208
+
209
+ it('data present + unauthorised → structured map-overlap refusal + nonzero exit', async () => {
210
+ // Paint sprite 130 non-zero: the shared region already holds data.
211
+ writeCartWithSprite(
212
+ 130,
213
+ makeGrid((r, c) => (r === 0 && c === 0 ? 7 : 0)),
214
+ );
215
+ const grid = nibblesToGrid(makeGrid(() => 3));
216
+
217
+ const {stdout, exitCode} = await runGfx([
218
+ 'gfx',
219
+ 'set',
220
+ '130',
221
+ grid,
222
+ cartPath,
223
+ '--json',
224
+ ]);
225
+ expect(exitCode).not.toBe(0);
226
+ const out = JSON.parse(stdout);
227
+ // incur error envelope: code is the machine handle; detail + remedy in message.
228
+ expect(out.code).toBe('map-overlap');
229
+ expect(out.message).toContain('sprite 130');
230
+ expect(out.message).toContain('--allow-map-overlap');
231
+ expect(out.message).toContain('allowMapOverlap');
232
+ // The CTA points at the authorised re-run.
233
+ expect(JSON.stringify(out.cta)).toContain('--allow-map-overlap');
234
+ });
235
+
236
+ it('REGRESSION GUARD: a refusal leaves the sprite __gfx__ bytes BYTE-UNCHANGED', async () => {
237
+ writeCartWithSprite(
238
+ 130,
239
+ makeGrid((r, c) => (r === 0 && c === 0 ? 7 : 0)),
240
+ );
241
+ const before = readFileSync(cartPath, 'utf8');
242
+
243
+ const grid = nibblesToGrid(makeGrid(() => 3));
244
+ const {exitCode} = await runGfx([
245
+ 'gfx',
246
+ 'set',
247
+ '130',
248
+ grid,
249
+ cartPath,
250
+ '--json',
251
+ ]);
252
+ expect(exitCode).not.toBe(0);
253
+
254
+ // The silent-corruption guard: the WHOLE cart file is byte-identical.
255
+ const after = readFileSync(cartPath, 'utf8');
256
+ expect(after).toBe(before);
257
+ });
258
+
259
+ it('data present + --allow-map-overlap flag → succeeds, overwrites, reports it', async () => {
260
+ writeCartWithSprite(
261
+ 130,
262
+ makeGrid((r, c) => (r === 0 && c === 0 ? 7 : 0)),
263
+ );
264
+ const grid = nibblesToGrid(makeGrid(() => 4));
265
+
266
+ const {stdout, exitCode} = await runGfx([
267
+ 'gfx',
268
+ 'set',
269
+ '130',
270
+ grid,
271
+ cartPath,
272
+ '--allow-map-overlap',
273
+ '--json',
274
+ ]);
275
+ expect(exitCode).toBe(0);
276
+ const out = JSON.parse(stdout);
277
+ expect(out.overwroteSharedData).toBe(true);
278
+ expect(out.note).toContain('authorised');
279
+ // The new pixels replaced the old.
280
+ const sheet = GfxSheet.fromBody(
281
+ (readFileSync(cartPath, 'utf8').match(/__gfx__\n([\s\S]*)/) ?? [])[1],
282
+ );
283
+ expect(nibblesToGrid(sheet.getSprite(130))).toBe(grid);
284
+ });
285
+
286
+ it('data present + allowMapOverlap in picopilot.json → succeeds (config authorisation)', async () => {
287
+ writeCartWithSprite(
288
+ 130,
289
+ makeGrid((r, c) => (r === 0 && c === 0 ? 7 : 0)),
290
+ );
291
+ // Author a config that authorises overlap.
292
+ const cfg = JSON.parse(defaultConfigFile());
293
+ cfg.commands.gfx.commands.set.options.allowMapOverlap = true;
294
+ const cfgPath = join(dir, 'picopilot.json');
295
+ writeFileSync(cfgPath, `${JSON.stringify(cfg, null, 2)}\n`);
296
+
297
+ const grid = nibblesToGrid(makeGrid(() => 5));
298
+ const {stdout, exitCode} = await runGfx([
299
+ 'gfx',
300
+ 'set',
301
+ '130',
302
+ grid,
303
+ cartPath,
304
+ '--config',
305
+ cfgPath,
306
+ '--json',
307
+ ]);
308
+ expect(exitCode).toBe(0);
309
+ expect(JSON.parse(stdout).overwroteSharedData).toBe(true);
310
+ });
311
+ });
312
+
313
+ describe('picopilot gfx set: input validation never touches the cart', () => {
314
+ it('a malformed grid is rejected with a nonzero exit and no write', async () => {
315
+ writeFileSync(cartPath, cartText());
316
+ const before = readFileSync(cartPath, 'utf8');
317
+ // Only 3 rows: malformed.
318
+ const {stdout, exitCode} = await runGfx([
319
+ 'gfx',
320
+ 'set',
321
+ '3',
322
+ '...\n...\n...',
323
+ cartPath,
324
+ '--json',
325
+ ]);
326
+ expect(exitCode).not.toBe(0);
327
+ expect(JSON.parse(stdout).code).toBe('invalid-grid');
328
+ expect(readFileSync(cartPath, 'utf8')).toBe(before);
329
+ });
330
+
331
+ it('a missing cart is a distinct cart-not-found error', async () => {
332
+ const grid = nibblesToGrid(makeGrid(() => 0));
333
+ const {stdout, exitCode} = await runGfx([
334
+ 'gfx',
335
+ 'set',
336
+ '3',
337
+ grid,
338
+ join(dir, 'nope.p8'),
339
+ '--json',
340
+ ]);
341
+ expect(exitCode).not.toBe(0);
342
+ expect(JSON.parse(stdout).code).toBe('cart-not-found');
343
+ });
344
+ });
345
+
346
+ describe('picopilot gfx render: the JUDGE surface (upscaled palette-accurate PNG)', () => {
347
+ it('renders a sprite to an upscaled PNG with the CORRECT palette RGB per index', async () => {
348
+ // Paint sprite 5 so pixel (c, r) = index (r*8 + c) % 16 (every colour used).
349
+ const painted = makeGrid((r, c) => (r * 8 + c) % 16);
350
+ writeCartWithSprite(5, painted);
351
+
352
+ const {stdout, exitCode} = await runGfx([
353
+ 'gfx',
354
+ 'render',
355
+ '5',
356
+ cartPath,
357
+ '--json',
358
+ ]);
359
+ expect(exitCode).toBe(0);
360
+ const out = JSON.parse(stdout);
361
+
362
+ // Reports the PNG path AND the grid (so the skill can pick view-vs-imagine).
363
+ expect(out.png).toContain('main-sprite-5.png');
364
+ expect(existsSync(out.png)).toBe(true);
365
+ expect(out.grid).toBe(nibblesToGrid(painted));
366
+ expect(out.target).toBe('5');
367
+ expect(out.width).toBe(256);
368
+ expect(out.height).toBe(256);
369
+
370
+ // Assert PIXEL BYTES: the centre of each 8x8-source block is that index's
371
+ // exact palette RGB (32x nearest-neighbour upscale).
372
+ const img = decodePngFile(out.png);
373
+ expect(img.width).toBe(256);
374
+ for (let r = 0; r < 8; r++) {
375
+ for (let c = 0; c < 8; c++) {
376
+ const index = (r * 8 + c) % 16;
377
+ expect(img.pixel(c * 32 + 16, r * 32 + 16)).toEqual(rgbOf(index));
378
+ }
379
+ }
380
+ });
381
+
382
+ it('renders the whole `sheet` similarly (256x256, palette-accurate)', async () => {
383
+ const sheet = GfxSheet.fromBody(undefined);
384
+ sheet.setSprite(
385
+ 0,
386
+ makeGrid(() => 8),
387
+ ); // red top-left
388
+ sheet.setSprite(
389
+ 255,
390
+ makeGrid(() => 10),
391
+ ); // yellow bottom-right
392
+ writeFileSync(cartPath, cartText(sheet.toBody()));
393
+
394
+ const {stdout, exitCode} = await runGfx([
395
+ 'gfx',
396
+ 'render',
397
+ 'sheet',
398
+ cartPath,
399
+ '--json',
400
+ ]);
401
+ expect(exitCode).toBe(0);
402
+ const out = JSON.parse(stdout);
403
+ expect(out.target).toBe('sheet');
404
+ expect(out.png).toContain('main-sheet.png');
405
+ expect(out.grid).toBeUndefined(); // a sheet has no single 8x8 grid
406
+ expect(out.width).toBe(256);
407
+
408
+ const img = decodePngFile(out.png);
409
+ expect(img.width).toBe(256);
410
+ expect(img.pixel(0, 0)).toEqual(rgbOf(8)); // sprite 0 top-left
411
+ expect(img.pixel(255, 255)).toEqual(rgbOf(10)); // sprite 255 bottom-right
412
+ });
413
+
414
+ it('wires the CTA loop render -> set -> render', async () => {
415
+ writeFileSync(cartPath, cartText());
416
+ const {stdout} = await runGfx(['gfx', 'render', '7', cartPath, '--json']);
417
+ const cta = JSON.stringify(JSON.parse(stdout).cta);
418
+ expect(cta).toContain('gfx set 7');
419
+ expect(cta).toContain('gfx render 7');
420
+ });
421
+
422
+ it('flags aliasesMap = true for a rendered 128-255 sprite', async () => {
423
+ writeFileSync(cartPath, cartText());
424
+ const {stdout} = await runGfx(['gfx', 'render', '200', cartPath, '--json']);
425
+ expect(JSON.parse(stdout).aliasesMap).toBe(true);
426
+ });
427
+
428
+ it('honours an explicit --out path', async () => {
429
+ writeFileSync(cartPath, cartText());
430
+ const outPath = join(dir, 'custom.png');
431
+ const {stdout, exitCode} = await runGfx([
432
+ 'gfx',
433
+ 'render',
434
+ '0',
435
+ cartPath,
436
+ '--out',
437
+ outPath,
438
+ '--json',
439
+ ]);
440
+ expect(exitCode).toBe(0);
441
+ expect(JSON.parse(stdout).png).toBe(outPath);
442
+ expect(existsSync(outPath)).toBe(true);
443
+ });
444
+
445
+ it('rejects a bad target with a nonzero exit and writes no file', async () => {
446
+ writeFileSync(cartPath, cartText());
447
+ const {stdout, exitCode} = await runGfx([
448
+ 'gfx',
449
+ 'render',
450
+ 'nope',
451
+ cartPath,
452
+ '--json',
453
+ ]);
454
+ expect(exitCode).not.toBe(0);
455
+ expect(JSON.parse(stdout).code).toBe('invalid-target');
456
+ expect(existsSync(join(dir, 'main-sprite-nope.png'))).toBe(false);
457
+ });
458
+
459
+ it('a missing cart is a distinct cart-not-found error', async () => {
460
+ const {stdout, exitCode} = await runGfx([
461
+ 'gfx',
462
+ 'render',
463
+ '0',
464
+ join(dir, 'nope.p8'),
465
+ '--json',
466
+ ]);
467
+ expect(exitCode).not.toBe(0);
468
+ expect(JSON.parse(stdout).code).toBe('cart-not-found');
469
+ });
470
+ });