diff --git a/Aetheria/Fiction/index.md b/Aetheria/Fiction/index.md
index b58fac7..f44cd6d 100644
--- a/Aetheria/Fiction/index.md
+++ b/Aetheria/Fiction/index.md
@@ -5,4 +5,4 @@ Finished long-form fiction promoted from Aetheria's developmental story rooms.
## Novellas
- [[The Burden of Proof]] - A warm black comedy of pirate doctrine, repeated tactical failure, worker authority, cognition, and the difficult distinction between being needed and being chosen. [Download EPUB](/static/fiction/the-burden-of-proof.epub)
-- [[The Body That Asks]] - A political mecha academy thriller about five cadets, one damaged machine person, and an institution trying to manufacture the power of a family that can refuse itself and remain.
+- [[The Body That Asks]] - A political mecha academy thriller about five cadets, one damaged machine person, and an institution trying to manufacture the power of a family that can refuse itself and remain. [Download EPUB](/static/fiction/the-body-that-asks.epub)
diff --git a/Aetheria/static/fiction/the-body-that-asks.epub b/Aetheria/static/fiction/the-body-that-asks.epub
new file mode 100644
index 0000000..ba43d58
Binary files /dev/null and b/Aetheria/static/fiction/the-body-that-asks.epub differ
diff --git a/Aetheria/static/fiction/the-burden-of-proof.epub b/Aetheria/static/fiction/the-burden-of-proof.epub
index 89c5c06..0d097ba 100644
Binary files a/Aetheria/static/fiction/the-burden-of-proof.epub and b/Aetheria/static/fiction/the-burden-of-proof.epub differ
diff --git a/scripts/fiction/README.md b/scripts/fiction/README.md
new file mode 100644
index 0000000..c14d92e
--- /dev/null
+++ b/scripts/fiction/README.md
@@ -0,0 +1,18 @@
+# Fiction EPUB Builder
+
+`build_epub.py` is the single EPUB packaging and validation path for Aetheria fiction. It derives the title, movements, chapter order, navigation, and spine from the promoted manuscript while taking the output path and stable public identifier explicitly.
+
+It accepts both live manuscript heading schemes:
+
+- `# Title`, `## Movement`, `### Chapter`
+- `# Title`, `# Movement`, `## Chapter`
+
+Build the published books from the repository root:
+
+```powershell
+python scripts/fiction/build_epub.py --source "Aetheria/Fiction/The Burden of Proof.md" --output "Aetheria/static/fiction/the-burden-of-proof.epub" --identifier-url "https://aetheria.gamecult.org/Fiction/The-Burden-of-Proof" --expected-chapters 28 --expected-title "The Burden of Proof"
+
+python scripts/fiction/build_epub.py --source "Aetheria/Fiction/The Body That Asks.md" --output "Aetheria/static/fiction/the-body-that-asks.epub" --identifier-url "https://aetheria.gamecult.org/Fiction/The-Body-That-Asks" --expected-chapters 28 --expected-title "The Body That Asks"
+```
+
+Use the same arguments with `--check` to validate an existing artifact without rebuilding it. Set `SOURCE_DATE_EPOCH` when byte-for-byte reproducibility across filesystems is required.
diff --git a/scripts/fiction/build_the_burden_of_proof_epub.py b/scripts/fiction/build_epub.py
similarity index 51%
rename from scripts/fiction/build_the_burden_of_proof_epub.py
rename to scripts/fiction/build_epub.py
index cdcb163..a623712 100644
--- a/scripts/fiction/build_the_burden_of_proof_epub.py
+++ b/scripts/fiction/build_epub.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-"""Build and validate The Burden of Proof as a deterministic EPUB 3 archive."""
+"""Build and validate an Aetheria fiction manuscript as a deterministic EPUB 3 archive."""
from __future__ import annotations
@@ -15,14 +15,9 @@ from pathlib import Path
from xml.etree import ElementTree
-ROOT = Path(__file__).resolve().parents[2]
-DEFAULT_SOURCE = ROOT / "Aetheria" / "Fiction" / "The Burden of Proof.md"
-DEFAULT_OUTPUT = ROOT / "Aetheria" / "static" / "fiction" / "the-burden-of-proof.epub"
-BOOK_ID = f"urn:uuid:{uuid.uuid5(uuid.NAMESPACE_URL, 'https://aetheria.gamecult.org/Fiction/The-Burden-of-Proof')}"
-
-
@dataclass
class Chapter:
+ number: int
title: str
movement: str
blocks: list[str]
@@ -53,17 +48,42 @@ def parse_manuscript(source: Path) -> tuple[str, list[Chapter]]:
title = lines[0][2:].strip()
movement = ""
+ movement_level: int | None = None
+ heading_schema: tuple[int, int] | None = None
chapters: list[Chapter] = []
current: Chapter | None = None
for line in lines[1:]:
- if line.startswith("## "):
- movement = line[3:].strip()
- elif line.startswith("### "):
- current = Chapter(line[4:].strip(), movement, [])
+ heading = re.fullmatch(r"(#{1,3})\s+(.+?)\s*", line)
+ if heading and re.match(r"Movement\s+", heading.group(2), re.IGNORECASE):
+ level = len(heading.group(1))
+ if heading_schema is not None and level != heading_schema[0]:
+ raise ValueError("Movement heading level changes within the manuscript")
+ movement = heading.group(2)
+ movement_level = level
+ current = None
+ elif heading and (chapter_match := re.match(r"Chapter\s+(\d+)\b", heading.group(2), re.IGNORECASE)):
+ number = int(chapter_match.group(1))
+ expected = len(chapters) + 1
+ if number != expected:
+ raise ValueError(f"Expected Chapter {expected}, found Chapter {number}")
+ if not movement:
+ raise ValueError(f"Chapter {number} has no movement")
+ schema = (movement_level, len(heading.group(1)))
+ if schema not in {(1, 2), (2, 3)}:
+ raise ValueError("Use H1 movements with H2 chapters, or H2 movements with H3 chapters")
+ if heading_schema is None:
+ heading_schema = schema
+ elif schema != heading_schema:
+ raise ValueError("Movement and chapter heading levels must remain consistent")
+ current = Chapter(number, heading.group(2), movement, [])
chapters.append(current)
+ elif heading:
+ raise ValueError(f"Unsupported heading after title: {line}")
elif current is not None and line.strip():
current.blocks.append(line.strip())
+ elif line.strip():
+ raise ValueError(f"Prose appears before the first chapter: {line}")
if not chapters or any(not chapter.blocks for chapter in chapters):
raise ValueError("Every chapter must contain prose")
@@ -82,6 +102,9 @@ def xhtml_page(title: str, body: str, body_class: str = "") -> str:
def chapter_xhtml(chapter: Chapter) -> str:
paragraphs = []
for block in chapter.blocks:
+ if block in {"***", "* * *", "---", "_ _ _"}:
+ paragraphs.append('