feat: add production campaigns, gameplay labs, and review documentation

This commit is contained in:
cabinw
2026-09-13 12:27:10 +08:00
parent d1dffa9338
commit 4644e975b0
214 changed files with 98994 additions and 134 deletions
+136
View File
@@ -0,0 +1,136 @@
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));