use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy)]
pub struct BeatSpec {
pub name: &'static str,
pub act: u8,
pub target_position: f32,
pub expected_tension: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Beat {
pub framework: String,
pub beat: String,
pub act: u8,
pub target_position: f32,
#[serde(default)]
pub mapped_chapter: Option<String>,
#[serde(default)]
pub threads: Vec<String>,
#[serde(default = "default_status")]
pub status: String,
#[serde(default)]
pub notes: String,
}
fn default_status() -> String {
"planned".to_string()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Framework {
ThreeAct,
SaveTheCat,
StoryCircle,
HeroJourney,
SevenPoint,
}
impl Framework {
#[allow(dead_code)]
pub const ALL: [Self; 5] = [
Self::ThreeAct,
Self::SaveTheCat,
Self::StoryCircle,
Self::HeroJourney,
Self::SevenPoint,
];
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().replace([' ', '-'], "_").as_str() {
"three_act" | "threeact" | "3act" | "three" => Some(Self::ThreeAct),
"save_the_cat" | "savethecat" | "stc" | "cat" => Some(Self::SaveTheCat),
"story_circle" | "storycircle" | "circle" => Some(Self::StoryCircle),
"hero_journey" | "herojourney" | "heros_journey" | "hero" => Some(Self::HeroJourney),
"seven_point" | "sevenpoint" | "7point" | "seven" => Some(Self::SevenPoint),
_ => None,
}
}
pub fn slug(self) -> &'static str {
match self {
Self::ThreeAct => "three_act",
Self::SaveTheCat => "save_the_cat",
Self::StoryCircle => "story_circle",
Self::HeroJourney => "hero_journey",
Self::SevenPoint => "seven_point",
}
}
pub fn label(self) -> &'static str {
match self {
Self::ThreeAct => "Three-Act",
Self::SaveTheCat => "Save the Cat",
Self::StoryCircle => "Story Circle",
Self::HeroJourney => "Hero's Journey",
Self::SevenPoint => "Seven-Point",
}
}
pub fn beats(self) -> &'static [BeatSpec] {
match self {
Self::ThreeAct => THREE_ACT,
Self::SaveTheCat => SAVE_THE_CAT,
Self::StoryCircle => STORY_CIRCLE,
Self::HeroJourney => HERO_JOURNEY,
Self::SevenPoint => SEVEN_POINT,
}
}
pub fn seed_beats(self) -> Vec<Beat> {
self.beats()
.iter()
.map(|b| Beat {
framework: self.slug().to_string(),
beat: b.name.to_string(),
act: b.act,
target_position: b.target_position,
mapped_chapter: None,
threads: Vec::new(),
status: default_status(),
notes: String::new(),
})
.collect()
}
}
pub fn beat_body(b: &Beat) -> String {
let mapped = match &b.mapped_chapter {
Some(c) => format!("\"{}\"", esc(c)),
None => "null".to_string(),
};
let threads = if b.threads.is_empty() {
"[]".to_string()
} else {
format!(
"[{}]",
b.threads
.iter()
.map(|t| format!("\"{}\"", esc(t)))
.collect::<Vec<_>>()
.join(", ")
)
};
format!(
"// planning beat — framework: {fw}\n\
{{\n \
framework: \"{fw}\"\n \
beat: \"{beat}\"\n \
act: {act}\n \
// Target fraction through the book (0.0–1.0).\n \
target_position: {pos}\n \
// Chapter slug this beat maps to (null = a gap).\n \
mapped_chapter: {mapped}\n \
// Thread (arc) slugs this beat advances.\n \
threads: {threads}\n \
// planned | drafted | done\n \
status: \"{status}\"\n \
// Author's notes for this structural beat.\n \
notes: \"{notes}\"\n\
}}\n",
fw = esc(&b.framework),
beat = esc(&b.beat),
act = b.act,
pos = b.target_position,
mapped = mapped,
threads = threads,
status = esc(&b.status),
notes = esc(&b.notes),
)
}
#[allow(dead_code)]
pub fn parse_beat(body: &str) -> Option<Beat> {
serde_hjson::from_str(body).ok()
}
fn esc(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
pub const ANALYZE_SLUG: &str = "plan-analyze";
pub fn analyze_system_prompt() -> &'static str {
"You are a developmental editor with deep command of story structure. Using ONLY the supplied \
chapter summaries — never invent plot — do these: (1) map each framework beat to the single \
best-fitting chapter, or say it has no clear home; (2) diagnose the structure plainly: missing or \
weak beats, where the middle sags, and pacing problems; (3) if scene cards are listed, flag any \
scene that states a goal but doesn't turn (no disaster) and suggest the turn it's missing. Be \
specific and concise. No preamble."
}
pub fn analyze_user_prompt(framework: Framework, digest_context: &str, scenes: &[Scene]) -> String {
let mut beats = String::new();
for b in framework.beats() {
beats.push_str(&format!(
"- {} (act {}, ~{:.0}%)\n",
b.name,
b.act,
b.target_position * 100.0
));
}
let scene_block = if scenes.is_empty() {
String::new()
} else {
let mut s = String::from("\nSCENE CARDS (goal → conflict → disaster):\n");
for sc in scenes {
s.push_str(&format!(
"- [{}] {}: goal={} | conflict={} | disaster={}\n",
if sc.chapter.is_empty() { "?" } else { &sc.chapter },
sc.title,
if sc.goal.trim().is_empty() { "—" } else { sc.goal.trim() },
if sc.conflict.trim().is_empty() { "—" } else { sc.conflict.trim() },
if sc.disaster.trim().is_empty() { "—" } else { sc.disaster.trim() },
));
}
s
};
format!(
"STORY-STRUCTURE FRAMEWORK: {label}\nBeats (with target position through the book):\n{beats}{scene_block}\n\
BOOK:\n{digest_context}\n\nMap the beats to chapters, then diagnose the structure.",
label = framework.label(),
)
}
pub const SCAFFOLD_SLUG: &str = "plan-scaffold";
pub fn scaffold_system_prompt() -> &'static str {
"You are a story architect. Given a premise and a beat sheet, write a concrete 1–2 sentence \
intention for EACH beat — what actually happens at that beat in this story. These are planning \
notes, not prose. Output exactly one line per beat in the form `<Beat Name>: <intention>`, using \
the beat names verbatim, in order, with no numbering and no preamble."
}
pub fn scaffold_user_prompt(framework: Framework, premise: &str) -> String {
let mut names = String::new();
for b in framework.beats() {
names.push_str(&format!("- {}\n", b.name));
}
format!(
"PREMISE / LOGLINE: {premise}\n\nSTORY-STRUCTURE FRAMEWORK: {label}\n\nWrite a `<Beat Name>: \
<intention>` line for each beat below, in order.\n\nBEATS:\n{names}",
label = framework.label(),
)
}
pub fn parse_scaffold(raw: &str, beats: &[Beat]) -> Vec<(String, String)> {
let mut out = Vec::new();
for line in raw.lines() {
let Some((name, intention)) = line.split_once(':') else {
continue;
};
let name = name
.trim()
.trim_start_matches(|c: char| c.is_ascii_digit() || c == '.' || c == '-' || c == ')')
.trim();
let intention = intention.trim();
if intention.is_empty() {
continue;
}
if let Some(b) = beats.iter().find(|b| b.beat.eq_ignore_ascii_case(name)) {
if !out.iter().any(|(n, _): &(String, String)| n == &b.beat) {
out.push((b.beat.clone(), intention.to_string()));
}
}
}
out
}
#[derive(Debug, Clone)]
pub struct ChapterPos {
pub slug: String,
pub start: f32,
}
#[derive(Debug, Clone, Serialize)]
pub struct BeatStatus {
pub beat: String,
pub act: u8,
pub target_position: f32,
pub mapped_chapter: Option<String>,
pub actual_position: Option<f32>,
pub drift: Option<f32>,
pub threads: Vec<String>,
pub unknown_threads: Vec<String>,
pub notes: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ActPacing {
pub act: u8,
pub expected: f32,
pub actual: Option<f32>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ChapterRef {
pub slug: String,
pub position: f32,
}
#[derive(Debug, Clone, Serialize)]
pub struct PlanReport {
pub beats: Vec<BeatStatus>,
pub gaps: Vec<String>,
pub acts: Vec<ActPacing>,
pub warnings: Vec<String>,
pub chapters: Vec<ChapterRef>,
pub available_threads: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tension: Option<TensionCurve>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub scenes: Vec<SceneStatus>,
}
pub fn analyze(
beats: &[Beat],
chapters: &[ChapterPos],
drift_threshold: f32,
known_threads: &std::collections::BTreeSet<String>,
) -> PlanReport {
use std::collections::{BTreeSet, HashMap};
let pos: HashMap<&str, f32> =
chapters.iter().map(|c| (c.slug.as_str(), c.start)).collect();
let mut statuses = Vec::with_capacity(beats.len());
let mut gaps = Vec::new();
let mut mapped_without_thread = 0usize;
for b in beats {
let actual = b
.mapped_chapter
.as_deref()
.and_then(|c| pos.get(c).copied());
if b.mapped_chapter.is_none() {
gaps.push(b.beat.clone());
}
let unknown_threads: Vec<String> = b
.threads
.iter()
.filter(|t| !known_threads.is_empty() && !known_threads.contains(*t))
.cloned()
.collect();
if b.mapped_chapter.is_some() && b.threads.is_empty() {
mapped_without_thread += 1;
}
statuses.push(BeatStatus {
beat: b.beat.clone(),
act: b.act,
target_position: b.target_position,
mapped_chapter: b.mapped_chapter.clone(),
actual_position: actual,
drift: actual.map(|a| a - b.target_position),
threads: b.threads.clone(),
unknown_threads,
notes: b.notes.clone(),
});
}
let acts_vec: Vec<u8> = beats.iter().map(|b| b.act).collect::<BTreeSet<_>>().into_iter().collect();
let first_of = |act: u8| beats.iter().find(|b| b.act == act);
let target_start = |act: u8| -> f32 {
if acts_vec.first() == Some(&act) {
0.0
} else {
first_of(act).map(|b| b.target_position).unwrap_or(0.0)
}
};
let actual_start = |act: u8| -> Option<f32> {
if acts_vec.first() == Some(&act) {
return Some(0.0); }
first_of(act)
.and_then(|b| b.mapped_chapter.as_deref())
.and_then(|c| pos.get(c).copied())
};
let mut acts = Vec::new();
for (i, &a) in acts_vec.iter().enumerate() {
let exp_end = acts_vec.get(i + 1).map(|&n| target_start(n)).unwrap_or(1.0);
let expected = (exp_end - target_start(a)).max(0.0);
let act_end = acts_vec.get(i + 1).map(|&n| actual_start(n)).unwrap_or(Some(1.0));
let actual = match (actual_start(a), act_end) {
(Some(s), Some(e)) => Some((e - s).max(0.0)),
_ => None,
};
acts.push(ActPacing { act: a, expected, actual });
}
let mut warnings = Vec::new();
for g in &gaps {
warnings.push(format!("gap: `{g}` is unmapped"));
}
for s in &statuses {
if let (Some(d), Some(a)) = (s.drift, s.actual_position) {
if d.abs() > drift_threshold {
warnings.push(format!(
"drift: `{}` lands at {:.0}% (target {:.0}%, {:+.0}%)",
s.beat,
a * 100.0,
s.target_position * 100.0,
d * 100.0
));
}
}
}
for p in &acts {
if let Some(a) = p.actual {
let dev = a - p.expected;
if dev.abs() > drift_threshold {
warnings.push(format!(
"pacing: Act {} is {:.0}% of words (expected {:.0}%, {})",
p.act,
a * 100.0,
p.expected * 100.0,
if dev > 0.0 { "long" } else { "short" }
));
}
}
}
for s in &statuses {
for t in &s.unknown_threads {
warnings.push(format!("thread: `{}` references unknown thread `{t}`", s.beat));
}
}
if mapped_without_thread > 0 && beats.iter().any(|b| !b.threads.is_empty()) {
warnings.push(format!(
"threads: {mapped_without_thread} mapped beat(s) advance no tracked thread — link them in each beat's `threads`"
));
}
let chapter_refs = chapters
.iter()
.map(|c| ChapterRef { slug: c.slug.clone(), position: c.start })
.collect();
PlanReport {
beats: statuses,
gaps,
acts,
warnings,
chapters: chapter_refs,
available_threads: known_threads.iter().cloned().collect(),
tension: None,
scenes: Vec::new(),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Scene {
#[serde(default)]
pub chapter: String,
pub title: String,
#[serde(default)]
pub goal: String,
#[serde(default)]
pub conflict: String,
#[serde(default)]
pub disaster: String,
#[serde(default = "default_status")]
pub status: String,
}
pub fn scene_body(s: &Scene) -> String {
format!(
"// planning scene card — goal → conflict → disaster\n\
{{\n \
chapter: \"{chapter}\"\n \
title: \"{title}\"\n \
// What the POV character wants in this scene.\n \
goal: \"{goal}\"\n \
// What stands in the way.\n \
conflict: \"{conflict}\"\n \
// The turn — how the scene ends worse / changed. A scene with no\n \
// disaster doesn't turn.\n \
disaster: \"{disaster}\"\n \
// planned | drafted | done\n \
status: \"{status}\"\n\
}}\n",
chapter = esc(&s.chapter),
title = esc(&s.title),
goal = esc(&s.goal),
conflict = esc(&s.conflict),
disaster = esc(&s.disaster),
status = esc(&s.status),
)
}
pub fn parse_scene(body: &str) -> Option<Scene> {
serde_hjson::from_str(body).ok()
}
#[derive(Debug, Clone, Serialize)]
pub struct SceneStatus {
pub title: String,
pub chapter: String,
pub has_goal: bool,
pub has_conflict: bool,
pub has_disaster: bool,
pub no_turn: bool,
}
pub fn analyze_scenes(scenes: &[Scene]) -> (Vec<SceneStatus>, Vec<String>) {
let mut statuses = Vec::with_capacity(scenes.len());
let mut warnings = Vec::new();
for s in scenes {
let has_goal = !s.goal.trim().is_empty();
let has_conflict = !s.conflict.trim().is_empty();
let has_disaster = !s.disaster.trim().is_empty();
let no_turn = has_goal && !has_disaster;
if no_turn {
warnings.push(format!(
"scene: `{}` states a goal but never turns (no disaster)",
s.title
));
}
statuses.push(SceneStatus {
title: s.title.clone(),
chapter: s.chapter.clone(),
has_goal,
has_conflict,
has_disaster,
no_turn,
});
}
(statuses, warnings)
}
#[derive(Debug, Clone, Copy)]
pub struct OpenSpan {
pub start: f32,
pub end: f32,
pub weight: f32,
}
#[derive(Debug, Clone, Serialize)]
pub struct TensionPoint {
pub beat: String,
pub position: Option<f32>,
pub expected: f32,
pub actual: Option<f32>,
pub gap: Option<f32>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TensionCurve {
pub points: Vec<TensionPoint>,
pub series: Vec<(f32, f32)>,
pub has_actual: bool,
pub warnings: Vec<String>,
}
fn open_load(spans: &[OpenSpan], position: f32) -> f32 {
spans
.iter()
.filter(|s| s.start <= position && position < s.end)
.map(|s| s.weight)
.sum()
}
fn expected_tension_for(framework: &str, beat: &str) -> f32 {
Framework::parse(framework)
.and_then(|fw| {
fw.beats()
.iter()
.find(|b| b.name == beat)
.map(|b| b.expected_tension)
})
.unwrap_or(0.5)
}
pub fn tension_curve(
beats: &[Beat],
chapters: &[ChapterPos],
spans: &[OpenSpan],
flat_threshold: f32,
) -> TensionCurve {
use std::collections::HashMap;
let pos: HashMap<&str, f32> = chapters.iter().map(|c| (c.slug.as_str(), c.start)).collect();
let mut sample_pos: Vec<f32> = chapters.iter().map(|c| c.start).collect();
sample_pos.push(1.0);
let raw: Vec<f32> = sample_pos.iter().map(|&p| open_load(spans, p)).collect();
let max_load = raw.iter().copied().fold(0.0f32, f32::max);
let has_actual = max_load > 0.0;
let norm = |load: f32| if max_load > 0.0 { (load / max_load).clamp(0.0, 1.0) } else { 0.0 };
let series: Vec<(f32, f32)> = sample_pos
.iter()
.zip(&raw)
.map(|(&p, &l)| (p, norm(l)))
.collect();
let mut points = Vec::with_capacity(beats.len());
let mut warnings = Vec::new();
for b in beats {
let expected = expected_tension_for(&b.framework, &b.beat);
let position = b.mapped_chapter.as_deref().and_then(|c| pos.get(c).copied());
let actual = if has_actual {
position.map(|p| norm(open_load(spans, p)))
} else {
None
};
let gap = actual.map(|a| expected - a);
if let Some(g) = gap {
if expected >= 0.5 && g > flat_threshold {
warnings.push(format!(
"tension: `{}` is flat — actual {:.0}% vs expected {:.0}%",
b.beat,
actual.unwrap_or(0.0) * 100.0,
expected * 100.0
));
}
}
points.push(TensionPoint {
beat: b.beat.clone(),
position,
expected,
actual,
gap,
});
}
TensionCurve {
points,
series,
has_actual,
warnings,
}
}
pub fn intensity_sparkline(points: &[(f32, f32)], width: usize) -> String {
const RAMP: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
let sample = |p: f32| -> f32 {
if points.is_empty() {
return 0.0;
}
if p <= points[0].0 {
return points[0].1;
}
let last = points[points.len() - 1];
if p >= last.0 {
return last.1;
}
for w in points.windows(2) {
let ((x0, y0), (x1, y1)) = (w[0], w[1]);
if p >= x0 && p <= x1 {
let f = if (x1 - x0).abs() < 1e-6 { 0.0 } else { (p - x0) / (x1 - x0) };
return y0 + (y1 - y0) * f;
}
}
last.1
};
(0..width)
.map(|i| {
let v = sample((i as f32 + 0.5) / width.max(1) as f32).clamp(0.0, 1.0);
RAMP[((v * 7.0).round() as usize).min(7)]
})
.collect()
}
const THREE_ACT: &[BeatSpec] = &[
BeatSpec { name: "Opening", act: 1, target_position: 0.00, expected_tension: 0.10 },
BeatSpec { name: "Inciting Incident", act: 1, target_position: 0.10, expected_tension: 0.35 },
BeatSpec { name: "Plot Point One", act: 2, target_position: 0.25, expected_tension: 0.45 },
BeatSpec { name: "First Pinch Point", act: 2, target_position: 0.375, expected_tension: 0.55 },
BeatSpec { name: "Midpoint", act: 2, target_position: 0.50, expected_tension: 0.65 },
BeatSpec { name: "Second Pinch Point", act: 2, target_position: 0.625, expected_tension: 0.75 },
BeatSpec { name: "Plot Point Two", act: 3, target_position: 0.75, expected_tension: 0.85 },
BeatSpec { name: "Climax", act: 3, target_position: 0.90, expected_tension: 1.00 },
BeatSpec { name: "Resolution", act: 3, target_position: 1.00, expected_tension: 0.15 },
];
const SAVE_THE_CAT: &[BeatSpec] = &[
BeatSpec { name: "Opening Image", act: 1, target_position: 0.00, expected_tension: 0.10 },
BeatSpec { name: "Theme Stated", act: 1, target_position: 0.05, expected_tension: 0.15 },
BeatSpec { name: "Set-Up", act: 1, target_position: 0.08, expected_tension: 0.20 },
BeatSpec { name: "Catalyst", act: 1, target_position: 0.10, expected_tension: 0.35 },
BeatSpec { name: "Debate", act: 1, target_position: 0.15, expected_tension: 0.30 },
BeatSpec { name: "Break into Two", act: 2, target_position: 0.20, expected_tension: 0.40 },
BeatSpec { name: "B Story", act: 2, target_position: 0.22, expected_tension: 0.35 },
BeatSpec { name: "Fun and Games", act: 2, target_position: 0.30, expected_tension: 0.45 },
BeatSpec { name: "Midpoint", act: 2, target_position: 0.50, expected_tension: 0.65 },
BeatSpec { name: "Bad Guys Close In", act: 2, target_position: 0.62, expected_tension: 0.75 },
BeatSpec { name: "All Is Lost", act: 2, target_position: 0.75, expected_tension: 0.90 },
BeatSpec { name: "Dark Night of the Soul", act: 2, target_position: 0.77, expected_tension: 0.80 },
BeatSpec { name: "Break into Three", act: 3, target_position: 0.80, expected_tension: 0.70 },
BeatSpec { name: "Finale", act: 3, target_position: 0.90, expected_tension: 1.00 },
BeatSpec { name: "Final Image", act: 3, target_position: 1.00, expected_tension: 0.15 },
];
const STORY_CIRCLE: &[BeatSpec] = &[
BeatSpec { name: "You (comfort zone)", act: 1, target_position: 0.00, expected_tension: 0.10 },
BeatSpec { name: "Need", act: 1, target_position: 0.125, expected_tension: 0.30 },
BeatSpec { name: "Go (cross the threshold)", act: 2, target_position: 0.25, expected_tension: 0.45 },
BeatSpec { name: "Search (adapt)", act: 2, target_position: 0.375, expected_tension: 0.55 },
BeatSpec { name: "Find (get what they wanted)", act: 2, target_position: 0.50, expected_tension: 0.65 },
BeatSpec { name: "Take (pay the price)", act: 2, target_position: 0.625, expected_tension: 0.85 },
BeatSpec { name: "Return", act: 3, target_position: 0.75, expected_tension: 1.00 },
BeatSpec { name: "Change", act: 3, target_position: 0.875, expected_tension: 0.25 },
];
const HERO_JOURNEY: &[BeatSpec] = &[
BeatSpec { name: "Ordinary World", act: 1, target_position: 0.00, expected_tension: 0.10 },
BeatSpec { name: "Call to Adventure", act: 1, target_position: 0.08, expected_tension: 0.35 },
BeatSpec { name: "Refusal of the Call", act: 1, target_position: 0.12, expected_tension: 0.30 },
BeatSpec { name: "Meeting the Mentor", act: 1, target_position: 0.17, expected_tension: 0.28 },
BeatSpec { name: "Crossing the Threshold", act: 2, target_position: 0.25, expected_tension: 0.45 },
BeatSpec { name: "Tests, Allies, Enemies", act: 2, target_position: 0.35, expected_tension: 0.50 },
BeatSpec { name: "Approach to the Inmost Cave", act: 2, target_position: 0.45, expected_tension: 0.60 },
BeatSpec { name: "The Ordeal", act: 2, target_position: 0.50, expected_tension: 0.80 },
BeatSpec { name: "Reward", act: 2, target_position: 0.60, expected_tension: 0.45 },
BeatSpec { name: "The Road Back", act: 3, target_position: 0.75, expected_tension: 0.65 },
BeatSpec { name: "Resurrection", act: 3, target_position: 0.90, expected_tension: 1.00 },
BeatSpec { name: "Return with the Elixir", act: 3, target_position: 1.00, expected_tension: 0.15 },
];
const SEVEN_POINT: &[BeatSpec] = &[
BeatSpec { name: "Hook", act: 1, target_position: 0.00, expected_tension: 0.15 },
BeatSpec { name: "Plot Turn One", act: 2, target_position: 0.25, expected_tension: 0.40 },
BeatSpec { name: "Pinch Point One", act: 2, target_position: 0.375, expected_tension: 0.55 },
BeatSpec { name: "Midpoint", act: 2, target_position: 0.50, expected_tension: 0.65 },
BeatSpec { name: "Pinch Point Two", act: 2, target_position: 0.625, expected_tension: 0.80 },
BeatSpec { name: "Plot Turn Two", act: 3, target_position: 0.75, expected_tension: 0.90 },
BeatSpec { name: "Resolution", act: 3, target_position: 1.00, expected_tension: 1.00 },
];
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
#[test]
fn every_framework_table_is_well_formed() {
for fw in Framework::ALL {
let beats = fw.beats();
assert!(beats.len() >= 7, "{} has enough beats", fw.slug());
let names: BTreeSet<_> = beats.iter().map(|b| b.name).collect();
assert_eq!(names.len(), beats.len(), "{} beat names distinct", fw.slug());
let mut prev_pos = -1.0f32;
let mut prev_act = 0u8;
for b in beats {
assert!(
(0.0..=1.0).contains(&b.target_position),
"{}/{} position in range",
fw.slug(),
b.name
);
assert!(
b.target_position >= prev_pos,
"{}/{} positions monotonic",
fw.slug(),
b.name
);
assert!((1..=3).contains(&b.act), "{}/{} act 1..3", fw.slug(), b.name);
assert!(b.act >= prev_act, "{}/{} acts non-decreasing", fw.slug(), b.name);
assert!(
(0.0..=1.0).contains(&b.expected_tension),
"{}/{} expected_tension in range",
fw.slug(),
b.name
);
prev_pos = b.target_position;
prev_act = b.act;
}
assert!(beats[0].target_position < 1e-6, "{} opens at 0", fw.slug());
let peak = beats
.iter()
.max_by(|a, b| a.expected_tension.partial_cmp(&b.expected_tension).unwrap())
.unwrap();
assert!((peak.expected_tension - 1.0).abs() < 1e-6, "{} peaks at 1.0", fw.slug());
assert!(peak.target_position >= 0.6, "{} peak is in the back half", fw.slug());
assert!(beats[0].expected_tension < 0.3, "{} opens calm", fw.slug());
}
}
#[test]
fn framework_parse_round_trips_slug() {
for fw in Framework::ALL {
assert_eq!(Framework::parse(fw.slug()), Some(fw));
}
assert_eq!(Framework::parse("Save The Cat"), Some(Framework::SaveTheCat));
assert_eq!(Framework::parse("7point"), Some(Framework::SevenPoint));
assert!(Framework::parse("freytag").is_none());
}
fn beat(name: &str, act: u8, target: f32, mapped: Option<&str>) -> Beat {
Beat {
framework: "t".into(),
beat: name.into(),
act,
target_position: target,
mapped_chapter: mapped.map(|s| s.to_string()),
threads: vec![],
status: "planned".into(),
notes: String::new(),
}
}
#[test]
fn analyze_flags_gaps_drift_and_pacing() {
let beats = vec![
beat("A", 1, 0.0, Some("c1")),
beat("B", 2, 0.5, Some("c2")), beat("C", 3, 0.9, None), ];
let chapters = vec![
ChapterPos { slug: "c1".into(), start: 0.0 },
ChapterPos { slug: "c2".into(), start: 0.65 },
];
let r = analyze(&beats, &chapters, 0.10, &Default::default());
assert_eq!(r.gaps, vec!["C"]);
let b = r.beats.iter().find(|s| s.beat == "B").unwrap();
assert!((b.drift.unwrap() - 0.15).abs() < 1e-5, "B drifts +15%");
assert!(r.beats.iter().find(|s| s.beat == "A").unwrap().drift.unwrap().abs() < 1e-6);
let act1 = r.acts.iter().find(|p| p.act == 1).unwrap();
assert!((act1.expected - 0.5).abs() < 1e-6, "act1 expected 0..0.5");
assert!((act1.actual.unwrap() - 0.65).abs() < 1e-5, "act1 actual 0..0.65");
assert!(r.acts.iter().find(|p| p.act == 2).unwrap().actual.is_none());
assert!(r.warnings.iter().any(|w| w.contains("gap: `C`")));
assert!(r.warnings.iter().any(|w| w.contains("drift: `B`")));
assert!(r.warnings.iter().any(|w| w.contains("Act 1") && w.contains("long")));
}
#[test]
fn expected_act_proportions_are_canonical() {
let r = analyze(&Framework::ThreeAct.seed_beats(), &[], 0.10, &Default::default());
let exp: Vec<f32> = r.acts.iter().map(|a| a.expected).collect();
assert_eq!(exp.len(), 3);
assert!((exp[0] - 0.25).abs() < 1e-6, "act1 25%");
assert!((exp[1] - 0.50).abs() < 1e-6, "act2 50%");
assert!((exp[2] - 0.25).abs() < 1e-6, "act3 25%");
for fw in Framework::ALL {
let r = analyze(&fw.seed_beats(), &[], 0.10, &Default::default());
let sum: f32 = r.acts.iter().map(|a| a.expected).sum();
assert!((sum - 1.0).abs() < 1e-5, "{} sums to 1", fw.slug());
assert!(
(0.15..=0.30).contains(&r.acts[0].expected),
"{} act1 is a sane setup ({})",
fw.slug(),
r.acts[0].expected
);
}
}
#[test]
fn analyze_surfaces_and_validates_thread_links() {
let mut a = beat("A", 1, 0.0, Some("c1"));
a.threads = vec!["the-inheritance".into()];
let mut b = beat("B", 2, 0.5, Some("c2"));
b.threads = vec!["ghost-thread".into()]; let c = beat("C", 3, 0.9, Some("c3")); let chapters = vec![
ChapterPos { slug: "c1".into(), start: 0.0 },
ChapterPos { slug: "c2".into(), start: 0.5 },
ChapterPos { slug: "c3".into(), start: 0.9 },
];
let known: std::collections::BTreeSet<String> =
["the-inheritance".to_string()].into_iter().collect();
let r = analyze(&[a, b, c], &chapters, 0.10, &known);
assert_eq!(r.beats[0].threads, vec!["the-inheritance"]);
assert_eq!(r.beats[1].unknown_threads, vec!["ghost-thread"]);
assert!(r.warnings.iter().any(|w| w.contains("unknown thread `ghost-thread`")));
assert!(r.warnings.iter().any(|w| w.contains("advance no tracked thread")));
}
#[test]
fn analyze_clean_structure_has_no_warnings() {
let beats = vec![
beat("A", 1, 0.0, Some("c1")),
beat("B", 2, 0.25, Some("c2")),
beat("C", 3, 0.75, Some("c3")),
];
let chapters = vec![
ChapterPos { slug: "c1".into(), start: 0.0 },
ChapterPos { slug: "c2".into(), start: 0.25 },
ChapterPos { slug: "c3".into(), start: 0.75 },
];
let r = analyze(&beats, &chapters, 0.10, &Default::default());
assert!(r.gaps.is_empty());
assert!(r.warnings.is_empty(), "unexpected warnings: {:?}", r.warnings);
assert!((r.acts.iter().find(|p| p.act == 2).unwrap().actual.unwrap() - 0.5).abs() < 1e-5);
}
#[test]
fn scaffold_prompt_and_parse() {
let p = scaffold_user_prompt(Framework::ThreeAct, "A lighthouse keeper hides a body");
assert!(p.contains("A lighthouse keeper hides a body"));
assert!(p.contains("Three-Act"));
assert!(p.contains("- Midpoint"));
let beats = Framework::ThreeAct.seed_beats();
let raw = "Opening: A quiet town wakes.\n\
1. Midpoint: The truth lands hard.\n\
Climax: They finally face it.\n\
Nonsense: ignore me\n\
Resolution: ";
let out = parse_scaffold(raw, &beats);
assert!(out.iter().any(|(n, i)| n == "Opening" && i == "A quiet town wakes."));
assert!(out.iter().any(|(n, i)| n == "Midpoint" && i == "The truth lands hard."));
assert!(out.iter().any(|(n, _)| n == "Climax"));
assert!(!out.iter().any(|(n, _)| n == "Nonsense"));
assert!(!out.iter().any(|(n, _)| n == "Resolution"));
}
#[test]
fn analyze_prompt_carries_framework_and_context() {
let p = analyze_user_prompt(Framework::SaveTheCat, "TITLE: X\nCHAPTER SUMMARIES:\n1. Foo", &[]);
assert!(p.contains("Save the Cat"));
assert!(p.contains("Midpoint (act 2, ~50%)"));
assert!(p.contains("CHAPTER SUMMARIES:"));
assert!(!p.contains("SCENE CARDS"), "no scene block when none supplied");
assert!(!analyze_system_prompt().is_empty());
}
#[test]
fn analyze_prompt_includes_scene_cards() {
let scenes = vec![Scene {
chapter: "the-wharf".into(),
title: "Confrontation".into(),
goal: "get the manifest".into(),
conflict: "he stonewalls".into(),
disaster: "".into(),
status: "planned".into(),
}];
let p = analyze_user_prompt(Framework::ThreeAct, "TITLE: X", &scenes);
assert!(p.contains("SCENE CARDS"));
assert!(p.contains("Confrontation"));
assert!(p.contains("get the manifest"));
assert!(p.contains("disaster=—"), "empty disaster rendered as —");
}
#[test]
fn beat_body_round_trips_through_hjson() {
let beats = Framework::SaveTheCat.seed_beats();
let mid = beats.iter().find(|b| b.beat == "Midpoint").unwrap();
let back = parse_beat(&beat_body(mid)).expect("parses");
assert_eq!(back.framework, "save_the_cat");
assert_eq!(back.beat, "Midpoint");
assert_eq!(back.act, 2);
assert!((back.target_position - 0.50).abs() < 1e-6);
assert_eq!(back.status, "planned");
assert!(back.mapped_chapter.is_none());
let mut mapped = mid.clone();
mapped.mapped_chapter = Some("the-wharf".into());
mapped.threads = vec!["the-inheritance".into(), "the-secret".into()];
mapped.notes = "The truth lands; she can't unsee it.".into();
let back = parse_beat(&beat_body(&mapped)).unwrap();
assert_eq!(back.mapped_chapter.as_deref(), Some("the-wharf"));
assert_eq!(back.threads, vec!["the-inheritance", "the-secret"]);
assert_eq!(back.notes, "The truth lands; she can't unsee it.");
}
#[test]
fn open_load_counts_covering_spans() {
let spans = vec![
OpenSpan { start: 0.0, end: 0.5, weight: 1.0 },
OpenSpan { start: 0.2, end: 0.8, weight: 1.0 },
OpenSpan { start: 0.6, end: 1.0, weight: 2.0 },
];
assert_eq!(open_load(&spans, 0.1), 1.0); assert_eq!(open_load(&spans, 0.3), 2.0); assert_eq!(open_load(&spans, 0.7), 3.0); assert_eq!(open_load(&spans, 0.9), 2.0); assert_eq!(open_load(&spans, 0.5), 1.0); }
#[test]
fn expected_tension_resolves_from_framework_table() {
assert!((expected_tension_for("three_act", "Climax") - 1.0).abs() < 1e-6);
assert!(expected_tension_for("three_act", "Opening") < 0.3);
assert!((expected_tension_for("nope", "x") - 0.5).abs() < 1e-6);
assert!((expected_tension_for("three_act", "Nonexistent") - 0.5).abs() < 1e-6);
}
fn tbeat(name: &str, target: f32, mapped: &str) -> Beat {
Beat {
framework: "three_act".into(),
beat: name.into(),
act: 2,
target_position: target,
mapped_chapter: Some(mapped.into()),
threads: vec![],
status: "planned".into(),
notes: String::new(),
}
}
#[test]
fn tension_curve_flags_a_flat_high_beat() {
let beats = vec![tbeat("Midpoint", 0.5, "mid"), tbeat("Climax", 0.9, "end")];
let chapters = vec![
ChapterPos { slug: "mid".into(), start: 0.5 },
ChapterPos { slug: "end".into(), start: 0.9 },
];
let spans = vec![
OpenSpan { start: 0.85, end: 1.0, weight: 1.0 },
OpenSpan { start: 0.85, end: 1.0, weight: 1.0 },
OpenSpan { start: 0.85, end: 1.0, weight: 1.0 },
];
let curve = tension_curve(&beats, &chapters, &spans, 0.25);
assert!(curve.has_actual);
let mid = curve.points.iter().find(|p| p.beat == "Midpoint").unwrap();
let climax = curve.points.iter().find(|p| p.beat == "Climax").unwrap();
assert_eq!(mid.actual, Some(0.0), "no obligations open at the midpoint");
assert_eq!(climax.actual, Some(1.0), "climax carries the normalized peak");
assert!(curve.warnings.iter().any(|w| w.contains("Midpoint") && w.contains("flat")));
assert!(!curve.warnings.iter().any(|w| w.contains("Climax")), "climax isn't flat");
}
#[test]
fn intensity_sparkline_tracks_control_points() {
let rise = intensity_sparkline(&[(0.0, 0.0), (1.0, 1.0)], 8);
let r: Vec<char> = rise.chars().collect();
assert_eq!(r.len(), 8);
assert!(r[0] < r[7], "rises left→right");
assert_eq!(r[7], '█');
let flat = intensity_sparkline(&[(0.0, 0.0), (1.0, 0.0)], 5);
assert!(flat.chars().all(|c| c == '▁'), "flat low: {flat}");
let peak = intensity_sparkline(&[(0.0, 0.0), (0.5, 1.0), (1.0, 0.0)], 9);
let p: Vec<char> = peak.chars().collect();
assert!(p[4] > p[0] && p[4] > p[8], "peaks in the middle: {peak}");
assert_eq!(intensity_sparkline(&[], 4).chars().count(), 4);
}
#[test]
fn scene_body_round_trips() {
let s = Scene {
chapter: "the-wharf".into(),
title: "Mara confronts the harbourmaster".into(),
goal: "get the manifest".into(),
conflict: "he stonewalls".into(),
disaster: "he names her father as the debtor".into(),
status: "drafted".into(),
};
let back = parse_scene(&scene_body(&s)).expect("parses");
assert_eq!(back.chapter, "the-wharf");
assert_eq!(back.goal, "get the manifest");
assert_eq!(back.disaster, "he names her father as the debtor");
assert_eq!(back.status, "drafted");
}
#[test]
fn analyze_scenes_flags_a_scene_that_doesnt_turn() {
let scenes = vec![
Scene {
chapter: "c1".into(),
title: "Turns".into(),
goal: "find the letter".into(),
conflict: "the room is locked".into(),
disaster: "the letter is already gone".into(),
status: "planned".into(),
},
Scene {
chapter: "c2".into(),
title: "Flat".into(),
goal: "win the argument".into(),
conflict: "".into(),
disaster: "".into(), status: "planned".into(),
},
];
let (st, warn) = analyze_scenes(&scenes);
assert_eq!(st.len(), 2);
assert!(!st[0].no_turn, "a scene with a disaster turns");
assert!(st[1].no_turn, "goal + no disaster → flat");
assert_eq!(warn.len(), 1);
assert!(warn[0].contains("Flat") && warn[0].contains("never turns"));
}
#[test]
fn tension_curve_no_data_is_expected_only() {
let beats = vec![tbeat("Midpoint", 0.5, "mid")];
let chapters = vec![ChapterPos { slug: "mid".into(), start: 0.5 }];
let curve = tension_curve(&beats, &chapters, &[], 0.25);
assert!(!curve.has_actual, "no spans → no actual curve");
assert_eq!(curve.points[0].actual, None);
assert!(curve.points[0].expected > 0.6, "expected still resolves from the table");
assert!(curve.warnings.is_empty(), "no flat flags without data");
}
}