@playcraft/cli 0.0.55 → 0.0.57

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 (170) hide show
  1. package/README.md +18 -0
  2. package/REMIX.md +115 -19
  3. package/dist/atom-tree/fusion-hints.js +8 -3
  4. package/dist/atom-tree/fusion-intent.js +62 -0
  5. package/dist/atom-tree/selection.js +3 -1
  6. package/dist/commands/create.js +299 -85
  7. package/dist/commands/image.js +79 -69
  8. package/dist/commands/levels.js +29 -0
  9. package/dist/commands/patterns.js +75 -0
  10. package/dist/commands/remix.js +93 -0
  11. package/dist/commands/skills.js +48 -17
  12. package/dist/index.js +9 -5
  13. package/dist/remix/clone-source.js +208 -0
  14. package/dist/remix/clone.js +256 -0
  15. package/dist/remix/local-git.js +56 -0
  16. package/dist/remix/pull.js +64 -0
  17. package/dist/remix/push.js +70 -0
  18. package/dist/scaffold/templates.js +1 -1
  19. package/dist/utils/agent-api-client.js +10 -3
  20. package/dist/utils/remix-image-attachments.js +4 -32
  21. package/dist/utils/sandbox-path.js +20 -0
  22. package/package.json +3 -3
  23. package/project-template/.claude/agents/creative-director.md +114 -0
  24. package/project-template/.claude/agents/developer.md +5 -2
  25. package/project-template/.claude/agents/game-designer.md +41 -20
  26. package/project-template/.claude/agents/refs/README.md +8 -0
  27. package/project-template/.claude/agents/refs/creative-director-guide.md +35 -0
  28. package/project-template/.claude/agents/refs/creative-director-kickoff-guide.md +52 -0
  29. package/project-template/.claude/agents/refs/creative-discovery-guide.md +76 -0
  30. package/project-template/.claude/agents/refs/creative-north-star-examples.md +82 -0
  31. package/project-template/.claude/agents/refs/developer-dev-handoff.md +1 -1
  32. package/project-template/.claude/agents/refs/game-designer-fusion-depth-playbook.md +28 -0
  33. package/project-template/.claude/agents/refs/game-designer-gameplay-sufficiency.md +4 -4
  34. package/project-template/.claude/agents/reviewer.md +11 -0
  35. package/project-template/.claude/hooks/README.md +12 -12
  36. package/project-template/.claude/hooks/block-developer-preflight.mjs +49 -13
  37. package/project-template/.claude/hooks/block-ring1-tasklist.mjs +1 -1
  38. package/project-template/.claude/hooks/user-prompt.mjs +3 -13
  39. package/project-template/.claude/hooks/validate-workflow-stop.mjs +209 -12
  40. package/project-template/.claude/settings.json +2 -2
  41. package/project-template/.claude/skills/playcraft-playable-optimization/SKILL.md +20 -20
  42. package/project-template/.claude/skills/session-analyzer/scripts/resolve-session.mjs +0 -1
  43. package/project-template/.claude/snapshot.mjs +0 -1
  44. package/project-template/.codebuddy/settings.json +1 -1
  45. package/project-template/.cursor/rules/playcraft-orchestrator.mdc +1 -1
  46. package/project-template/CLAUDE.md +9 -9
  47. package/project-template/docs/project-state.md +1 -1
  48. package/project-template/docs/team/agent-conduct.md +18 -18
  49. package/project-template/docs/team/collaboration.md +19 -7
  50. package/project-template/docs/team/workflow-changelog.md +15 -0
  51. package/project-template/fusion-patterns/catalog.json +77 -0
  52. package/project-template/package.json +2 -0
  53. package/project-template/skills/_shared/scripts/create-playable.mjs +31 -11
  54. package/project-template/skills/_shared/scripts/dev-start.mjs +3 -3
  55. package/project-template/skills/_shared/scripts/disclosure-config.mjs +65 -39
  56. package/project-template/skills/_shared/scripts/harness-smoke.mjs +3 -1
  57. package/project-template/skills/_shared/scripts/lib/fusion-intent.mjs +88 -0
  58. package/project-template/skills/_shared/scripts/lib/skill-fidelity.mjs +185 -0
  59. package/project-template/skills/_shared/scripts/slice-set.mjs +29 -2
  60. package/project-template/skills/_shared/scripts/state-get.mjs +124 -7
  61. package/project-template/skills/_shared/scripts/state-store.mjs +25 -2
  62. package/project-template/skills/_shared/scripts/validate-level-sketch.mjs +444 -0
  63. package/project-template/skills/_shared/scripts/verify-design-consistency.mjs +728 -0
  64. package/project-template/skills/playable-record/scripts/lib/dev-server.mjs +46 -11
  65. package/project-template/skills/playable-record/scripts/record-playable.mjs +2 -1
  66. package/project-template/skills/playable-record/scripts/verify-contract.mjs +4 -4
  67. package/project-template/skills/playable-record/scripts/verify-firstwin.mjs +3 -3
  68. package/project-template/skills/playable-record/scripts/verify-fusion.mjs +37 -2
  69. package/project-template/skills/playable-record/scripts/verify-slice.mjs +2 -2
  70. package/project-template/templates/design-brief.template.md +34 -0
  71. package/project-template/templates/design-methodology.md +1 -2
  72. package/project-template/templates/design.template.md +65 -0
  73. package/project-template/templates/developer-log.template.md +12 -1
  74. package/project-template/templates/five-axis-framework.md +12 -0
  75. package/project-template/templates/game-designer-creative-process.template.md +99 -0
  76. package/project-template/templates/level-sketch.template.json +56 -0
  77. package/project-template/templates/north-star.template.md +43 -0
  78. package/project-template/templates/project-state.template.md +1 -1
  79. package/project-template/vite.config.ts +4 -5
  80. package/project-templates/phaser/.claude/agents/creative-director.md +114 -0
  81. package/project-templates/phaser/.claude/agents/game-designer.md +41 -20
  82. package/project-templates/phaser/.claude/agents/refs/creative-director-guide.md +35 -0
  83. package/project-templates/phaser/.claude/agents/refs/creative-director-kickoff-guide.md +52 -0
  84. package/project-templates/phaser/.claude/agents/refs/creative-discovery-guide.md +76 -0
  85. package/project-templates/phaser/.claude/agents/refs/creative-north-star-examples.md +82 -0
  86. package/project-templates/phaser/.claude/agents/refs/developer-dev-handoff.md +1 -1
  87. package/project-templates/phaser/.claude/agents/refs/game-designer-fusion-depth-playbook.md +28 -0
  88. package/project-templates/phaser/.claude/agents/reviewer.md +11 -0
  89. package/project-templates/phaser/.claude/hooks/block-ring1-tasklist.mjs +2 -2
  90. package/project-templates/phaser/.claude/hooks/user-prompt.mjs +17 -18
  91. package/project-templates/phaser/.claude/hooks/validate-workflow-stop.mjs +209 -12
  92. package/project-templates/phaser/.claude/skills/playcraft-playable-optimization/SKILL.md +20 -20
  93. package/project-templates/phaser/.cursor/rules/playcraft-orchestrator.mdc +1 -1
  94. package/project-templates/phaser/CLAUDE.md +88 -54
  95. package/project-templates/phaser/docs/DESIGN.md +25 -0
  96. package/project-templates/phaser/docs/project-state.md +1 -1
  97. package/project-templates/phaser/docs/team/collaboration.md +19 -7
  98. package/project-templates/phaser/docs/team/workflow-changelog.md +14 -0
  99. package/project-templates/phaser/fusion-patterns/catalog.json +77 -0
  100. package/project-templates/phaser/package.json +3 -1
  101. package/project-templates/phaser/skills/_shared/scripts/dev-start.mjs +3 -3
  102. package/project-templates/phaser/skills/_shared/scripts/disclosure-config.mjs +65 -39
  103. package/project-templates/phaser/skills/_shared/scripts/harness-smoke.mjs +3 -1
  104. package/project-templates/phaser/skills/_shared/scripts/lib/fusion-intent.mjs +88 -0
  105. package/project-templates/phaser/skills/_shared/scripts/lib/skill-fidelity.mjs +185 -0
  106. package/project-templates/phaser/skills/_shared/scripts/state-get.mjs +124 -7
  107. package/project-templates/phaser/skills/_shared/scripts/state-store.mjs +25 -2
  108. package/project-templates/phaser/skills/_shared/scripts/validate-level-sketch.mjs +444 -0
  109. package/project-templates/phaser/skills/_shared/scripts/verify-design-consistency.mjs +728 -0
  110. package/project-templates/phaser/skills/playable-record/scripts/lib/dev-server.mjs +46 -11
  111. package/project-templates/phaser/skills/playable-record/scripts/record-playable.mjs +2 -1
  112. package/project-templates/phaser/skills/playable-record/scripts/verify-contract.mjs +4 -4
  113. package/project-templates/phaser/skills/playable-record/scripts/verify-firstwin.mjs +3 -3
  114. package/project-templates/phaser/skills/playable-record/scripts/verify-fusion.mjs +37 -2
  115. package/project-templates/phaser/skills/playable-record/scripts/verify-slice.mjs +2 -2
  116. package/project-templates/phaser/templates/design-brief.template.md +42 -11
  117. package/project-templates/phaser/templates/design-methodology.md +1 -2
  118. package/project-templates/phaser/templates/design.template.md +77 -0
  119. package/project-templates/phaser/templates/five-axis-framework.md +50 -1
  120. package/project-templates/phaser/templates/game-designer-creative-process.template.md +99 -0
  121. package/project-templates/phaser/templates/level-sketch.template.json +56 -0
  122. package/project-templates/phaser/templates/north-star.template.md +43 -0
  123. package/project-templates/phaser/templates/project-state.template.md +1 -1
  124. package/project-templates/phaser/vite.config.ts +4 -5
  125. package/project-templates/threejs/.claude/agents/creative-director.md +114 -0
  126. package/project-templates/threejs/.claude/agents/game-designer.md +41 -20
  127. package/project-templates/threejs/.claude/agents/refs/creative-director-guide.md +35 -0
  128. package/project-templates/threejs/.claude/agents/refs/creative-director-kickoff-guide.md +52 -0
  129. package/project-templates/threejs/.claude/agents/refs/creative-discovery-guide.md +76 -0
  130. package/project-templates/threejs/.claude/agents/refs/creative-north-star-examples.md +82 -0
  131. package/project-templates/threejs/.claude/agents/refs/developer-dev-handoff.md +1 -1
  132. package/project-templates/threejs/.claude/agents/refs/game-designer-fusion-depth-playbook.md +28 -0
  133. package/project-templates/threejs/.claude/agents/reviewer.md +11 -0
  134. package/project-templates/threejs/.claude/hooks/block-ring1-tasklist.mjs +2 -2
  135. package/project-templates/threejs/.claude/hooks/user-prompt.mjs +17 -18
  136. package/project-templates/threejs/.claude/hooks/validate-workflow-stop.mjs +209 -12
  137. package/project-templates/threejs/.claude/skills/playcraft-playable-optimization/SKILL.md +20 -20
  138. package/project-templates/threejs/.cursor/rules/playcraft-orchestrator.mdc +1 -1
  139. package/project-templates/threejs/CLAUDE.md +88 -54
  140. package/project-templates/threejs/docs/DESIGN.md +25 -0
  141. package/project-templates/threejs/docs/project-state.md +1 -1
  142. package/project-templates/threejs/docs/team/collaboration.md +19 -7
  143. package/project-templates/threejs/docs/team/workflow-changelog.md +14 -0
  144. package/project-templates/threejs/fusion-patterns/catalog.json +77 -0
  145. package/project-templates/threejs/package.json +3 -1
  146. package/project-templates/threejs/skills/_shared/scripts/dev-start.mjs +3 -3
  147. package/project-templates/threejs/skills/_shared/scripts/disclosure-config.mjs +65 -39
  148. package/project-templates/threejs/skills/_shared/scripts/harness-smoke.mjs +3 -1
  149. package/project-templates/threejs/skills/_shared/scripts/lib/fusion-intent.mjs +88 -0
  150. package/project-templates/threejs/skills/_shared/scripts/lib/skill-fidelity.mjs +185 -0
  151. package/project-templates/threejs/skills/_shared/scripts/state-get.mjs +124 -7
  152. package/project-templates/threejs/skills/_shared/scripts/state-store.mjs +25 -2
  153. package/project-templates/threejs/skills/_shared/scripts/validate-level-sketch.mjs +444 -0
  154. package/project-templates/threejs/skills/_shared/scripts/verify-design-consistency.mjs +728 -0
  155. package/project-templates/threejs/skills/playable-record/scripts/lib/dev-server.mjs +46 -11
  156. package/project-templates/threejs/skills/playable-record/scripts/record-playable.mjs +2 -1
  157. package/project-templates/threejs/skills/playable-record/scripts/verify-contract.mjs +4 -4
  158. package/project-templates/threejs/skills/playable-record/scripts/verify-firstwin.mjs +3 -3
  159. package/project-templates/threejs/skills/playable-record/scripts/verify-fusion.mjs +37 -2
  160. package/project-templates/threejs/skills/playable-record/scripts/verify-slice.mjs +2 -2
  161. package/project-templates/threejs/templates/design-brief.template.md +42 -11
  162. package/project-templates/threejs/templates/design-methodology.md +1 -2
  163. package/project-templates/threejs/templates/design.template.md +77 -0
  164. package/project-templates/threejs/templates/five-axis-framework.md +50 -1
  165. package/project-templates/threejs/templates/game-designer-creative-process.template.md +99 -0
  166. package/project-templates/threejs/templates/level-sketch.template.json +56 -0
  167. package/project-templates/threejs/templates/north-star.template.md +43 -0
  168. package/project-templates/threejs/templates/project-state.template.md +1 -1
  169. package/project-templates/threejs/vite.config.ts +4 -5
  170. package/project-template/docs/DESIGN.md +0 -65
@@ -0,0 +1,444 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Validate docs/level-sketch.json — solvability + puzzle identity + difficulty profiles.
4
+ * Pure JS — no TS runtime dependency.
5
+ *
6
+ * Env: LEVEL_DIFFICULTY_PROFILE=ring1_min|standard|hard
7
+ */
8
+ import { readFileSync, existsSync } from 'node:fs';
9
+ import { join, resolve, basename } from 'node:path';
10
+
11
+ export const DIFFICULTY_PROFILES = {
12
+ ring1_min: {
13
+ boardRow: 8,
14
+ boardCol: 8,
15
+ minPaths: 6,
16
+ minSegments: 12,
17
+ minDependencyDepth: 2,
18
+ maxOpeningRemovable: 2,
19
+ maxTrayPeak: 5,
20
+ },
21
+ standard: {
22
+ boardRow: 8,
23
+ boardCol: 8,
24
+ minPaths: 9,
25
+ minSegments: 18,
26
+ minDependencyDepth: 3,
27
+ maxOpeningRemovable: 3,
28
+ maxTrayPeak: 6,
29
+ },
30
+ hard: {
31
+ boardRow: 8,
32
+ boardCol: 8,
33
+ minPaths: 12,
34
+ minSegments: 24,
35
+ minDependencyDepth: 4,
36
+ maxOpeningRemovable: 1,
37
+ maxTrayPeak: 7,
38
+ },
39
+ };
40
+
41
+ function parseArgs(argv) {
42
+ const opts = { root: process.cwd(), sketch: 'docs/level-sketch.json', json: false };
43
+ for (const arg of argv) {
44
+ if (arg === '--json') opts.json = true;
45
+ else if (arg.startsWith('--root=')) opts.root = resolve(arg.slice('--root='.length));
46
+ else if (arg.startsWith('--sketch=')) opts.sketch = arg.slice('--sketch='.length);
47
+ else if (arg.startsWith('--profile=')) opts.profile = arg.slice('--profile='.length);
48
+ }
49
+ return opts;
50
+ }
51
+
52
+ /** @typedef {{ x: number, y: number }} GridPoint */
53
+ /** @typedef {{ points: GridPoint[], head: GridPoint, direction: string, color: string, indices: number[], segments: number }} LevelPath */
54
+ /** @typedef {{ index: number, rows: number, cols: number, paths: LevelPath[], levelTime: number, trayCapacity: number, matchCount: number, boardPhases: unknown[] | null }} ParsedLevel */
55
+
56
+ function pathSegments(points) {
57
+ return points.length <= 1 ? 0 : points.length - 1;
58
+ }
59
+
60
+ /** @param {unknown} raw @returns {ParsedLevel} */
61
+ export function parseLevelSketch(raw) {
62
+ let target = raw;
63
+ if (Array.isArray(raw)) {
64
+ const maybe = raw[5];
65
+ if (Array.isArray(maybe) && Array.isArray(maybe[0]) && maybe[0].length >= 3) {
66
+ target = maybe[0][2];
67
+ }
68
+ }
69
+ if (typeof target !== 'object' || target === null) {
70
+ throw new Error('Invalid level sketch: expected object');
71
+ }
72
+ const obj = /** @type {Record<string, unknown>} */ (target);
73
+ const rows = typeof obj.row === 'number' ? obj.row : 8;
74
+ const cols = typeof obj.col === 'number' ? obj.col : 8;
75
+ const trayCapacity = typeof obj.trayCapacity === 'number' ? obj.trayCapacity : 7;
76
+ const matchCount = typeof obj.matchCount === 'number' ? obj.matchCount : 3;
77
+ const colorList = Array.isArray(obj.colors) ? obj.colors : [];
78
+ const rawArrows = Array.isArray(obj.arrows) ? obj.arrows : [];
79
+
80
+ const paths = rawArrows.map((rawPath, pathIndex) => {
81
+ if (!Array.isArray(rawPath)) throw new Error(`paths[${pathIndex}] must be array`);
82
+ const points = rawPath.map((p, pi) => {
83
+ if (!Array.isArray(p) || p.length < 2) throw new Error(`paths[${pathIndex}][${pi}] invalid`);
84
+ const [x, cocosY] = /** @type {[number, number]} */ (p);
85
+ const colorFromPoint = typeof p[2] === 'string' ? p[2] : undefined;
86
+ return { x, y: rows > 0 ? rows - 1 - cocosY : cocosY, colorFromPoint };
87
+ });
88
+ if (points.length === 0) {
89
+ return {
90
+ points: [],
91
+ head: { x: 0, y: 0 },
92
+ direction: 'right',
93
+ color: colorList[pathIndex] ?? `color_${pathIndex % 3}`,
94
+ indices: [],
95
+ segments: 0,
96
+ };
97
+ }
98
+ const head = points[points.length - 1];
99
+ const before = points[Math.max(0, points.length - 2)];
100
+ const dx = head.x - before.x;
101
+ const dy = head.y - before.y;
102
+ let direction = 'right';
103
+ if (Math.abs(dx) >= Math.abs(dy)) direction = dx < 0 ? 'left' : 'right';
104
+ else direction = dy < 0 ? 'up' : 'down';
105
+ const indices = points.map((pt) => pt.y * cols + pt.x);
106
+ const color =
107
+ colorList[pathIndex] ??
108
+ points.find((p) => p.colorFromPoint)?.colorFromPoint ??
109
+ `color_${pathIndex % 3}`;
110
+
111
+ return {
112
+ points: points.map((p) => ({ x: p.x, y: p.y })),
113
+ head,
114
+ direction,
115
+ color,
116
+ indices,
117
+ segments: pathSegments(points.map((p) => [p.x, p.y])),
118
+ };
119
+ });
120
+
121
+ return {
122
+ index: typeof obj.name === 'number' ? obj.name : 0,
123
+ rows,
124
+ cols,
125
+ paths,
126
+ levelTime: typeof obj.levelTime === 'number' ? obj.levelTime : 0,
127
+ trayCapacity,
128
+ matchCount,
129
+ boardPhases: Array.isArray(obj.boardPhases) ? obj.boardPhases : null,
130
+ };
131
+ }
132
+
133
+ /** @param {ParsedLevel} level @param {number} pathIndex @param {Set<number>} removed */
134
+ export function canPathExitBoard(level, pathIndex, removed) {
135
+ const path = level.paths[pathIndex];
136
+ if (!path) return { canExit: false };
137
+ const delta =
138
+ path.direction === 'left'
139
+ ? { dx: -1, dy: 0 }
140
+ : path.direction === 'right'
141
+ ? { dx: 1, dy: 0 }
142
+ : path.direction === 'up'
143
+ ? { dx: 0, dy: -1 }
144
+ : { dx: 0, dy: 1 };
145
+ const occupied = new Set();
146
+ for (let i = 0; i < level.paths.length; i++) {
147
+ if (i === pathIndex || removed.has(i)) continue;
148
+ for (const idx of level.paths[i].indices) occupied.add(idx);
149
+ }
150
+ let x = path.head.x;
151
+ let y = path.head.y;
152
+ for (let step = 0; step < Math.max(level.rows, level.cols) + 2; step += 1) {
153
+ x += delta.dx;
154
+ y += delta.dy;
155
+ if (x < 0 || x >= level.cols || y < 0 || y >= level.rows) return { canExit: true };
156
+ if (occupied.has(y * level.cols + x)) return { canExit: false };
157
+ }
158
+ return { canExit: false };
159
+ }
160
+
161
+ /** @param {ParsedLevel} level */
162
+ export function countOpeningRemovable(level) {
163
+ const removed = new Set();
164
+ let count = 0;
165
+ for (let i = 0; i < level.paths.length; i += 1) {
166
+ if (canPathExitBoard(level, i, removed).canExit) count += 1;
167
+ }
168
+ return count;
169
+ }
170
+
171
+ /** @param {ParsedLevel} level */
172
+ export function computeDependencyDepth(level) {
173
+ const removed = new Set();
174
+ let depth = 0;
175
+ let remaining = level.paths.length;
176
+
177
+ while (remaining > 0) {
178
+ const wave = [];
179
+ for (let i = 0; i < level.paths.length; i += 1) {
180
+ if (removed.has(i)) continue;
181
+ if (canPathExitBoard(level, i, removed).canExit) wave.push(i);
182
+ }
183
+ if (wave.length === 0) return -1;
184
+ depth += 1;
185
+ for (const i of wave) removed.add(i);
186
+ remaining -= wave.length;
187
+ }
188
+ return depth;
189
+ }
190
+
191
+ /** @param {ParsedLevel} level */
192
+ export function totalSegments(level) {
193
+ return level.paths.reduce((sum, p) => sum + p.segments, 0);
194
+ }
195
+
196
+ /** @param {ParsedLevel} level */
197
+ export function occupationRatio(level) {
198
+ const cells = new Set();
199
+ for (const path of level.paths) {
200
+ for (const idx of path.indices) cells.add(idx);
201
+ }
202
+ const area = level.rows * level.cols;
203
+ return area > 0 ? cells.size / area : 0;
204
+ }
205
+
206
+ /** @param {ParsedLevel} level */
207
+ export function simulateTrayPeak(level) {
208
+ const colorGroups = new Map();
209
+ for (const path of level.paths) {
210
+ const list = colorGroups.get(path.color) || [];
211
+ list.push(path.pathIndex);
212
+ colorGroups.set(path.color, list);
213
+ }
214
+
215
+ let traySize = 0;
216
+ let peak = 0;
217
+ for (const [, pathIndices] of colorGroups) {
218
+ traySize += pathIndices.length;
219
+ peak = Math.max(peak, traySize);
220
+ const matches = Math.floor(pathIndices.length / level.matchCount);
221
+ traySize -= matches * level.matchCount;
222
+ }
223
+ return peak;
224
+ }
225
+
226
+ /** @param {ParsedLevel} level */
227
+ export function checkOverlap(level) {
228
+ const errors = [];
229
+ const seen = new Map();
230
+ for (const path of level.paths) {
231
+ for (const idx of path.indices) {
232
+ if (seen.has(idx)) {
233
+ errors.push(`Paths ${seen.get(idx)} and ${path.pathIndex} overlap at cell ${idx}`);
234
+ } else {
235
+ seen.set(idx, path.pathIndex);
236
+ }
237
+ }
238
+ }
239
+ return errors;
240
+ }
241
+
242
+ /** @param {ParsedLevel} level */
243
+ export function validateLevelSolvable(level) {
244
+ const removed = new Set();
245
+ while (removed.size < level.paths.length) {
246
+ let found = null;
247
+ for (let i = 0; i < level.paths.length; i++) {
248
+ if (removed.has(i)) continue;
249
+ if (canPathExitBoard(level, i, removed).canExit) {
250
+ found = i;
251
+ break;
252
+ }
253
+ }
254
+ if (found === null) {
255
+ return {
256
+ solvable: false,
257
+ reason: `greedy stuck after removing ${removed.size}/${level.paths.length} paths`,
258
+ };
259
+ }
260
+ removed.add(found);
261
+ }
262
+ return { solvable: true };
263
+ }
264
+
265
+ /**
266
+ * @param {ParsedLevel} level
267
+ * @param {keyof typeof DIFFICULTY_PROFILES} profileName
268
+ */
269
+ export function validateDifficulty(level, profileName = 'ring1_min') {
270
+ const profile = DIFFICULTY_PROFILES[profileName];
271
+ if (!profile) {
272
+ return { errors: [`Unknown difficulty profile: ${profileName}`], warnings: [], metrics: {} };
273
+ }
274
+
275
+ const errors = [];
276
+ const warnings = [];
277
+
278
+ if (level.rows !== profile.boardRow || level.cols !== profile.boardCol) {
279
+ errors.push(
280
+ `Board must be ${profile.boardRow}×${profile.boardCol} for profile ${profileName}, got ${level.rows}×${level.cols}`,
281
+ );
282
+ }
283
+
284
+ if (level.paths.length < profile.minPaths) {
285
+ errors.push(`Too few paths: ${level.paths.length} < ${profile.minPaths} (${profileName})`);
286
+ }
287
+
288
+ const segments = totalSegments(level);
289
+ if (segments < profile.minSegments) {
290
+ errors.push(`Too few segments: ${segments} < ${profile.minSegments} (${profileName})`);
291
+ }
292
+
293
+ const opening = countOpeningRemovable(level);
294
+ if (opening >= level.paths.length && level.paths.length > 1) {
295
+ errors.push('All paths are FREE at opening — zero decision puzzle (anti-pattern)');
296
+ } else if (opening > profile.maxOpeningRemovable) {
297
+ warnings.push(
298
+ `Opening removable ${opening} > max ${profile.maxOpeningRemovable} for ${profileName}`,
299
+ );
300
+ }
301
+
302
+ const depDepth = computeDependencyDepth(level);
303
+ if (depDepth < 0) {
304
+ errors.push('Dependency graph is not solvable (deadlock in greedy waves)');
305
+ } else if (depDepth < profile.minDependencyDepth) {
306
+ errors.push(`Dependency depth ${depDepth} < ${profile.minDependencyDepth} (${profileName})`);
307
+ }
308
+
309
+ const trayPeak = simulateTrayPeak(level);
310
+ if (trayPeak > profile.maxTrayPeak) {
311
+ errors.push(`Tray peak ${trayPeak} > ${profile.maxTrayPeak} (${profileName})`);
312
+ }
313
+
314
+ const occupancy = occupationRatio(level);
315
+ if (occupancy < 0.4) {
316
+ warnings.push(`Occupancy ${(occupancy * 100).toFixed(0)}% < 40% — board may feel sparse`);
317
+ }
318
+
319
+ return {
320
+ errors,
321
+ warnings,
322
+ metrics: { segmentCount: segments, segments, opening, dependencyDepth: depDepth, depDepth, trayPeakSimulated: trayPeak, trayPeak, occupancy },
323
+ };
324
+ }
325
+
326
+ /** @param {ParsedLevel} level */
327
+ export function validateBoardPhases(level) {
328
+ if (!level.boardPhases || level.boardPhases.length === 0) return [];
329
+ const errors = [];
330
+ for (const [i, phase] of level.boardPhases.entries()) {
331
+ if (!phase || typeof phase !== 'object') {
332
+ errors.push(`boardPhases[${i}] must be an object`);
333
+ continue;
334
+ }
335
+ const p = /** @type {Record<string, unknown>} */ (phase);
336
+ if (!p.phaseId) errors.push(`boardPhases[${i}] missing phaseId`);
337
+ if (!p.activeMask && !p.mask) {
338
+ errors.push(`boardPhases[${i}] missing activeMask or mask`);
339
+ }
340
+ if (!p.unlockAfter) {
341
+ errors.push(`boardPhases[${i}] missing unlockAfter (e.g. matchClears or pathsRemoved)`);
342
+ }
343
+ }
344
+ return errors;
345
+ }
346
+
347
+ /** @param {string} root @param {string} sketchRel @param {{ profile?: string }} [options] */
348
+ export function validateLevelSketch(root, sketchRel = 'docs/level-sketch.json', options = {}) {
349
+ const sketchPath = join(root, sketchRel);
350
+ const profileName =
351
+ options.profile ??
352
+ process.env.LEVEL_DIFFICULTY_PROFILE ??
353
+ 'ring1_min';
354
+
355
+ if (!existsSync(sketchPath)) {
356
+ return {
357
+ ok: false,
358
+ problems: [`level sketch not found: ${sketchRel}`],
359
+ metrics: {
360
+ pathCount: 0,
361
+ openingRemovableCount: 0,
362
+ hasBlockedPaths: false,
363
+ solvable: false,
364
+ segmentCount: 0,
365
+ dependencyDepth: 0,
366
+ trayPeakSimulated: 0,
367
+ },
368
+ profile: profileName,
369
+ };
370
+ }
371
+
372
+ const problems = [];
373
+ const warnings = [];
374
+ let level;
375
+ try {
376
+ level = parseLevelSketch(JSON.parse(readFileSync(sketchPath, 'utf8')));
377
+ } catch (err) {
378
+ return {
379
+ ok: false,
380
+ problems: [`parse error: ${err instanceof Error ? err.message : String(err)}`],
381
+ metrics: {
382
+ pathCount: 0,
383
+ openingRemovableCount: 0,
384
+ hasBlockedPaths: false,
385
+ solvable: false,
386
+ segmentCount: 0,
387
+ dependencyDepth: 0,
388
+ trayPeakSimulated: 0,
389
+ },
390
+ profile: profileName,
391
+ };
392
+ }
393
+
394
+ problems.push(...checkOverlap(level));
395
+ problems.push(...validateBoardPhases(level));
396
+
397
+ const openingRemovableCount = countOpeningRemovable(level);
398
+ const solvability = validateLevelSolvable(level);
399
+ const diff = validateDifficulty(level, profileName);
400
+ warnings.push(...(diff.warnings ?? []));
401
+ problems.push(...diff.errors);
402
+
403
+ if (level.paths.length < 3) {
404
+ problems.push('level sketch should include at least 3 paths for Ring 1 gameplay identity');
405
+ }
406
+ if (!solvability.solvable) {
407
+ problems.push(solvability.reason ?? 'level is not solvable');
408
+ }
409
+
410
+ const metrics = {
411
+ pathCount: level.paths.length,
412
+ openingRemovableCount,
413
+ hasBlockedPaths: level.paths.length > openingRemovableCount,
414
+ solvable: solvability.solvable,
415
+ solvabilityReason: solvability.reason,
416
+ segmentCount: diff.metrics?.segmentCount ?? totalSegments(level),
417
+ dependencyDepth: diff.metrics?.dependencyDepth ?? computeDependencyDepth(level),
418
+ trayPeakSimulated: diff.metrics?.trayPeakSimulated ?? simulateTrayPeak(level),
419
+ occupancy: diff.metrics?.occupancy ?? occupationRatio(level),
420
+ boardSize: `${level.rows}x${level.cols}`,
421
+ };
422
+
423
+ return { ok: problems.length === 0, problems, warnings, metrics, profile: profileName };
424
+ }
425
+
426
+ function main() {
427
+ const opts = parseArgs(process.argv.slice(2));
428
+ const result = validateLevelSketch(opts.root, opts.sketch, { profile: opts.profile });
429
+ if (opts.json) {
430
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
431
+ } else if (result.ok) {
432
+ process.stdout.write(
433
+ `[levels validate] OK (${result.profile}) — paths=${result.metrics.pathCount} openingRemovable=${result.metrics.openingRemovableCount} segments=${result.metrics.segmentCount}\n`,
434
+ );
435
+ for (const w of result.warnings ?? []) process.stderr.write(` WARN: ${w}\n`);
436
+ } else {
437
+ process.stderr.write(`[levels validate] FAIL\n - ${result.problems.join('\n - ')}\n`);
438
+ }
439
+ process.exit(result.ok ? 0 : 2);
440
+ }
441
+
442
+ if (process.argv[1] && basename(process.argv[1]) === 'validate-level-sketch.mjs') {
443
+ main();
444
+ }