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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+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);