picopilot 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +4 -0
- package/dist/bin.js.map +1 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +59 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/audio.d.ts +17 -0
- package/dist/commands/audio.d.ts.map +1 -0
- package/dist/commands/audio.js +315 -0
- package/dist/commands/audio.js.map +1 -0
- package/dist/commands/export.d.ts +36 -0
- package/dist/commands/export.d.ts.map +1 -0
- package/dist/commands/export.js +257 -0
- package/dist/commands/export.js.map +1 -0
- package/dist/commands/gfx.d.ts +28 -0
- package/dist/commands/gfx.d.ts.map +1 -0
- package/dist/commands/gfx.js +521 -0
- package/dist/commands/gfx.js.map +1 -0
- package/dist/commands/init.d.ts +34 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +151 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/lint.d.ts +20 -0
- package/dist/commands/lint.d.ts.map +1 -0
- package/dist/commands/lint.js +90 -0
- package/dist/commands/lint.js.map +1 -0
- package/dist/commands/minify.d.ts +20 -0
- package/dist/commands/minify.d.ts.map +1 -0
- package/dist/commands/minify.js +129 -0
- package/dist/commands/minify.js.map +1 -0
- package/dist/commands/music.d.ts +13 -0
- package/dist/commands/music.d.ts.map +1 -0
- package/dist/commands/music.js +241 -0
- package/dist/commands/music.js.map +1 -0
- package/dist/commands/playtest.d.ts +29 -0
- package/dist/commands/playtest.d.ts.map +1 -0
- package/dist/commands/playtest.js +631 -0
- package/dist/commands/playtest.js.map +1 -0
- package/dist/commands/run.d.ts +30 -0
- package/dist/commands/run.d.ts.map +1 -0
- package/dist/commands/run.js +202 -0
- package/dist/commands/run.js.map +1 -0
- package/dist/commands/serve.d.ts +50 -0
- package/dist/commands/serve.d.ts.map +1 -0
- package/dist/commands/serve.js +213 -0
- package/dist/commands/serve.js.map +1 -0
- package/dist/commands/sfx.d.ts +13 -0
- package/dist/commands/sfx.d.ts.map +1 -0
- package/dist/commands/sfx.js +254 -0
- package/dist/commands/sfx.js.map +1 -0
- package/dist/commands/tokens.d.ts +36 -0
- package/dist/commands/tokens.d.ts.map +1 -0
- package/dist/commands/tokens.js +129 -0
- package/dist/commands/tokens.js.map +1 -0
- package/dist/commands/verify.d.ts +57 -0
- package/dist/commands/verify.d.ts.map +1 -0
- package/dist/commands/verify.js +213 -0
- package/dist/commands/verify.js.map +1 -0
- package/dist/commands/version.d.ts +12 -0
- package/dist/commands/version.d.ts.map +1 -0
- package/dist/commands/version.js +24 -0
- package/dist/commands/version.js.map +1 -0
- package/dist/engine/audio/index.d.ts +14 -0
- package/dist/engine/audio/index.d.ts.map +1 -0
- package/dist/engine/audio/index.js +14 -0
- package/dist/engine/audio/index.js.map +1 -0
- package/dist/engine/audio/music.d.ts +116 -0
- package/dist/engine/audio/music.d.ts.map +1 -0
- package/dist/engine/audio/music.js +138 -0
- package/dist/engine/audio/music.js.map +1 -0
- package/dist/engine/audio/section.d.ts +34 -0
- package/dist/engine/audio/section.d.ts.map +1 -0
- package/dist/engine/audio/section.js +67 -0
- package/dist/engine/audio/section.js.map +1 -0
- package/dist/engine/audio/sfx.d.ts +141 -0
- package/dist/engine/audio/sfx.d.ts.map +1 -0
- package/dist/engine/audio/sfx.js +506 -0
- package/dist/engine/audio/sfx.js.map +1 -0
- package/dist/engine/cart/cart.d.ts +75 -0
- package/dist/engine/cart/cart.d.ts.map +1 -0
- package/dist/engine/cart/cart.js +153 -0
- package/dist/engine/cart/cart.js.map +1 -0
- package/dist/engine/cart/errors.d.ts +29 -0
- package/dist/engine/cart/errors.d.ts.map +1 -0
- package/dist/engine/cart/errors.js +26 -0
- package/dist/engine/cart/errors.js.map +1 -0
- package/dist/engine/cart/gfx.d.ts +79 -0
- package/dist/engine/cart/gfx.d.ts.map +1 -0
- package/dist/engine/cart/gfx.js +171 -0
- package/dist/engine/cart/gfx.js.map +1 -0
- package/dist/engine/cart/index.d.ts +15 -0
- package/dist/engine/cart/index.d.ts.map +1 -0
- package/dist/engine/cart/index.js +15 -0
- package/dist/engine/cart/index.js.map +1 -0
- package/dist/engine/cart/map.d.ts +46 -0
- package/dist/engine/cart/map.d.ts.map +1 -0
- package/dist/engine/cart/map.js +118 -0
- package/dist/engine/cart/map.js.map +1 -0
- package/dist/engine/config/config.d.ts +74 -0
- package/dist/engine/config/config.d.ts.map +1 -0
- package/dist/engine/config/config.js +103 -0
- package/dist/engine/config/config.js.map +1 -0
- package/dist/engine/config/index.d.ts +7 -0
- package/dist/engine/config/index.d.ts.map +1 -0
- package/dist/engine/config/index.js +7 -0
- package/dist/engine/config/index.js.map +1 -0
- package/dist/engine/gfx/grid.d.ts +51 -0
- package/dist/engine/gfx/grid.d.ts.map +1 -0
- package/dist/engine/gfx/grid.js +116 -0
- package/dist/engine/gfx/grid.js.map +1 -0
- package/dist/engine/gfx/index.d.ts +17 -0
- package/dist/engine/gfx/index.d.ts.map +1 -0
- package/dist/engine/gfx/index.js +17 -0
- package/dist/engine/gfx/index.js.map +1 -0
- package/dist/engine/gfx/label.d.ts +34 -0
- package/dist/engine/gfx/label.d.ts.map +1 -0
- package/dist/engine/gfx/label.js +190 -0
- package/dist/engine/gfx/label.js.map +1 -0
- package/dist/engine/gfx/overlap.d.ts +42 -0
- package/dist/engine/gfx/overlap.d.ts.map +1 -0
- package/dist/engine/gfx/overlap.js +57 -0
- package/dist/engine/gfx/overlap.js.map +1 -0
- package/dist/engine/gfx/palette.d.ts +29 -0
- package/dist/engine/gfx/palette.d.ts.map +1 -0
- package/dist/engine/gfx/palette.js +46 -0
- package/dist/engine/gfx/palette.js.map +1 -0
- package/dist/engine/gfx/png.d.ts +31 -0
- package/dist/engine/gfx/png.d.ts.map +1 -0
- package/dist/engine/gfx/png.js +165 -0
- package/dist/engine/gfx/png.js.map +1 -0
- package/dist/engine/gfx/render.d.ts +42 -0
- package/dist/engine/gfx/render.d.ts.map +1 -0
- package/dist/engine/gfx/render.js +72 -0
- package/dist/engine/gfx/render.js.map +1 -0
- package/dist/engine/index.d.ts +22 -0
- package/dist/engine/index.d.ts.map +1 -0
- package/dist/engine/index.js +22 -0
- package/dist/engine/index.js.map +1 -0
- package/dist/engine/pico8/adapter.d.ts +267 -0
- package/dist/engine/pico8/adapter.d.ts.map +1 -0
- package/dist/engine/pico8/adapter.js +37 -0
- package/dist/engine/pico8/adapter.js.map +1 -0
- package/dist/engine/pico8/drive.d.ts +308 -0
- package/dist/engine/pico8/drive.d.ts.map +1 -0
- package/dist/engine/pico8/drive.js +581 -0
- package/dist/engine/pico8/drive.js.map +1 -0
- package/dist/engine/pico8/harness.d.ts +146 -0
- package/dist/engine/pico8/harness.d.ts.map +1 -0
- package/dist/engine/pico8/harness.js +251 -0
- package/dist/engine/pico8/harness.js.map +1 -0
- package/dist/engine/pico8/index.d.ts +21 -0
- package/dist/engine/pico8/index.d.ts.map +1 -0
- package/dist/engine/pico8/index.js +21 -0
- package/dist/engine/pico8/index.js.map +1 -0
- package/dist/engine/pico8/sentinel.d.ts +63 -0
- package/dist/engine/pico8/sentinel.d.ts.map +1 -0
- package/dist/engine/pico8/sentinel.js +86 -0
- package/dist/engine/pico8/sentinel.js.map +1 -0
- package/dist/engine/pico8/session-daemon-entry.d.ts +10 -0
- package/dist/engine/pico8/session-daemon-entry.d.ts.map +1 -0
- package/dist/engine/pico8/session-daemon-entry.js +11 -0
- package/dist/engine/pico8/session-daemon-entry.js.map +1 -0
- package/dist/engine/pico8/session-daemon-main.d.ts +44 -0
- package/dist/engine/pico8/session-daemon-main.d.ts.map +1 -0
- package/dist/engine/pico8/session-daemon-main.js +71 -0
- package/dist/engine/pico8/session-daemon-main.js.map +1 -0
- package/dist/engine/pico8/session-daemon.d.ts +68 -0
- package/dist/engine/pico8/session-daemon.d.ts.map +1 -0
- package/dist/engine/pico8/session-daemon.js +264 -0
- package/dist/engine/pico8/session-daemon.js.map +1 -0
- package/dist/engine/pico8/session.d.ts +140 -0
- package/dist/engine/pico8/session.d.ts.map +1 -0
- package/dist/engine/pico8/session.js +250 -0
- package/dist/engine/pico8/session.js.map +1 -0
- package/dist/engine/pico8/shell.d.ts +103 -0
- package/dist/engine/pico8/shell.d.ts.map +1 -0
- package/dist/engine/pico8/shell.js +324 -0
- package/dist/engine/pico8/shell.js.map +1 -0
- package/dist/engine/pico8/supervisor.d.ts +137 -0
- package/dist/engine/pico8/supervisor.d.ts.map +1 -0
- package/dist/engine/pico8/supervisor.js +140 -0
- package/dist/engine/pico8/supervisor.js.map +1 -0
- package/dist/engine/scaffold/index.d.ts +8 -0
- package/dist/engine/scaffold/index.d.ts.map +1 -0
- package/dist/engine/scaffold/index.js +8 -0
- package/dist/engine/scaffold/index.js.map +1 -0
- package/dist/engine/scaffold/scaffold.d.ts +49 -0
- package/dist/engine/scaffold/scaffold.d.ts.map +1 -0
- package/dist/engine/scaffold/scaffold.js +217 -0
- package/dist/engine/scaffold/scaffold.js.map +1 -0
- package/dist/engine/shrinko/adapter.d.ts +167 -0
- package/dist/engine/shrinko/adapter.d.ts.map +1 -0
- package/dist/engine/shrinko/adapter.js +107 -0
- package/dist/engine/shrinko/adapter.js.map +1 -0
- package/dist/engine/shrinko/index.d.ts +13 -0
- package/dist/engine/shrinko/index.d.ts.map +1 -0
- package/dist/engine/shrinko/index.js +13 -0
- package/dist/engine/shrinko/index.js.map +1 -0
- package/dist/engine/shrinko/shell.d.ts +69 -0
- package/dist/engine/shrinko/shell.d.ts.map +1 -0
- package/dist/engine/shrinko/shell.js +156 -0
- package/dist/engine/shrinko/shell.js.map +1 -0
- package/dist/engine/skills/index.d.ts +8 -0
- package/dist/engine/skills/index.d.ts.map +1 -0
- package/dist/engine/skills/index.js +8 -0
- package/dist/engine/skills/index.js.map +1 -0
- package/dist/engine/skills/skills.d.ts +104 -0
- package/dist/engine/skills/skills.d.ts.map +1 -0
- package/dist/engine/skills/skills.js +149 -0
- package/dist/engine/skills/skills.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +19 -0
- package/dist/version.js.map +1 -0
- package/package.json +48 -2
- package/src/bin.ts +4 -0
- package/src/cli.ts +61 -0
- package/src/commands/audio.test.ts +280 -0
- package/src/commands/audio.ts +410 -0
- package/src/commands/export.test.ts +310 -0
- package/src/commands/export.ts +316 -0
- package/src/commands/gfx-io.test.ts +178 -0
- package/src/commands/gfx.test.ts +470 -0
- package/src/commands/gfx.ts +645 -0
- package/src/commands/init.test.ts +460 -0
- package/src/commands/init.ts +193 -0
- package/src/commands/lint.test.ts +137 -0
- package/src/commands/lint.ts +117 -0
- package/src/commands/minify.test.ts +166 -0
- package/src/commands/minify.ts +156 -0
- package/src/commands/music.test.ts +272 -0
- package/src/commands/music.ts +288 -0
- package/src/commands/playtest.test.ts +317 -0
- package/src/commands/playtest.ts +773 -0
- package/src/commands/run.test.ts +238 -0
- package/src/commands/run.ts +259 -0
- package/src/commands/serve.test.ts +218 -0
- package/src/commands/serve.ts +272 -0
- package/src/commands/sfx.test.ts +349 -0
- package/src/commands/sfx.ts +300 -0
- package/src/commands/tokens.test.ts +212 -0
- package/src/commands/tokens.ts +168 -0
- package/src/commands/verify.test.ts +238 -0
- package/src/commands/verify.ts +256 -0
- package/src/commands/version.test.ts +70 -0
- package/src/commands/version.ts +24 -0
- package/src/engine/audio/index.ts +47 -0
- package/src/engine/audio/music.test.ts +188 -0
- package/src/engine/audio/music.ts +218 -0
- package/src/engine/audio/section.test.ts +124 -0
- package/src/engine/audio/section.ts +79 -0
- package/src/engine/audio/sfx.test.ts +410 -0
- package/src/engine/audio/sfx.ts +694 -0
- package/src/engine/cart/cart.test.ts +304 -0
- package/src/engine/cart/cart.ts +211 -0
- package/src/engine/cart/errors.ts +39 -0
- package/src/engine/cart/fixtures/crlf.p8 +6 -0
- package/src/engine/cart/fixtures/full.p8 +174 -0
- package/src/engine/cart/fixtures/header-only.p8 +2 -0
- package/src/engine/cart/fixtures/malformed-no-header.p8 +2 -0
- package/src/engine/cart/fixtures/minimal.p8 +10 -0
- package/src/engine/cart/fixtures/no-trailing-newline.p8 +6 -0
- package/src/engine/cart/gfx.ts +195 -0
- package/src/engine/cart/index.ts +27 -0
- package/src/engine/cart/map.ts +134 -0
- package/src/engine/config/config.ts +115 -0
- package/src/engine/config/index.ts +12 -0
- package/src/engine/gfx/grid.test.ts +128 -0
- package/src/engine/gfx/grid.ts +136 -0
- package/src/engine/gfx/index.ts +34 -0
- package/src/engine/gfx/label.test.ts +105 -0
- package/src/engine/gfx/label.ts +229 -0
- package/src/engine/gfx/overlap.test.ts +71 -0
- package/src/engine/gfx/overlap.ts +66 -0
- package/src/engine/gfx/palette.test.ts +35 -0
- package/src/engine/gfx/palette.ts +54 -0
- package/src/engine/gfx/png.test.ts +111 -0
- package/src/engine/gfx/png.ts +196 -0
- package/src/engine/gfx/render.test.ts +180 -0
- package/src/engine/gfx/render.ts +106 -0
- package/src/engine/index.ts +21 -0
- package/src/engine/pico8/adapter.ts +303 -0
- package/src/engine/pico8/drive.test.ts +415 -0
- package/src/engine/pico8/drive.ts +729 -0
- package/src/engine/pico8/harness.test.ts +181 -0
- package/src/engine/pico8/harness.ts +354 -0
- package/src/engine/pico8/index.ts +20 -0
- package/src/engine/pico8/sentinel.test.ts +80 -0
- package/src/engine/pico8/sentinel.ts +93 -0
- package/src/engine/pico8/session-daemon-entry.ts +12 -0
- package/src/engine/pico8/session-daemon-main.ts +98 -0
- package/src/engine/pico8/session-daemon.test.ts +212 -0
- package/src/engine/pico8/session-daemon.ts +306 -0
- package/src/engine/pico8/session.test.ts +239 -0
- package/src/engine/pico8/session.ts +321 -0
- package/src/engine/pico8/shell.test.ts +583 -0
- package/src/engine/pico8/shell.ts +412 -0
- package/src/engine/pico8/supervisor.test.ts +105 -0
- package/src/engine/pico8/supervisor.ts +197 -0
- package/src/engine/scaffold/index.ts +15 -0
- package/src/engine/scaffold/scaffold.ts +236 -0
- package/src/engine/shrinko/adapter.ts +241 -0
- package/src/engine/shrinko/fixtures/count-no-compressed.txt +2 -0
- package/src/engine/shrinko/fixtures/count-over-budget.txt +3 -0
- package/src/engine/shrinko/fixtures/count-under-budget.txt +3 -0
- package/src/engine/shrinko/index.ts +33 -0
- package/src/engine/shrinko/shell.ts +224 -0
- package/src/engine/shrinko/shrinko.test.ts +182 -0
- package/src/engine/skills/index.ts +14 -0
- package/src/engine/skills/skills.test.ts +376 -0
- package/src/engine/skills/skills.ts +205 -0
- package/src/index.ts +13 -0
- package/src/skills/game-design-reference/SKILL.md +70 -0
- package/src/skills/game-jam/SKILL.md +45 -0
- package/src/skills/picopilot-art/SKILL.md +62 -0
- package/src/skills/picopilot-audio/SKILL.md +194 -0
- package/src/skills/picopilot-code/SKILL.md +97 -0
- package/src/skills/picopilot-code/reference/README.md +42 -0
- package/src/skills/picopilot-code/reference/mode7-racing.md +95 -0
- package/src/skills/picopilot-code/reference/platformer.md +119 -0
- package/src/skills/picopilot-code/reference/puzzle-grid.md +106 -0
- package/src/skills/picopilot-code/reference/rpg-menus-dialog.md +122 -0
- package/src/skills/picopilot-code/reference/top-down-adventure.md +117 -0
- package/src/skills/picopilot-code/reference/twin-stick-arcade.md +128 -0
- package/src/skills/picopilot-debug/SKILL.md +195 -0
- package/src/skills/picopilot-overview/SKILL.md +52 -0
- package/src/version.ts +22 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import {execFileSync} from 'node:child_process';
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
statSync,
|
|
8
|
+
} from 'node:fs';
|
|
9
|
+
import {homedir, tmpdir} from 'node:os';
|
|
10
|
+
import {join} from 'node:path';
|
|
11
|
+
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
|
12
|
+
import {createCli} from '../../cli.js';
|
|
13
|
+
import {installSkills, SKILL_NAMES, skillsSourceDir} from './index.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Reads an authored skill's SKILL.md straight from the on-disk source dir, the
|
|
17
|
+
* same files incur's `include` glob ships. Skill GENERATION output is these
|
|
18
|
+
* files, so the generation tests assert against them directly.
|
|
19
|
+
*/
|
|
20
|
+
function readSkill(name: string): string {
|
|
21
|
+
return readFileSync(join(skillsSourceDir(), name, 'SKILL.md'), 'utf8');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('picopilot skills: generation output (the authored discipline skills)', () => {
|
|
25
|
+
it('ships exactly the named authored skills, each with valid frontmatter', () => {
|
|
26
|
+
expect([...SKILL_NAMES].sort()).toEqual(
|
|
27
|
+
[
|
|
28
|
+
'picopilot-art',
|
|
29
|
+
'picopilot-audio',
|
|
30
|
+
'picopilot-code',
|
|
31
|
+
'picopilot-debug',
|
|
32
|
+
'picopilot-overview',
|
|
33
|
+
'game-design-reference',
|
|
34
|
+
'game-jam',
|
|
35
|
+
].sort(),
|
|
36
|
+
);
|
|
37
|
+
for (const name of SKILL_NAMES) {
|
|
38
|
+
const md = readSkill(name);
|
|
39
|
+
// incur discovers a skill by its `name:` frontmatter line; a `description:`
|
|
40
|
+
// is what makes it load on-demand. Both must be present and match.
|
|
41
|
+
expect(md).toMatch(new RegExp(`^name:\\s*${name}$`, 'm'));
|
|
42
|
+
expect(md).toMatch(/^description:\s*.+/m);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('game-design-reference is a USER-INVOKED reference body (no per-turn context load)', () => {
|
|
47
|
+
const md = readSkill('game-design-reference');
|
|
48
|
+
// The invocation stance: only game-design-reference disables model
|
|
49
|
+
// invocation, so it is reached by pointer (from game-jam), never fired on a
|
|
50
|
+
// vague trigger, and costs no context every turn. game-jam stays
|
|
51
|
+
// model-invoked (no such line).
|
|
52
|
+
expect(md).toMatch(/^disable-model-invocation:\s*true$/m);
|
|
53
|
+
expect(readSkill('game-jam')).not.toMatch(/^disable-model-invocation:/m);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('game-design-reference carries the universal principles + method-not-menu originality', () => {
|
|
57
|
+
const md = readSkill('game-design-reference');
|
|
58
|
+
// Fairness: BOTH reachability AND the skipped hazard-avoidability check.
|
|
59
|
+
expect(md.toLowerCase()).toContain('reachability');
|
|
60
|
+
expect(md.toLowerCase()).toContain('avoidability');
|
|
61
|
+
// Human reaction budget in PICO-8's 30fps terms.
|
|
62
|
+
expect(md).toContain('200-300ms');
|
|
63
|
+
expect(md).toContain('frame-perfect');
|
|
64
|
+
// Visibility (draw what you spr()).
|
|
65
|
+
expect(md).toContain('spr(');
|
|
66
|
+
// Originality is a METHOD with a self-check, and lists NO concrete
|
|
67
|
+
// mechanics (the answer-menu trap). Assert the caveat that guards it.
|
|
68
|
+
expect(md.toLowerCase()).toContain('self-check');
|
|
69
|
+
expect(md.toLowerCase()).toMatch(/no (example |concrete )?mechanic/);
|
|
70
|
+
// Progression as a universal principle: a game GOES SOMEWHERE, expressed
|
|
71
|
+
// as a ladder of FORMS (ramp -> waves -> levels -> worlds). The situated
|
|
72
|
+
// "how far up given the clock" call lives in game-jam, not here.
|
|
73
|
+
expect(md.toLowerCase()).toContain('progression');
|
|
74
|
+
expect(md.toLowerCase()).toContain('goes somewhere');
|
|
75
|
+
expect(md.toLowerCase()).toContain('waves');
|
|
76
|
+
expect(md.toLowerCase()).toMatch(/worlds/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('game-jam carries the clock discipline and POINTS AT game-design-reference (does not re-teach it)', () => {
|
|
80
|
+
const md = readSkill('game-jam');
|
|
81
|
+
// Composition: it points at the reference body by name.
|
|
82
|
+
expect(md).toContain('game-design-reference');
|
|
83
|
+
// REGRESSION GUARD (see the FLIPRUN observation + re-grill): a
|
|
84
|
+
// disable-model-invocation reference skill is NOT auto-loaded by pi, so a
|
|
85
|
+
// bare-name "read and apply X" pointer gets SKIPPED (the agent never opens
|
|
86
|
+
// the file). The reliable pattern (Matt's, verified on pi + Claude Code) is
|
|
87
|
+
// an EXPLICIT relative path resolved against THIS skill's dir + an
|
|
88
|
+
// imperative to read/load it. Assert the pointer has both, so it never
|
|
89
|
+
// regresses to the weak wording that made the reference body unreachable.
|
|
90
|
+
expect(md).toContain('../game-design-reference/SKILL.md');
|
|
91
|
+
expect(md.toLowerCase()).toMatch(/\b(read|load)\b/);
|
|
92
|
+
// And game-design-reference stays user-invoked (the reason the explicit
|
|
93
|
+
// pointer is REQUIRED): if someone flips it to model-invoked, this pairing
|
|
94
|
+
// assumption changes and the pointer guard should be revisited.
|
|
95
|
+
expect(readSkill('game-design-reference')).toMatch(
|
|
96
|
+
/^disable-model-invocation:\s*true$/m,
|
|
97
|
+
);
|
|
98
|
+
// The jam-specific clock discipline (the slice/deepen/triage phases).
|
|
99
|
+
expect(md.toLowerCase()).toContain('playable slice');
|
|
100
|
+
expect(md.toLowerCase()).toContain('triage');
|
|
101
|
+
// The situated design calls the clock forces (jam-specific, live nowhere
|
|
102
|
+
// else): the dead state and the frame-perfect superhuman level.
|
|
103
|
+
expect(md.toLowerCase()).toContain('dead state');
|
|
104
|
+
expect(md).toContain('frame-perfect');
|
|
105
|
+
// Budget-scaled ambition (grilled: one skill, no per-tier files): scale to
|
|
106
|
+
// the clock, reach up the progression ladder as far as you can FINISH,
|
|
107
|
+
// deepen > polish on a long budget. Intent-framed, not a level-count menu.
|
|
108
|
+
expect(md.toLowerCase()).toContain('scale your ambition');
|
|
109
|
+
expect(md.toLowerCase()).toContain('progression ladder');
|
|
110
|
+
expect(md.toLowerCase()).toMatch(/finish/);
|
|
111
|
+
// It interprets the theme itself (decision B) and ships JAM.md.
|
|
112
|
+
expect(md).toContain('JAM.md');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('picopilot-overview carries the #include discipline', () => {
|
|
116
|
+
const md = readSkill('picopilot-overview');
|
|
117
|
+
expect(md).toContain('#include main.lua');
|
|
118
|
+
expect(md).toContain('main.lua');
|
|
119
|
+
// Edit the Lua, never the binary sections (allow line wrapping between words).
|
|
120
|
+
expect(md).toMatch(/never\s+hand-write/i);
|
|
121
|
+
expect(md).toContain('__gfx__');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('picopilot-code carries the token-breakdown discipline', () => {
|
|
125
|
+
const md = readSkill('picopilot-code');
|
|
126
|
+
expect(md).toContain('8,192');
|
|
127
|
+
expect(md).toContain('picopilot tokens');
|
|
128
|
+
expect(md).toContain('picopilot minify');
|
|
129
|
+
// A PICO-8 shorthand the skill teaches for reclaiming budget.
|
|
130
|
+
expect(md).toContain('+=');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('picopilot-art carries the render→look→set→render loop AND the non-multimodal fallback', () => {
|
|
134
|
+
const md = readSkill('picopilot-art');
|
|
135
|
+
// The art loop.
|
|
136
|
+
expect(md).toContain('gfx render');
|
|
137
|
+
expect(md).toContain('gfx set');
|
|
138
|
+
expect(md).toContain('gfx show');
|
|
139
|
+
// The load-bearing non-multimodal fallback: view the PNG if you can read
|
|
140
|
+
// images, else reason over the grid.
|
|
141
|
+
expect(md.toLowerCase()).toContain('cannot read images');
|
|
142
|
+
expect(md.toLowerCase()).toMatch(/reason(ing)?\s+over the[\s\S]*grid/i);
|
|
143
|
+
// The gfx/map overlap smart-refuse discipline.
|
|
144
|
+
expect(md.toLowerCase()).toContain('overlap');
|
|
145
|
+
expect(md).toContain('allowMapOverlap');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('picopilot-audio carries the picopilot-MML authoring model + the honest record-based render framing', () => {
|
|
149
|
+
const md = readSkill('picopilot-audio');
|
|
150
|
+
expect(md).toContain('picopilot-MML');
|
|
151
|
+
// ABC is explicitly NOT used.
|
|
152
|
+
expect(md).toContain('ABC');
|
|
153
|
+
// Music is assembled structurally from SFX references.
|
|
154
|
+
expect(md.toLowerCase()).toContain('structural');
|
|
155
|
+
// Honest scoping (ADR-0009): audio-to-WAV is a RECORDING, NOT an offline
|
|
156
|
+
// export, and needs a real audio session (a developer machine).
|
|
157
|
+
expect(md.toLowerCase()).toContain('recording');
|
|
158
|
+
expect(md.toLowerCase()).toContain('offline export');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('picopilot-debug carries the static-gate + run loop and the dependency boundaries', () => {
|
|
162
|
+
const md = readSkill('picopilot-debug');
|
|
163
|
+
expect(md).toContain('picopilot verify');
|
|
164
|
+
expect(md).toContain('picopilot run');
|
|
165
|
+
expect(md).toContain('printh');
|
|
166
|
+
expect(md).toContain('gate-incapable');
|
|
167
|
+
expect(md).toContain('pico8-not-found');
|
|
168
|
+
expect(md).toContain('shrinko-not-found');
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Recursively snapshots a directory tree as a sorted list of `relpath:mtimeMs`
|
|
174
|
+
* for files and `relpath/` for dirs, so a before/after comparison catches ANY
|
|
175
|
+
* new/removed entry AND any modified file. Returns `['<absent>']` when the dir
|
|
176
|
+
* does not exist (so "absent stays absent" is also asserted).
|
|
177
|
+
*/
|
|
178
|
+
function snapshot(root: string): string[] {
|
|
179
|
+
if (!existsSync(root)) return ['<absent>'];
|
|
180
|
+
const out: string[] = [];
|
|
181
|
+
const walk = (dir: string, rel: string) => {
|
|
182
|
+
for (const entry of readdirSync(dir, {withFileTypes: true}).sort((a, b) =>
|
|
183
|
+
a.name.localeCompare(b.name),
|
|
184
|
+
)) {
|
|
185
|
+
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
|
|
186
|
+
const abs = join(dir, entry.name);
|
|
187
|
+
if (entry.isDirectory()) {
|
|
188
|
+
out.push(`${relPath}/`);
|
|
189
|
+
walk(abs, relPath);
|
|
190
|
+
} else {
|
|
191
|
+
// lstat, not stat: never follow a symlink out of the snapshotted tree.
|
|
192
|
+
out.push(
|
|
193
|
+
`${relPath}:${statSync(abs, {throwIfNoEntry: false})?.mtimeMs ?? 'gone'}`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
walk(root, '');
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The REAL agent skill dirs this test must prove are byte-untouched. incur
|
|
204
|
+
* installs canonically to `~/.agents/skills` and symlinks detected non-universal
|
|
205
|
+
* agents (e.g. `~/.claude/skills`), so those are the dirs the isolation contract
|
|
206
|
+
* guards. Resolved from the real `homedir()` (which is what incur reads).
|
|
207
|
+
*/
|
|
208
|
+
const REAL_DIRS = [
|
|
209
|
+
join(homedir(), '.agents', 'skills'),
|
|
210
|
+
join(homedir(), '.claude', 'skills'),
|
|
211
|
+
join(homedir(), '.codex', 'skills'),
|
|
212
|
+
join(homedir(), '.cursor', 'skills'),
|
|
213
|
+
];
|
|
214
|
+
|
|
215
|
+
describe('picopilot skills: isolated --install-skills (the load-bearing shared-write test)', () => {
|
|
216
|
+
let target: string;
|
|
217
|
+
let before: Map<string, string[]>;
|
|
218
|
+
|
|
219
|
+
beforeEach(() => {
|
|
220
|
+
target = mkdtempSync(join(tmpdir(), 'picopilot-skills-install-'));
|
|
221
|
+
// Snapshot the REAL dirs before the install so we can prove they never moved.
|
|
222
|
+
before = new Map(REAL_DIRS.map((d) => [d, snapshot(d)]));
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
afterEach(() => {
|
|
226
|
+
try {
|
|
227
|
+
execFileSync('rm', ['-rf', target]);
|
|
228
|
+
} catch {
|
|
229
|
+
// ignore
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('installs into the redirected temp target, NOT a real dir', async () => {
|
|
234
|
+
// The lever: global=false + cwd=temp installs under <cwd>/.agents/skills.
|
|
235
|
+
const result = await installSkills({
|
|
236
|
+
cli: createCli(),
|
|
237
|
+
global: false,
|
|
238
|
+
cwd: target,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// Every named discipline skill landed (plus the per-command skills).
|
|
242
|
+
for (const name of SKILL_NAMES) {
|
|
243
|
+
expect(result.skills).toContain(name);
|
|
244
|
+
expect(
|
|
245
|
+
existsSync(join(target, '.agents', 'skills', name, 'SKILL.md')),
|
|
246
|
+
).toBe(true);
|
|
247
|
+
}
|
|
248
|
+
// Every reported canonical path is INSIDE the temp target.
|
|
249
|
+
for (const p of result.paths) {
|
|
250
|
+
expect(p.startsWith(target)).toBe(true);
|
|
251
|
+
}
|
|
252
|
+
// Any wired non-universal agent dir is also inside the temp target, never a
|
|
253
|
+
// real home dir (the symlinks go under cwd for a project install).
|
|
254
|
+
for (const a of result.agents) {
|
|
255
|
+
expect(a.path.startsWith(target)).toBe(true);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('leaves the REAL agent skill dirs BYTE-UNTOUCHED', async () => {
|
|
260
|
+
await installSkills({cli: createCli(), global: false, cwd: target});
|
|
261
|
+
|
|
262
|
+
// The real dirs are identical before and after: no new entry, nothing
|
|
263
|
+
// modified, and an absent dir stays absent. This is the pollution guard.
|
|
264
|
+
for (const dir of REAL_DIRS) {
|
|
265
|
+
expect(snapshot(dir)).toEqual(before.get(dir));
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('a custom sourceDir proves the source is decoupled from the install target', async () => {
|
|
270
|
+
// Point the include SOURCE at a fixture with a single skill; install TARGET
|
|
271
|
+
// stays the temp dir. Only that fixture's authored skill (plus the
|
|
272
|
+
// per-command skills) should appear, proving cwd and source are independent.
|
|
273
|
+
const source = mkdtempSync(join(tmpdir(), 'picopilot-skills-src-'));
|
|
274
|
+
try {
|
|
275
|
+
const skillDir = join(source, 'picopilot-fixture');
|
|
276
|
+
execFileSync('mkdir', ['-p', skillDir]);
|
|
277
|
+
execFileSync('sh', [
|
|
278
|
+
'-c',
|
|
279
|
+
`printf '%s\\n' '---' 'name: picopilot-fixture' 'description: A fixture skill.' '---' '# fixture' > ${JSON.stringify(join(skillDir, 'SKILL.md'))}`,
|
|
280
|
+
]);
|
|
281
|
+
|
|
282
|
+
const result = await installSkills({
|
|
283
|
+
cli: createCli(),
|
|
284
|
+
global: false,
|
|
285
|
+
cwd: target,
|
|
286
|
+
sourceDir: source,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
expect(result.skills).toContain('picopilot-fixture');
|
|
290
|
+
// The real authored discipline skills are NOT pulled in from this source.
|
|
291
|
+
expect(result.skills).not.toContain('picopilot-overview');
|
|
292
|
+
// Still installed into the temp target, and the real dirs stay untouched.
|
|
293
|
+
expect(
|
|
294
|
+
existsSync(
|
|
295
|
+
join(target, '.agents', 'skills', 'picopilot-fixture', 'SKILL.md'),
|
|
296
|
+
),
|
|
297
|
+
).toBe(true);
|
|
298
|
+
for (const dir of REAL_DIRS) {
|
|
299
|
+
expect(snapshot(dir)).toEqual(before.get(dir));
|
|
300
|
+
}
|
|
301
|
+
} finally {
|
|
302
|
+
execFileSync('rm', ['-rf', source]);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('ships skill RESOURCE files, not just SKILL.md (incur 0.4.10 workaround)', async () => {
|
|
307
|
+
// incur's SyncSkills.sync copies only SKILL.md; installSkills backfills the
|
|
308
|
+
// sibling resources. The genre code references under
|
|
309
|
+
// picopilot-code/reference/ must reach the installed skill dir, or the
|
|
310
|
+
// SKILL.md's on-demand pointers dangle.
|
|
311
|
+
await installSkills({cli: createCli(), global: false, cwd: target});
|
|
312
|
+
|
|
313
|
+
const codeSkill = join(target, '.agents', 'skills', 'picopilot-code');
|
|
314
|
+
expect(existsSync(join(codeSkill, 'SKILL.md'))).toBe(true);
|
|
315
|
+
for (const res of [
|
|
316
|
+
'reference/README.md',
|
|
317
|
+
'reference/platformer.md',
|
|
318
|
+
'reference/puzzle-grid.md',
|
|
319
|
+
'reference/twin-stick-arcade.md',
|
|
320
|
+
'reference/top-down-adventure.md',
|
|
321
|
+
'reference/mode7-racing.md',
|
|
322
|
+
'reference/rpg-menus-dialog.md',
|
|
323
|
+
]) {
|
|
324
|
+
expect(existsSync(join(codeSkill, res))).toBe(true);
|
|
325
|
+
}
|
|
326
|
+
// The resource content is the authored source byte-for-byte (a real copy).
|
|
327
|
+
expect(
|
|
328
|
+
readFileSync(join(codeSkill, 'reference/platformer.md'), 'utf8'),
|
|
329
|
+
).toBe(
|
|
330
|
+
readFileSync(
|
|
331
|
+
join(skillsSourceDir(), 'picopilot-code', 'reference/platformer.md'),
|
|
332
|
+
'utf8',
|
|
333
|
+
),
|
|
334
|
+
);
|
|
335
|
+
// The real dirs stay untouched (resource copy respects isolation).
|
|
336
|
+
for (const dir of REAL_DIRS) {
|
|
337
|
+
expect(snapshot(dir)).toEqual(before.get(dir));
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('copies arbitrary nested resources from a custom source, isolated', async () => {
|
|
342
|
+
// A fixture skill with a nested resource proves the copy is general (not
|
|
343
|
+
// hard-coded to picopilot-code) and stays inside the temp target.
|
|
344
|
+
const source = mkdtempSync(join(tmpdir(), 'picopilot-skills-src-'));
|
|
345
|
+
try {
|
|
346
|
+
const skillDir = join(source, 'picopilot-fixture');
|
|
347
|
+
execFileSync('mkdir', ['-p', join(skillDir, 'reference', 'deep')]);
|
|
348
|
+
execFileSync('sh', [
|
|
349
|
+
'-c',
|
|
350
|
+
`printf '%s\n' '---' 'name: picopilot-fixture' 'description: A fixture skill.' '---' '# fixture' > ${JSON.stringify(join(skillDir, 'SKILL.md'))}`,
|
|
351
|
+
]);
|
|
352
|
+
execFileSync('sh', [
|
|
353
|
+
'-c',
|
|
354
|
+
`printf 'RES' > ${JSON.stringify(join(skillDir, 'reference', 'deep', 'note.md'))}`,
|
|
355
|
+
]);
|
|
356
|
+
|
|
357
|
+
await installSkills({
|
|
358
|
+
cli: createCli(),
|
|
359
|
+
global: false,
|
|
360
|
+
cwd: target,
|
|
361
|
+
sourceDir: source,
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
const installed = join(target, '.agents', 'skills', 'picopilot-fixture');
|
|
365
|
+
expect(existsSync(join(installed, 'SKILL.md'))).toBe(true);
|
|
366
|
+
expect(
|
|
367
|
+
readFileSync(join(installed, 'reference', 'deep', 'note.md'), 'utf8'),
|
|
368
|
+
).toBe('RES');
|
|
369
|
+
for (const dir of REAL_DIRS) {
|
|
370
|
+
expect(snapshot(dir)).toEqual(before.get(dir));
|
|
371
|
+
}
|
|
372
|
+
} finally {
|
|
373
|
+
execFileSync('rm', ['-rf', source]);
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import {cpSync, existsSync, readdirSync} from 'node:fs';
|
|
2
|
+
import {basename, dirname, join} from 'node:path';
|
|
3
|
+
import {fileURLToPath} from 'node:url';
|
|
4
|
+
import {Cli, SyncSkills} from 'incur';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The engine behind `picopilot init --install-skills`: the ONE picopilot path
|
|
8
|
+
* that writes to a SHARED/GLOBAL location. incur's `skills add` defaults to a
|
|
9
|
+
* GLOBAL install (into `~/.agents/skills` plus symlinks into detected agent
|
|
10
|
+
* dirs like `~/.claude/skills`), so this module is where the work-contract's
|
|
11
|
+
* shared-write isolation obligation is discharged: every knob that decides the
|
|
12
|
+
* install TARGET (`global`, `cwd`) is a parameter a test can redirect to a temp
|
|
13
|
+
* dir, so installing skills can never pollute the real environment in tests.
|
|
14
|
+
*
|
|
15
|
+
* The install runs IN-PROCESS through incur's `SyncSkills.sync`. NOTE (incur
|
|
16
|
+
* 0.4.10): `SyncSkills.sync` / the `skills add` built-in resolve their target
|
|
17
|
+
* from `os.homedir()` and `process.env` captured AT MODULE LOAD, NOT from the
|
|
18
|
+
* `env` DI you can pass to `cli.serve(argv, { env })`, so overriding the serve
|
|
19
|
+
* env does NOT move the install target. The effective in-process lever is
|
|
20
|
+
* `global: false` + `cwd: <temp dir>`, which installs under `<cwd>/.agents/skills`
|
|
21
|
+
* (and `<cwd>/.claude/skills` etc.) and leaves the real dirs byte-untouched.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The authored picopilot skills (US #20 discipline skills + the game skills),
|
|
26
|
+
* as SKILL.md files under {@link skillsSourceDir}. They load on-demand by
|
|
27
|
+
* concern and carry the hard-won workflow knowledge (the `#include` discipline,
|
|
28
|
+
* the token breakdown, the `gfx render`→look→`gfx set`→fix loop incl. the
|
|
29
|
+
* non-multimodal fallback, composing in picopilot-MML) so an agent gets it
|
|
30
|
+
* without bloating every turn.
|
|
31
|
+
*
|
|
32
|
+
* The GAME skills are a composed pair (grilled design in
|
|
33
|
+
* work/notes/ideas/game-jam-design-skill.md): `game-jam` (model-invoked, the
|
|
34
|
+
* clock+theme discipline) POINTS AT `game-design-reference` (a user-invoked
|
|
35
|
+
* reference body of universal design principles, `disable-model-invocation`, so
|
|
36
|
+
* it costs no per-turn context and is reached only by pointer, not fired on a
|
|
37
|
+
* vague trigger). Both ship engine-packaged here; `game-design-reference`'s
|
|
38
|
+
* CONTENT is engine-agnostic.
|
|
39
|
+
*
|
|
40
|
+
* They ship ALONGSIDE the per-command skills incur auto-generates from the
|
|
41
|
+
* command surface (`sync: { depth: 1 }`), pulled in via an `include` glob; the
|
|
42
|
+
* command surface is still growing, so the named discipline skills are authored
|
|
43
|
+
* rather than tied to a command-group shape that does not exist yet.
|
|
44
|
+
*/
|
|
45
|
+
export const SKILL_NAMES = [
|
|
46
|
+
'picopilot-overview',
|
|
47
|
+
'picopilot-code',
|
|
48
|
+
'picopilot-art',
|
|
49
|
+
'picopilot-audio',
|
|
50
|
+
'picopilot-debug',
|
|
51
|
+
'game-design-reference',
|
|
52
|
+
'game-jam',
|
|
53
|
+
] as const;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolves the on-disk directory that holds the authored `picopilot-*` SKILL.md
|
|
57
|
+
* files (`<packageRoot>/src/skills`). The files ship in the npm package (the
|
|
58
|
+
* package `files` includes `src`), so the same path works from the built
|
|
59
|
+
* `dist/` runtime and from `src/` under tsx: we walk up from THIS module to the
|
|
60
|
+
* package root (the nearest ancestor with a `package.json`) and join `src/skills`,
|
|
61
|
+
* rather than a `dist`-relative path that would not exist (SKILL.md is not
|
|
62
|
+
* compiled).
|
|
63
|
+
*/
|
|
64
|
+
export function skillsSourceDir(): string {
|
|
65
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
66
|
+
const seen = new Set<string>();
|
|
67
|
+
while (!seen.has(dir)) {
|
|
68
|
+
seen.add(dir);
|
|
69
|
+
if (existsSync(join(dir, 'package.json'))) {
|
|
70
|
+
return join(dir, 'src', 'skills');
|
|
71
|
+
}
|
|
72
|
+
const parent = dirname(dir);
|
|
73
|
+
if (parent === dir) break;
|
|
74
|
+
dir = parent;
|
|
75
|
+
}
|
|
76
|
+
// Fallback: two levels up from src/engine/skills is the package root.
|
|
77
|
+
return join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'skills');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** One agent whose skill dir picopilot's install wired up (from incur's result). */
|
|
81
|
+
export interface WiredAgent {
|
|
82
|
+
readonly agent: string;
|
|
83
|
+
readonly path: string;
|
|
84
|
+
readonly mode: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The outcome of installing picopilot's skills. */
|
|
88
|
+
export interface InstallSkillsResult {
|
|
89
|
+
/** Canonical install paths (one per skill, under `<base>/.agents/skills`). */
|
|
90
|
+
readonly paths: string[];
|
|
91
|
+
/** Non-universal agents whose dirs were symlinked/copied to the canonical skills. */
|
|
92
|
+
readonly agents: WiredAgent[];
|
|
93
|
+
/** The skill names that were installed. */
|
|
94
|
+
readonly skills: string[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Options controlling WHERE and HOW picopilot's skills are installed. */
|
|
98
|
+
export interface InstallSkillsOptions {
|
|
99
|
+
/**
|
|
100
|
+
* The picopilot CLI whose command surface the per-command skills are generated
|
|
101
|
+
* from. Its command map (via {@link Cli.toCommands}) is handed to incur.
|
|
102
|
+
*/
|
|
103
|
+
readonly cli: Cli.Cli;
|
|
104
|
+
/**
|
|
105
|
+
* Install globally (the real default, into `~/.agents/skills` + detected agent
|
|
106
|
+
* dirs) when true; install project-locally (into `<cwd>/.agents/skills`) when
|
|
107
|
+
* false. This is the shared-write isolation lever: tests pass `false`.
|
|
108
|
+
*/
|
|
109
|
+
readonly global: boolean;
|
|
110
|
+
/**
|
|
111
|
+
* The install base for a project-local (`global: false`) install and, when a
|
|
112
|
+
* non-universal agent is detected, the base its symlinks are written under.
|
|
113
|
+
* Ignored for a global install (that goes to the real home). Tests point this
|
|
114
|
+
* at a temp dir.
|
|
115
|
+
*/
|
|
116
|
+
readonly cwd?: string;
|
|
117
|
+
/**
|
|
118
|
+
* Override the authored-skills source dir. Defaults to {@link skillsSourceDir}.
|
|
119
|
+
* Exists so a test can prove isolation using its own fixture skills.
|
|
120
|
+
*/
|
|
121
|
+
readonly sourceDir?: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Installs picopilot's skills (the authored `picopilot-*` discipline skills plus
|
|
126
|
+
* the per-command skills incur generates from the CLI) via incur's
|
|
127
|
+
* `SyncSkills.sync`. This is the real work `init --install-skills` performs.
|
|
128
|
+
*
|
|
129
|
+
* The authored skills are pulled in with an ABSOLUTE `include` glob into
|
|
130
|
+
* {@link skillsSourceDir}, DECOUPLED from `cwd` on purpose: `cwd` selects the
|
|
131
|
+
* install TARGET, the include glob selects the skills SOURCE. That decoupling is
|
|
132
|
+
* what lets a test install to a temp `cwd` while still reading the real authored
|
|
133
|
+
* skills (or its own fixtures), the crux of the isolation contract.
|
|
134
|
+
*/
|
|
135
|
+
export async function installSkills(
|
|
136
|
+
options: InstallSkillsOptions,
|
|
137
|
+
): Promise<InstallSkillsResult> {
|
|
138
|
+
const commands = Cli.toCommands.get(options.cli);
|
|
139
|
+
if (commands === undefined) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
'installSkills: the given CLI has no command map (was it built by Cli.create?)',
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const source = options.sourceDir ?? skillsSourceDir();
|
|
146
|
+
|
|
147
|
+
const result = await SyncSkills.sync('picopilot', commands, {
|
|
148
|
+
depth: 1,
|
|
149
|
+
description: 'An agent-first toolchain for PICO-8 game development.',
|
|
150
|
+
global: options.global,
|
|
151
|
+
cwd: options.cwd,
|
|
152
|
+
// Absolute glob → resolves against `source`, independent of `cwd`.
|
|
153
|
+
include: [join(source, '*')],
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Ship skill RESOURCE files (everything a skill dir carries besides SKILL.md).
|
|
157
|
+
// incur 0.4.10's SyncSkills.sync copies ONLY SKILL.md and drops sibling files
|
|
158
|
+
// (see work/notes/observations/incur-syncskills-drops-skill-resource-files.md),
|
|
159
|
+
// so genre code references under e.g. `picopilot-code/reference/` would never
|
|
160
|
+
// reach the installed skill. We backfill them here: for each installed skill
|
|
161
|
+
// path (canonical + each wired agent), copy the matching source skill's
|
|
162
|
+
// non-SKILL.md contents in. This stays inside the already-isolated installSkills
|
|
163
|
+
// seam, so the shared-write isolation test still governs it, and it becomes a
|
|
164
|
+
// no-op (safe to delete) once incur copies full skill dirs.
|
|
165
|
+
const installPaths = [...result.paths, ...result.agents.map((a) => a.path)];
|
|
166
|
+
for (const installedSkillDir of installPaths) {
|
|
167
|
+
copySkillResources(
|
|
168
|
+
join(source, basename(installedSkillDir)),
|
|
169
|
+
installedSkillDir,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
paths: result.paths,
|
|
175
|
+
agents: result.agents.map((a) => ({
|
|
176
|
+
agent: a.agent,
|
|
177
|
+
path: a.path,
|
|
178
|
+
mode: a.mode,
|
|
179
|
+
})),
|
|
180
|
+
skills: result.skills.map((s) => s.name),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Copies a source skill directory's RESOURCE files (everything except SKILL.md)
|
|
186
|
+
* into an already-installed skill directory. This is the workaround for incur
|
|
187
|
+
* 0.4.10 shipping only SKILL.md (see the observation referenced in installSkills):
|
|
188
|
+
* SKILL.md is left to incur (it is already written and may be transformed), and
|
|
189
|
+
* only the sibling resources are backfilled, recursively, preserving structure.
|
|
190
|
+
*
|
|
191
|
+
* Both the source skill and any resources are optional: a skill with no resource
|
|
192
|
+
* files, or a source dir that does not exist (a command-generated skill with no
|
|
193
|
+
* authored source), is a silent no-op. Nothing here reaches outside `destDir`,
|
|
194
|
+
* which is one of the install paths SyncSkills already chose, so it inherits the
|
|
195
|
+
* shared-write isolation of installSkills (tests redirect those paths to a temp).
|
|
196
|
+
*/
|
|
197
|
+
function copySkillResources(sourceSkillDir: string, destDir: string): void {
|
|
198
|
+
if (!existsSync(sourceSkillDir) || !existsSync(destDir)) return;
|
|
199
|
+
for (const entry of readdirSync(sourceSkillDir)) {
|
|
200
|
+
if (entry === 'SKILL.md') continue; // incur owns the manifest; do not clobber it
|
|
201
|
+
cpSync(join(sourceSkillDir, entry), join(destDir, entry), {
|
|
202
|
+
recursive: true,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type {Cli} from 'incur';
|
|
2
|
+
import {createCli} from './cli.js';
|
|
3
|
+
|
|
4
|
+
export {createCli} from './cli.js';
|
|
5
|
+
export {VERSION} from './version.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The default CLI instance, exported for `incur gen` (typed CTAs) and for
|
|
9
|
+
* embedding picopilot as a Fetch API handler / MCP server.
|
|
10
|
+
*/
|
|
11
|
+
const cli: Cli.Cli = createCli();
|
|
12
|
+
|
|
13
|
+
export default cli;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: game-design-reference
|
|
3
|
+
description: Reference body of universal game-design principles (fairness, human reaction budget, readability, visibility, originality method). A shared reference other skills point at (game-jam, and any make-a-game skill); not fired on its own.
|
|
4
|
+
disable-model-invocation: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# game design reference
|
|
8
|
+
|
|
9
|
+
Universal principles of a GOOD game, true of any game regardless of engine, clock, or theme. This is a shared REFERENCE body: another skill (e.g. `game-jam`) points you here and you apply every principle below. It is engine-agnostic; where a check needs a concrete PICO-8 procedure (drive a cart, count frames), that lives in the loop skill `picopilot-debug` (playtest), which you follow the pointer into.
|
|
10
|
+
|
|
11
|
+
The one failure mode that unites the traps below: **an agent playtests as a frame-perfect machine and never models a human player.** You can hit any button on the exact right frame and see every pixel, so a game that is fair-and-playable TO YOU can be unfair or impossible for a human. Every check here is you deliberately putting on the human's eyes and hands.
|
|
12
|
+
|
|
13
|
+
## Fairness: never a dead state
|
|
14
|
+
|
|
15
|
+
A game is UNFAIR when it can reach a state the player did nothing wrong to reach, from which EVERY available action leads to a loss. A death the player could not have avoided reads as the GAME cheating, not the player failing, and it damages fairness far more than a hard-but-fair challenge does. Fairness is "the player can always still win, even after a setback": from every state, at least one legal move is non-losing.
|
|
16
|
+
|
|
17
|
+
This is distinct from DIFFICULTY. Hard is fine; a dead state punishes the player for the game's own design gap.
|
|
18
|
+
|
|
19
|
+
**The self-check (the one that gets skipped).** When a game has HAZARDS plus LIMITED control (a few discrete actions), verify BOTH properties, not just the first:
|
|
20
|
+
|
|
21
|
+
1. **Goal reachability** — from spawn/rest states, some sequence of actions reaches the objective. (Agents check this one.)
|
|
22
|
+
2. **Hazard avoidability** — from EVERY state the player can settle/rest into, at least ONE available action is non-fatal. (Agents SKIP this one.) If a resting position exists where all actions force a loss, that is a DEAD STATE: a fairness defect. Fix the geometry (widen an exit, move the hazard, add a safe lane) so no such pocket exists.
|
|
23
|
+
|
|
24
|
+
For a discrete-control game this is enumerable: drive the player into every corner/pocket it can settle in, then try EACH action and assert not-all-fatal. Do this with the playtest loop (`picopilot-debug`) deliberately, it will not happen by accident.
|
|
25
|
+
|
|
26
|
+
## Reaction budget: a human is not frame-perfect
|
|
27
|
+
|
|
28
|
+
Simple human reaction time is ~200-300ms (see a thing, press a button) and that is DETECTION only, before deciding what to do. At PICO-8's 30fps that is roughly **6-9 frames** just to react. So:
|
|
29
|
+
|
|
30
|
+
- **Any required-input WINDOW narrower than ~6-9 frames is effectively frame-perfect** and unplayable by a human as a reaction (only as a memorised pattern).
|
|
31
|
+
- **Do not tune difficulty against your own `--input` scripts.** If the ONLY way you can clear a level is a hand-timed frame sequence you authored, a human cannot clear it. That is the trap: you tuned against a frame-perfect player (yourself).
|
|
32
|
+
- **Telegraph, then require.** Fast games stay fair by relying on PREDICTION: give a readable wind-up so the player acts on anticipation, not on a sub-reaction-time window. Difficulty should come from planning/prediction pressure, not from beating the reaction-time floor.
|
|
33
|
+
|
|
34
|
+
**The self-check.** Before calling a level done, ask: could a HUMAN with ~250ms reaction clear this, relying on telegraphed anticipation rather than frame-perfect timing? If clearing it needs your exact scripted frames, widen the window or add telegraphing.
|
|
35
|
+
|
|
36
|
+
## Visibility: the player must see the player
|
|
37
|
+
|
|
38
|
+
If you `spr(n)` an entity, sprite n must actually be DRAWN (a non-empty sprite), or the entity is invisible and the game is unplayable however correct the logic. Prefer primitives (`circfill`, `rectfill`, `line`) for the player and key entities unless you have authored real sprites, primitives can never be an empty slot. The player, and every entity the player must track, must be visible and distinguishable on screen.
|
|
39
|
+
|
|
40
|
+
## Readability and feedback
|
|
41
|
+
|
|
42
|
+
The player must be able to READ the game state at a glance and get FEEDBACK for every action: what am I, what is a hazard, what is a goal, did my input register, did I score or get hit. Motion trails, colour contrast, a HUD, particles/shake/sfx on events, these are not polish for its own sake, they are how the player perceives the state they must act on.
|
|
43
|
+
|
|
44
|
+
## Difficulty curve and progression
|
|
45
|
+
|
|
46
|
+
A good game GOES SOMEWHERE: it escalates. Ramp difficulty by introducing or combining MECHANICS (a new hazard type, a new interaction) over the playthrough, not merely by piling more of the same obstacle. More-of-the-same reads as samey and does not deepen; a new wrinkle does. Aim for a difficulty ARC with a satisfying peak, not a flat loop. Keep the first contact with each new mechanic gentle enough to learn it before it is tested under pressure.
|
|
47
|
+
|
|
48
|
+
Progression can take MANY FORMS, a ladder from cheapest to richest:
|
|
49
|
+
|
|
50
|
+
1. **A difficulty ramp within one loop** (speed / spawn rate / hazard density rises as you play) — the smallest way to "go somewhere".
|
|
51
|
+
2. **Escalating waves / rounds** (new hazard TYPES arrive in later waves, not just more) — deepens one screen without new scenes.
|
|
52
|
+
3. **Discrete levels / stages** (hand-built rooms, each teaching or combining a mechanic) — a real authored arc.
|
|
53
|
+
4. **Worlds / acts** (sustained progression across many levels, new mechanics unlocking) — the top of the ladder.
|
|
54
|
+
|
|
55
|
+
Each rung "goes somewhere" more than the one below; each also costs more to build and finish. WHICH rung a given game should reach is not universal, it depends on how much you can build and FINISH (a rough over-reach loses to a finished smaller arc). Pick the highest rung you can complete well.
|
|
56
|
+
|
|
57
|
+
## Originality: a method, not a menu
|
|
58
|
+
|
|
59
|
+
Novelty is a real design quality and it is the axis judges cap hardest. But it cannot be handed to you as a list of mechanics, if it were, every game would converge on the same listed idea and it would be the list-writer's creativity, not yours. So this section teaches a METHOD to GENERATE and then TEST your own idea. It deliberately lists NO example mechanics. (If you are editing this skill: do not add concrete example mechanics here. A listed lever becomes the answer every agent copies, run after run, the exact trap this framing exists to avoid.)
|
|
60
|
+
|
|
61
|
+
**Interrogate your idea (questions to ask about YOUR idea, not answers to copy):**
|
|
62
|
+
|
|
63
|
+
- What is the OBVIOUS version of this theme, the first thing anyone would build? (Name it, so you can avoid it.)
|
|
64
|
+
- Could you SUBVERT the expectation the theme sets, rather than meet it?
|
|
65
|
+
- Could you INVERT the role, act on the world instead of the avatar, or control the thing usually controlled against you?
|
|
66
|
+
- Could the CONSTRAINT itself become the mechanic, rather than a limit you work around?
|
|
67
|
+
- Could two unrelated ideas COMBINE into something neither is alone?
|
|
68
|
+
- Could you RECONTEXTUALISE a familiar mechanic into a setting that changes its meaning?
|
|
69
|
+
|
|
70
|
+
**The self-check (load-bearing).** Before you commit to an idea: name the obvious version of this theme; is your idea just that with a reskin? If a judge (or player) has almost certainly seen this exact game before, go again. Commit only to an idea that passes this check, then build it well, a fresh idea executed roughly beats a stale idea executed perfectly.
|