import { createHash } from 'node:crypto'; import { stableCinemaJson } from './cinema-shot-compiler.js'; import type { CinemaSha256 } from './cinema-types.js'; import type { CinemaStoryBlueprint, CinemaStoryIssue, CinemaStoryValidation } from './cinema-story-types.js'; function hash(value: unknown): CinemaSha256 { return `sha256:${createHash('sha256').update(stableCinemaJson(value)).digest('hex')}`; } function add(issues: CinemaStoryIssue[], code: string, path: string, message: string): void { issues.push({ code, path, message }); } function text(value: string): boolean { return typeof value === 'string' && value.trim().length > 0; } export function validateCinemaStoryBlueprint(blueprint: CinemaStoryBlueprint): CinemaStoryValidation { const issues: CinemaStoryIssue[] = []; const { creativeContract: creative, storyBible: story } = blueprint; if (creative.projectSlug !== story.projectSlug) add(issues, 'project-mismatch', 'storyBible.projectSlug', 'must match creative contract'); for (const field of ['premise', 'genre', 'tone', 'audience', 'format'] as const) if (!text(creative[field])) add(issues, 'creative-field', `creativeContract.${field}`, 'is required'); if (!Number.isFinite(creative.runtimeSeconds) || creative.runtimeSeconds <= 0) add(issues, 'runtime', 'creativeContract.runtimeSeconds', 'must be positive'); if (new Set(creative.assumptions.map((entry) => entry.field)).size !== creative.assumptions.length) add(issues, 'duplicate-assumption', 'creativeContract.assumptions', 'fields must be unique'); const { contentHash: _creativeHash, ...creativeInput } = creative; if (creative.contentHash !== hash(creativeInput)) add(issues, 'creative-hash', 'creativeContract.contentHash', 'does not match canonical bytes'); if (story.creativeContractHash !== creative.contentHash) add(issues, 'creative-link', 'storyBible.creativeContractHash', 'must bind the current creative contract'); for (const field of ['theme', 'dramaticQuestion', 'opposition', 'stakes', 'ending'] as const) if (!text(story[field])) add(issues, 'story-field', `storyBible.${field}`, 'is required'); const characters = new Map(story.characters.map((character) => [character.characterId, character])); story.characters.forEach((character, index) => { for (const field of ['objective', 'want', 'need', 'contradiction', 'movement', 'stillness', 'stressBehavior', 'currentArcState'] as const) { if (!text(character[field])) add(issues, 'performance-profile', `storyBible.characters[${index}].${field}`, 'is required'); } }); const beats = new Map(story.beats.map((beat) => [beat.beatId, beat])); const setups = new Set(story.beats.flatMap((beat) => beat.setupIds)); const payoffs = new Set(story.beats.flatMap((beat) => beat.payoffIds)); for (const setupId of setups) if (!payoffs.has(setupId)) add(issues, 'unpaid-setup', 'storyBible.beats', `setup ${setupId} has no payoff`); for (const payoffId of payoffs) if (!setups.has(payoffId)) add(issues, 'orphan-payoff', 'storyBible.beats', `payoff ${payoffId} has no setup`); story.beats.forEach((beat, index) => { if (!text(beat.purpose) || !text(beat.change)) add(issues, 'empty-beat', `storyBible.beats[${index}]`, 'requires purpose and change'); for (const id of beat.characterIds) if (!characters.has(id)) add(issues, 'unknown-character', `storyBible.beats[${index}].characterIds`, `unknown ${id}`); }); const sceneTotal = story.scenes.reduce((sum, scene) => sum + scene.durationSeconds, 0); if (Math.abs(sceneTotal - creative.runtimeSeconds) > 0.001) add(issues, 'runtime-total', 'storyBible.scenes', `totals ${sceneTotal}, expected ${creative.runtimeSeconds}`); story.scenes.forEach((scene, index) => { if (!text(scene.purpose) || !text(scene.expectedChange)) add(issues, 'empty-scene', `storyBible.scenes[${index}]`, 'requires purpose and expected change'); if (scene.durationSeconds <= 0) add(issues, 'scene-duration', `storyBible.scenes[${index}].durationSeconds`, 'must be positive'); for (const beatId of scene.beatIds) if (!beats.has(beatId)) add(issues, 'unknown-beat', `storyBible.scenes[${index}].beatIds`, `unknown ${beatId}`); }); story.recognitionMilestones.forEach((milestone, index) => { if (!characters.has(milestone.characterId)) add(issues, 'recognition-character', `storyBible.recognitionMilestones[${index}].characterId`, 'is unknown'); if (!beats.has(milestone.earnedByBeatId)) add(issues, 'recognition-beat', `storyBible.recognitionMilestones[${index}].earnedByBeatId`, 'must name an existing beat'); if (!text(milestone.rationale) || milestone.fromLookId === milestone.toLookId) add(issues, 'recognition-rationale', `storyBible.recognitionMilestones[${index}]`, 'requires distinct looks and rationale'); }); const { contentHash: _storyHash, ...storyInput } = story; if (story.contentHash !== hash(storyInput)) add(issues, 'story-hash', 'storyBible.contentHash', 'does not match canonical bytes'); return { ok: issues.length === 0, issues }; }