refactor(presets): split mixed narration+dialogue beats into separate beats

Preset firstact JSONs had 37% of beats bundling narration and dialogue
on the same beat object, while the current engine (Paradigm D prose
splitter) produces strictly one-type-per-beat output. This mismatch
caused the preset card experience to feel different from actual gameplay.

Split 275 mixed beats across 94 files (120 total) into independent
narration→dialogue beat pairs, preserving all images, characters, voices,
and graph references. Beat count: 738→1013.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
yuanzonghao
2026-06-24 19:06:43 +08:00
parent eb79f3b272
commit f340ab69b5
95 changed files with 233 additions and 94 deletions
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env node
/**
* One-off structural transform: splits mixed beats (narration + dialogue on the
* same beat) into two separate beats so preset firstact JSONs match the current
* engine's Paradigm-D output where each beat is strictly one type.
*
* Safe to run multiple times — already-split files are left unchanged.
*
* node scripts/split-firstact-beats.mjs [--dry-run]
*/
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, "..");
const DRY_RUN = process.argv.includes("--dry-run");
const DIRS = [
resolve(ROOT, "public/home/firstact"),
resolve(ROOT, "public/home/firstact-portrait"),
];
let totalFiles = 0;
let totalBeats = 0;
let totalSplit = 0;
let filesModified = 0;
// Collect transformed data in memory for validation (works in dry-run too)
const transformed = [];
for (const dir of DIRS) {
const files = readdirSync(dir).filter((f) => f.endsWith(".json"));
for (const file of files) {
const path = resolve(dir, file);
const data = JSON.parse(readFileSync(path, "utf8"));
const beats = data.scene?.beats;
if (!beats || !Array.isArray(beats)) continue;
totalFiles++;
let splitCount = 0;
const newBeats = [];
for (const beat of beats) {
const hasNarration = Boolean(beat.narration);
const hasSpeaker = Boolean(beat.speaker);
if (hasNarration && hasSpeaker) {
const dialId = `${beat.id}_d`;
const narrBeat = {
id: beat.id,
narration: beat.narration,
next: { type: "continue", nextBeatId: dialId },
};
const dialBeat = {
id: dialId,
speaker: beat.speaker,
line: beat.line,
...(beat.lineDelivery && { lineDelivery: beat.lineDelivery }),
...(beat.activeCharacters && { activeCharacters: beat.activeCharacters }),
next: beat.next,
};
newBeats.push(narrBeat, dialBeat);
splitCount++;
} else {
newBeats.push(beat);
}
totalBeats++;
}
data.scene.beats = newBeats;
transformed.push({ file, data });
if (splitCount > 0) {
filesModified++;
totalSplit += splitCount;
if (!DRY_RUN) {
writeFileSync(path, JSON.stringify(data));
}
}
}
}
// ── Validation (runs against in-memory data, works for both dry-run and real) ──
console.log("\n=== Split Results ===");
console.log(`Files scanned: ${totalFiles}`);
console.log(`Files modified: ${filesModified}`);
console.log(`Beats scanned: ${totalBeats}`);
console.log(`Beats split: ${totalSplit}`);
console.log(`New total beats: ${totalBeats + totalSplit}`);
if (DRY_RUN) console.log("(dry-run — no files written)");
let errors = 0;
for (const { file, data } of transformed) {
const beats = data.scene.beats;
const label = file;
const beatIds = new Set(beats.map((b) => b.id));
if (!beatIds.has(data.scene.entryBeatId)) {
console.error(`[ERR] ${label}: entryBeatId "${data.scene.entryBeatId}" not found`);
errors++;
}
for (const beat of beats) {
if (beat.narration && beat.speaker) {
console.error(`[ERR] ${label}: beat ${beat.id} still mixed`);
errors++;
}
if (beat.next?.type === "continue" && beat.next.nextBeatId) {
if (!beatIds.has(beat.next.nextBeatId)) {
console.error(`[ERR] ${label}: beat ${beat.id} -> dangling nextBeatId "${beat.next.nextBeatId}"`);
errors++;
}
}
if (beat.next?.type === "choice") {
for (const choice of beat.next.choices ?? []) {
const eff = choice.effect;
if (eff?.kind === "advance-beat" && eff.targetBeatId) {
if (!beatIds.has(eff.targetBeatId)) {
console.error(`[ERR] ${label}: beat ${beat.id} choice -> dangling targetBeatId "${eff.targetBeatId}"`);
errors++;
}
}
}
}
}
}
console.log(`\nValidation: ${errors === 0 ? "PASS ✓" : `FAIL — ${errors} error(s)`}`);
process.exit(errors ? 1 : 0);