137 lines
12 KiB
JavaScript
137 lines
12 KiB
JavaScript
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
|
|
import { createHash } from 'node:crypto';
|
|
import { productionSlot, PRODUCTION_CAMPAIGN, PRODUCTION_PLAN_VERSION } from '../src/production-plan.js';
|
|
import { constructProductionCandidate, inspectCandidate } from '../src/production-generator.js';
|
|
import { assessLevel, canonicalLevelSignature, experienceSignature } from '../src/campaign-evidence.js';
|
|
|
|
const root = new URL('../', import.meta.url);
|
|
const args = process.argv.slice(2);
|
|
const arg = (name, fallback) => { const value = args.find(a => a.startsWith(`${name}=`)); return value ? value.split('=')[1] : fallback; };
|
|
const count = Number(arg('--count', 1000)), choices = Number(arg('--choices', 3));
|
|
if (!Number.isInteger(count) || count < 1 || count > 1000 || !Number.isInteger(choices) || choices < 1 || choices > 8) throw new Error('Invalid count or choices');
|
|
const publish = args.includes('--publish');
|
|
if (publish && count !== 1000) throw new Error('Only a fully validated 1000-level edition may be published');
|
|
const checkpointUrl = new URL('tmp/production-v05/checkpoint.json', root);
|
|
await mkdir(new URL('tmp/production-v05/', root), { recursive: true });
|
|
const digest = text => createHash('sha256').update(text).digest('hex');
|
|
const sourceFiles = ['scripts/build-production-levels.mjs', 'src/production-plan.js', 'src/production-generator.js', 'src/route-grammar.js', 'src/stage-seed.js', 'src/flower-study.js', 'src/experience-generator.js', 'src/campaign-evidence.js', 'src/core.js', 'src/path-math.js', 'src/geometry.js'];
|
|
const sourceHashes = Object.fromEntries(await Promise.all(sourceFiles.map(async path => [path, digest(await readFile(new URL(path, root)))])));
|
|
const pipelineHash = digest(JSON.stringify(sourceHashes));
|
|
const levels = [], rows = [], recipes = [], signatures = new Set(), rejections = {};
|
|
const reject = reason => { rejections[reason] = (rejections[reason] || 0) + 1; };
|
|
if (args.includes('--resume')) {
|
|
const saved = JSON.parse(await readFile(checkpointUrl, 'utf8'));
|
|
if (saved.planVersion !== PRODUCTION_PLAN_VERSION) throw new Error('Checkpoint plan version differs');
|
|
if (saved.pipelineHash !== pipelineHash) throw new Error('Generator/rule source changed; start a fresh batch instead of mixing checkpoints');
|
|
if (saved.choices !== choices) throw new Error('Candidate count differs; resume with the same --choices value');
|
|
levels.push(...saved.levels.slice(0, count)); rows.push(...saved.rows.slice(0, count)); recipes.push(...saved.recipes.slice(0, count));
|
|
Object.assign(rejections, saved.rejections);
|
|
for (const row of rows) signatures.add(row.layoutHash);
|
|
}
|
|
const started = Date.now();
|
|
const checkpoint = async () => {
|
|
const temporary = new URL('tmp/production-v05/checkpoint.next.json', root);
|
|
await writeFile(temporary, JSON.stringify({ planVersion: PRODUCTION_PLAN_VERSION, pipelineHash, sourceHashes, choices, levels, rows, recipes, rejections }));
|
|
await rename(temporary, checkpointUrl);
|
|
};
|
|
|
|
for (let index = levels.length; index < count; index++) {
|
|
const slot = productionSlot(index), shortlist = [];
|
|
let attempts = 0;
|
|
for (; attempts < 2400 && shortlist.length < choices; attempts++) {
|
|
const candidate = constructProductionCandidate(slot, attempts);
|
|
if (candidate.rejected) { reject(candidate.rejected); continue; }
|
|
const inspected = inspectCandidate(candidate.level, slot);
|
|
if (inspected.rejected) { reject(inspected.rejected); continue; }
|
|
const hash = digest(canonicalLevelSignature(candidate.level));
|
|
if (signatures.has(hash) || shortlist.some(c => c.hash === hash)) { reject('duplicate-layout'); continue; }
|
|
const evidence = assessLevel(candidate.level, { actions: candidate.level.experience.actions, policyRuns: 6 });
|
|
if (!evidence.solvable || evidence.exhausted || !evidence.reference.won || !evidence.solverReference.won || evidence.policies.some(p => !p.won)) { reject('policy-or-replay-failed'); continue; }
|
|
const corePolicies = evidence.policies.slice(0, 6);
|
|
const coreLongSeen = corePolicies.filter(p => p.longPaths > 0).length;
|
|
if (slot.number > 2 && coreLongSeen < 5) { reject('natural-play-misses-long-route'); continue; }
|
|
const heroId = candidate.level.experience.feature.id;
|
|
const heroContrasts = corePolicies.filter(p => p.fulfilledReveals.some(e => e.id === heroId && e.bends >= slot.minHeroBends)).length;
|
|
const heroGates = corePolicies.filter(p => p.fulfilledUnlocks.some(e => e.id === heroId && e.onRoute && e.bends >= slot.minHeroBends)).length;
|
|
if (slot.kind === 'contrast' && heroContrasts < (slot.number <= 30 ? 4 : 3)) { reject('natural-play-bypasses-contrast'); continue; }
|
|
if (slot.kind === 'gate' && heroGates < 4) { reject('natural-play-bypasses-gate'); continue; }
|
|
const heroLong = policy => policy.paths.some(path => path.id === heroId && path.bends >= slot.minHeroBends);
|
|
if (corePolicies.filter(heroLong).length < 4) { reject('core-play-misses-primary-route'); continue; }
|
|
const extended = assessLevel(candidate.level, { actions: candidate.level.experience.actions, policyRuns: 20 });
|
|
if (extended.policies.some(policy => !policy.won)) { reject('extended-replay-failed'); continue; }
|
|
const totalLong = extended.policies.filter(policy => policy.longPaths > 0).length;
|
|
const totalHero = extended.policies.filter(heroLong).length;
|
|
if (totalLong < 16 || totalHero < 10) { reject('exploration-misses-primary-reward'); continue; }
|
|
if (slot.kind === 'gate' && extended.policies.filter(p => p.fulfilledUnlocks.some(e => e.id === heroId && e.onRoute && e.bends >= slot.minHeroBends)).length < 8) { reject('exploration-bypasses-gate'); continue; }
|
|
if (slot.kind === 'contrast' && extended.policies.filter(p => p.fulfilledReveals.some(e => e.id === heroId && e.bends >= slot.minHeroBends)).length < 6) { reject('exploration-bypasses-contrast'); continue; }
|
|
const signature = experienceSignature(evidence), recent = rows.slice(-8);
|
|
const repetition = recent.filter(r => r.experienceSignature === signature).length;
|
|
// Compare candidates inside the requested family/intent only. This score is
|
|
// a selection aid (clarity and exposure), never a universal difficulty score.
|
|
const quality = coreLongSeen * 3 + totalLong * .5 + totalHero * .4 + inspected.shapeUsage.ratio * 5
|
|
- evidence.difficulty.sweptBlockerPieces * 1.5 - repetition * 4
|
|
- Math.max(0, evidence.reward.firstLongAction - Math.ceil(candidate.level.tiles.length / 2))
|
|
- Math.max(0, evidence.reference.maxDryRotations - 1) * 3;
|
|
shortlist.push({ ...candidate, inspected, evidence: extended, hash, signature, quality });
|
|
}
|
|
if (!shortlist.length) { await checkpoint(); throw new Error(`No accepted candidate for slot ${index + 1} ${JSON.stringify(slot)} after ${attempts} attempts; ${JSON.stringify(rejections)}`); }
|
|
shortlist.sort((a, b) => b.quality - a.quality);
|
|
const { level, inspected, evidence, hash, signature } = shortlist[0];
|
|
const paths = evidence.reference.paths;
|
|
level.production = {
|
|
...level.production,
|
|
difficulty: { ...evidence.difficulty, requiredRotations: evidence.difficulty.minimumRotations },
|
|
reward: { ...evidence.reward, longestSteps: Math.max(...paths.map(p => p.cells)), longestBends: evidence.reference.maxBends, longRoutes: evidence.reference.longPaths },
|
|
workload: evidence.workload, shapeUsage: inspected.shapeUsage, layoutHash: hash,
|
|
review: { automatic: 'passed', visual: 'pending', player: 'not-tested' },
|
|
};
|
|
level.requiresRotation = inspected.solution.rotations > 0;
|
|
signatures.add(hash); levels.push(level);
|
|
recipes.push({ id: level.id, number: index + 1, attempt: level.production.source.attempt, slot, seed: level.seed, layoutHash: hash });
|
|
rows.push({ id: level.id, level: index + 1, name: level.name, chapter: slot.chapter, kind: slot.kind, family: slot.family,
|
|
cells: level.cells.length, tiles: level.tiles.length, layoutHash: hash, experienceSignature: signature,
|
|
difficulty: level.production.difficulty, reward: level.production.reward, workload: evidence.workload,
|
|
shapeUsage: inspected.shapeUsage, opening: inspected.opening,
|
|
reference: evidence.solverReference, authored: evidence.reference, policies: evidence.policies,
|
|
candidates: { attempts, accepted: shortlist.length, selectedAttempt: level.production.source.attempt },
|
|
review: level.production.review });
|
|
if (levels.length % 10 === 0 || levels.length === count) {
|
|
await checkpoint();
|
|
console.log(`${levels.length}/${count} verified · ${slot.family}/${slot.kind} · ${Math.round((Date.now() - started) / 1000)}s`);
|
|
}
|
|
}
|
|
const tally = values => values.reduce((o, value) => ((o[value] = (o[value] || 0) + 1), o), {});
|
|
const allPaths = rows.flatMap(r => r.reference.paths), policies = rows.flatMap(r => r.policies);
|
|
const summary = {
|
|
levels: levels.length, independentlySolved: rows.filter(r => r.reference.won).length,
|
|
authoredReplaysPassed: rows.filter(r => r.authored.won).length,
|
|
policyRuns: policies.length, policyWins: policies.filter(p => p.won).length,
|
|
policyGroups: { core: 'First six named strategies per level; similar non-rotating policies are not independent player samples', exploration: 'Fourteen additional seeded random strategies per level; all twenty must retain long routes in >=16 and the primary long route in >=10 runs' },
|
|
uniqueLayouts: signatures.size, families: tally(rows.map(r => r.family)), experiences: tally(rows.map(r => r.kind)),
|
|
difficultyTiers: tally(rows.map(r => `D${r.difficulty.tier}`)),
|
|
rotationRequired: rows.filter(r => r.difficulty.requiredRotations > 0).length,
|
|
maxRequiredRotations: Math.max(...rows.map(r => r.difficulty.requiredRotations)),
|
|
longRouteLevels: rows.filter(r => r.reference.longPaths > 0).length,
|
|
longPolicyRuns: policies.filter(p => p.longPaths > 0).length,
|
|
successfulDepartures: allPaths.length, longDepartures: allPaths.filter(p => p.bends >= 6).length,
|
|
longestBends: Math.max(...allPaths.map(p => p.bends)), longestSteps: Math.max(...allPaths.map(p => p.cells)),
|
|
minShapeUsage: Math.min(...rows.map(r => r.shapeUsage.ratio)),
|
|
minHeroShapeRatio: Math.min(...rows.map(r => r.shapeUsage.heroFeaturedRatio)),
|
|
byPolicy: Object.fromEntries([...new Set(policies.map(p => p.name))].map(name => {
|
|
const runs = policies.filter(p => p.name === name);
|
|
return [name, { runs: runs.length, won: runs.filter(p => p.won).length, longRoute: runs.filter(p => p.longPaths > 0).length }];
|
|
})),
|
|
rejections, visualReview: 'pending', playerCalibration: 'not-tested',
|
|
};
|
|
await writeFile(new URL('tmp/production-v05/summary.json', root), JSON.stringify(summary, null, 2) + '\n');
|
|
if (publish) {
|
|
await mkdir(new URL('levels/v0.5/', root), { recursive: true });
|
|
await writeFile(new URL('levels/v0.5.json', root), JSON.stringify(levels) + '\n');
|
|
await writeFile(new URL('levels/v0.5-recipes.json', root), JSON.stringify({ planVersion: PRODUCTION_PLAN_VERSION, pipelineHash, sourceHashes, campaign: PRODUCTION_CAMPAIGN, recipes }, null, 2) + '\n');
|
|
await writeFile(new URL('levels/v0.5-analysis.json', root), JSON.stringify({ campaign: PRODUCTION_CAMPAIGN,
|
|
note: 'All metrics are formal-model observations. Six core policies plus fourteen additional seeded random policies are bounded samples, not real-player probabilities. Long routes use six bends as a diagnostic band. Visual review and player calibration are recorded separately.', summary, levels: rows }) + '\n');
|
|
for (let chapter = 0; chapter < 20; chapter++) await writeFile(new URL(`levels/v0.5/chapter-${String(chapter + 1).padStart(2, '0')}.json`, root), JSON.stringify(levels.slice(chapter * 50, chapter * 50 + 50)) + '\n');
|
|
await writeFile(new URL('src/levels-v05.js', root), '// Generated by npm run levels:v05. Frozen layouts and authored witnesses.\nexport const PRODUCTION_CAMPAIGN = ' + JSON.stringify(PRODUCTION_CAMPAIGN) + ';\nexport const PRODUCTION_LEVELS = ' + JSON.stringify(levels) + ';\n');
|
|
}
|
|
console.log(JSON.stringify(summary, null, 2));
|