@playcraft/cli 0.0.55 → 0.0.56
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/README.md +21 -0
- package/REMIX.md +103 -19
- package/dist/atom-tree/fusion-hints.js +8 -3
- package/dist/atom-tree/fusion-intent.js +62 -0
- package/dist/atom-tree/selection.js +3 -1
- package/dist/commands/create.js +299 -85
- package/dist/commands/image.js +79 -69
- package/dist/commands/levels.js +29 -0
- package/dist/commands/patterns.js +75 -0
- package/dist/commands/remix.js +47 -0
- package/dist/commands/skills.js +48 -17
- package/dist/index.js +9 -5
- package/dist/remix/clone-source.js +208 -0
- package/dist/remix/clone.js +255 -0
- package/dist/scaffold/templates.js +1 -1
- package/dist/utils/agent-api-client.js +10 -3
- package/dist/utils/remix-image-attachments.js +4 -32
- package/dist/utils/sandbox-path.js +20 -0
- package/package.json +3 -3
- package/project-template/.claude/agents/creative-director.md +114 -0
- package/project-template/.claude/agents/developer.md +5 -2
- package/project-template/.claude/agents/game-designer.md +41 -20
- package/project-template/.claude/agents/refs/README.md +8 -0
- package/project-template/.claude/agents/refs/creative-director-guide.md +35 -0
- package/project-template/.claude/agents/refs/creative-director-kickoff-guide.md +52 -0
- package/project-template/.claude/agents/refs/creative-discovery-guide.md +76 -0
- package/project-template/.claude/agents/refs/creative-north-star-examples.md +82 -0
- package/project-template/.claude/agents/refs/developer-dev-handoff.md +1 -1
- package/project-template/.claude/agents/refs/game-designer-fusion-depth-playbook.md +28 -0
- package/project-template/.claude/agents/refs/game-designer-gameplay-sufficiency.md +4 -4
- package/project-template/.claude/agents/reviewer.md +11 -0
- package/project-template/.claude/hooks/README.md +12 -12
- package/project-template/.claude/hooks/block-developer-preflight.mjs +49 -13
- package/project-template/.claude/hooks/block-ring1-tasklist.mjs +1 -1
- package/project-template/.claude/hooks/user-prompt.mjs +3 -13
- package/project-template/.claude/hooks/validate-workflow-stop.mjs +209 -12
- package/project-template/.claude/settings.json +2 -2
- package/project-template/.claude/skills/playcraft-playable-optimization/SKILL.md +20 -20
- package/project-template/.claude/skills/session-analyzer/scripts/resolve-session.mjs +0 -1
- package/project-template/.claude/snapshot.mjs +0 -1
- package/project-template/.codebuddy/settings.json +1 -1
- package/project-template/.cursor/rules/playcraft-orchestrator.mdc +1 -1
- package/project-template/CLAUDE.md +9 -9
- package/project-template/docs/project-state.md +1 -1
- package/project-template/docs/team/agent-conduct.md +18 -18
- package/project-template/docs/team/collaboration.md +19 -7
- package/project-template/docs/team/workflow-changelog.md +15 -0
- package/project-template/fusion-patterns/catalog.json +77 -0
- package/project-template/package.json +2 -0
- package/project-template/skills/_shared/scripts/create-playable.mjs +31 -11
- package/project-template/skills/_shared/scripts/dev-start.mjs +3 -3
- package/project-template/skills/_shared/scripts/disclosure-config.mjs +65 -39
- package/project-template/skills/_shared/scripts/harness-smoke.mjs +3 -1
- package/project-template/skills/_shared/scripts/lib/fusion-intent.mjs +88 -0
- package/project-template/skills/_shared/scripts/lib/skill-fidelity.mjs +185 -0
- package/project-template/skills/_shared/scripts/slice-set.mjs +29 -2
- package/project-template/skills/_shared/scripts/state-get.mjs +124 -7
- package/project-template/skills/_shared/scripts/state-store.mjs +25 -2
- package/project-template/skills/_shared/scripts/validate-level-sketch.mjs +444 -0
- package/project-template/skills/_shared/scripts/verify-design-consistency.mjs +728 -0
- package/project-template/skills/playable-record/scripts/lib/dev-server.mjs +46 -11
- package/project-template/skills/playable-record/scripts/record-playable.mjs +2 -1
- package/project-template/skills/playable-record/scripts/verify-contract.mjs +4 -4
- package/project-template/skills/playable-record/scripts/verify-firstwin.mjs +3 -3
- package/project-template/skills/playable-record/scripts/verify-fusion.mjs +37 -2
- package/project-template/skills/playable-record/scripts/verify-slice.mjs +2 -2
- package/project-template/templates/design-brief.template.md +34 -0
- package/project-template/templates/design-methodology.md +1 -2
- package/project-template/templates/design.template.md +65 -0
- package/project-template/templates/developer-log.template.md +12 -1
- package/project-template/templates/five-axis-framework.md +12 -0
- package/project-template/templates/game-designer-creative-process.template.md +99 -0
- package/project-template/templates/level-sketch.template.json +56 -0
- package/project-template/templates/north-star.template.md +43 -0
- package/project-template/templates/project-state.template.md +1 -1
- package/project-template/vite.config.ts +4 -5
- package/project-templates/phaser/.claude/agents/creative-director.md +114 -0
- package/project-templates/phaser/.claude/agents/game-designer.md +41 -20
- package/project-templates/phaser/.claude/agents/refs/creative-director-guide.md +35 -0
- package/project-templates/phaser/.claude/agents/refs/creative-director-kickoff-guide.md +52 -0
- package/project-templates/phaser/.claude/agents/refs/creative-discovery-guide.md +76 -0
- package/project-templates/phaser/.claude/agents/refs/creative-north-star-examples.md +82 -0
- package/project-templates/phaser/.claude/agents/refs/developer-dev-handoff.md +1 -1
- package/project-templates/phaser/.claude/agents/refs/game-designer-fusion-depth-playbook.md +28 -0
- package/project-templates/phaser/.claude/agents/reviewer.md +11 -0
- package/project-templates/phaser/.claude/hooks/block-ring1-tasklist.mjs +2 -2
- package/project-templates/phaser/.claude/hooks/user-prompt.mjs +17 -18
- package/project-templates/phaser/.claude/hooks/validate-workflow-stop.mjs +209 -12
- package/project-templates/phaser/.claude/skills/playcraft-playable-optimization/SKILL.md +20 -20
- package/project-templates/phaser/.cursor/rules/playcraft-orchestrator.mdc +1 -1
- package/project-templates/phaser/CLAUDE.md +88 -54
- package/project-templates/phaser/docs/DESIGN.md +25 -0
- package/project-templates/phaser/docs/project-state.md +1 -1
- package/project-templates/phaser/docs/team/collaboration.md +19 -7
- package/project-templates/phaser/docs/team/workflow-changelog.md +14 -0
- package/project-templates/phaser/fusion-patterns/catalog.json +77 -0
- package/project-templates/phaser/package.json +3 -1
- package/project-templates/phaser/skills/_shared/scripts/dev-start.mjs +3 -3
- package/project-templates/phaser/skills/_shared/scripts/disclosure-config.mjs +65 -39
- package/project-templates/phaser/skills/_shared/scripts/harness-smoke.mjs +3 -1
- package/project-templates/phaser/skills/_shared/scripts/lib/fusion-intent.mjs +88 -0
- package/project-templates/phaser/skills/_shared/scripts/lib/skill-fidelity.mjs +185 -0
- package/project-templates/phaser/skills/_shared/scripts/state-get.mjs +124 -7
- package/project-templates/phaser/skills/_shared/scripts/state-store.mjs +25 -2
- package/project-templates/phaser/skills/_shared/scripts/validate-level-sketch.mjs +444 -0
- package/project-templates/phaser/skills/_shared/scripts/verify-design-consistency.mjs +728 -0
- package/project-templates/phaser/skills/playable-record/scripts/lib/dev-server.mjs +46 -11
- package/project-templates/phaser/skills/playable-record/scripts/record-playable.mjs +2 -1
- package/project-templates/phaser/skills/playable-record/scripts/verify-contract.mjs +4 -4
- package/project-templates/phaser/skills/playable-record/scripts/verify-firstwin.mjs +3 -3
- package/project-templates/phaser/skills/playable-record/scripts/verify-fusion.mjs +37 -2
- package/project-templates/phaser/skills/playable-record/scripts/verify-slice.mjs +2 -2
- package/project-templates/phaser/templates/design-brief.template.md +42 -11
- package/project-templates/phaser/templates/design-methodology.md +1 -2
- package/project-templates/phaser/templates/design.template.md +77 -0
- package/project-templates/phaser/templates/five-axis-framework.md +50 -1
- package/project-templates/phaser/templates/game-designer-creative-process.template.md +99 -0
- package/project-templates/phaser/templates/level-sketch.template.json +56 -0
- package/project-templates/phaser/templates/north-star.template.md +43 -0
- package/project-templates/phaser/templates/project-state.template.md +1 -1
- package/project-templates/phaser/vite.config.ts +4 -5
- package/project-templates/threejs/.claude/agents/creative-director.md +114 -0
- package/project-templates/threejs/.claude/agents/game-designer.md +41 -20
- package/project-templates/threejs/.claude/agents/refs/creative-director-guide.md +35 -0
- package/project-templates/threejs/.claude/agents/refs/creative-director-kickoff-guide.md +52 -0
- package/project-templates/threejs/.claude/agents/refs/creative-discovery-guide.md +76 -0
- package/project-templates/threejs/.claude/agents/refs/creative-north-star-examples.md +82 -0
- package/project-templates/threejs/.claude/agents/refs/developer-dev-handoff.md +1 -1
- package/project-templates/threejs/.claude/agents/refs/game-designer-fusion-depth-playbook.md +28 -0
- package/project-templates/threejs/.claude/agents/reviewer.md +11 -0
- package/project-templates/threejs/.claude/hooks/block-ring1-tasklist.mjs +2 -2
- package/project-templates/threejs/.claude/hooks/user-prompt.mjs +17 -18
- package/project-templates/threejs/.claude/hooks/validate-workflow-stop.mjs +209 -12
- package/project-templates/threejs/.claude/skills/playcraft-playable-optimization/SKILL.md +20 -20
- package/project-templates/threejs/.cursor/rules/playcraft-orchestrator.mdc +1 -1
- package/project-templates/threejs/CLAUDE.md +88 -54
- package/project-templates/threejs/docs/DESIGN.md +25 -0
- package/project-templates/threejs/docs/project-state.md +1 -1
- package/project-templates/threejs/docs/team/collaboration.md +19 -7
- package/project-templates/threejs/docs/team/workflow-changelog.md +14 -0
- package/project-templates/threejs/fusion-patterns/catalog.json +77 -0
- package/project-templates/threejs/package.json +3 -1
- package/project-templates/threejs/skills/_shared/scripts/dev-start.mjs +3 -3
- package/project-templates/threejs/skills/_shared/scripts/disclosure-config.mjs +65 -39
- package/project-templates/threejs/skills/_shared/scripts/harness-smoke.mjs +3 -1
- package/project-templates/threejs/skills/_shared/scripts/lib/fusion-intent.mjs +88 -0
- package/project-templates/threejs/skills/_shared/scripts/lib/skill-fidelity.mjs +185 -0
- package/project-templates/threejs/skills/_shared/scripts/state-get.mjs +124 -7
- package/project-templates/threejs/skills/_shared/scripts/state-store.mjs +25 -2
- package/project-templates/threejs/skills/_shared/scripts/validate-level-sketch.mjs +444 -0
- package/project-templates/threejs/skills/_shared/scripts/verify-design-consistency.mjs +728 -0
- package/project-templates/threejs/skills/playable-record/scripts/lib/dev-server.mjs +46 -11
- package/project-templates/threejs/skills/playable-record/scripts/record-playable.mjs +2 -1
- package/project-templates/threejs/skills/playable-record/scripts/verify-contract.mjs +4 -4
- package/project-templates/threejs/skills/playable-record/scripts/verify-firstwin.mjs +3 -3
- package/project-templates/threejs/skills/playable-record/scripts/verify-fusion.mjs +37 -2
- package/project-templates/threejs/skills/playable-record/scripts/verify-slice.mjs +2 -2
- package/project-templates/threejs/templates/design-brief.template.md +42 -11
- package/project-templates/threejs/templates/design-methodology.md +1 -2
- package/project-templates/threejs/templates/design.template.md +77 -0
- package/project-templates/threejs/templates/five-axis-framework.md +50 -1
- package/project-templates/threejs/templates/game-designer-creative-process.template.md +99 -0
- package/project-templates/threejs/templates/level-sketch.template.json +56 -0
- package/project-templates/threejs/templates/north-star.template.md +43 -0
- package/project-templates/threejs/templates/project-state.template.md +1 -1
- package/project-templates/threejs/vite.config.ts +4 -5
- 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
|
+
}
|