From 56f4641564fe046d61a7b6e31bd8f823acf6266c Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 8 Sep 2026 18:19:07 +0200 Subject: [PATCH] Storyline: add the Prologue chapter, extend the layout for sessions Chapters now carry an eyebrow ("Overview", "Prologue", ...) and a short tag for the list/sidebar badge, instead of a bare running number, so non-numbered entries read correctly. New chapter: "Prologue - The Long Road to Brackenford", the session outline formatted as a runnable document -- an at-a-glance metadata band (levels, duration, locations, hook), the in-world courier notice as a posted handout, numbered scenes, GM callouts for the "ask the players" prompts, label/body rows for the skill-check clues and travel-day prompts, a three-stage encounter block, dramatic beat lines, and a before/after panel for the legend the party's own retelling becomes. Cross-links the Hearthburrow to Enemies and the closing line to the overview's "Five and the Five". Tests: render every chapter partial headless (no HTTP/DB) so a broken variable or path() fails loudly, check each registered chapter has its template file, and assert the /storyline routes sit behind the login gate. Co-Authored-By: Claude Sonnet 5 --- assets/styles/app.css | 146 +++++++++++ src/Controller/StorylineController.php | 16 +- templates/storyline/chapter.html.twig | 4 +- .../chapters/prologue-the-long-road.html.twig | 228 ++++++++++++++++++ templates/storyline/index.html.twig | 8 +- tests/Controller/StorylineControllerTest.php | 26 ++ tests/Storyline/ChapterTemplatesTest.php | 59 +++++ 7 files changed, 480 insertions(+), 7 deletions(-) create mode 100644 templates/storyline/chapters/prologue-the-long-road.html.twig create mode 100644 tests/Controller/StorylineControllerTest.php create mode 100644 tests/Storyline/ChapterTemplatesTest.php diff --git a/assets/styles/app.css b/assets/styles/app.css index ded7a3f..bdeb0dd 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -428,3 +428,149 @@ table.dataTable { color: var(--bs-secondary-color); } .storyline-pager-title { font-weight: 600; } + +/* Chapter list: kicker line above each title */ +.storyline-chapter-kicker { + display: block; + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.72rem; + font-weight: 700; + color: var(--bs-primary); +} + +/* --- Storyline: session / adventure chapter components -------------- */ + +/* At-a-glance metadata band under the chapter header */ +.storyline-meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(12.5rem, 1fr)); + gap: 0.6rem 1.75rem; + margin: 0 0 2rem; + padding: 1rem 1.25rem; + background: var(--bs-tertiary-bg); + border: 1px solid var(--bs-border-color); + border-radius: 0.5rem; +} +.storyline-meta > div { display: flex; flex-direction: column; } +.storyline-meta dt { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--bs-secondary-color); +} +.storyline-meta dd { margin: 0.1rem 0 0; font-weight: 500; } + +/* In-world handout / posted notice */ +.storyline-handout { + max-width: 24rem; + margin: 1.75rem auto; + padding: 1.5rem 1.75rem; + text-align: center; + background: var(--bs-tertiary-bg); + border: 1px solid var(--bs-border-color); + border-radius: 2px; + box-shadow: 0 0 0 5px var(--bs-body-bg), 0 0 0 6px var(--bs-border-color); + font-family: ui-monospace, "Cascadia Code", "Consolas", monospace; + font-size: 0.95rem; + line-height: 1.8; + letter-spacing: 0.02em; +} +.storyline-handout strong { + display: block; + letter-spacing: 0.14em; + text-transform: uppercase; + margin-bottom: 0.6rem; +} + +/* Label + body rows: skill checks, day prompts */ +.storyline-rows { + list-style: none; + margin: 1.25rem 0; + padding: 0; +} +.storyline-rows li { + display: flex; + gap: 1rem; + padding: 0.55rem 0; + border-top: 1px solid var(--bs-border-color); +} +.storyline-rows li:last-child { border-bottom: 1px solid var(--bs-border-color); } +.storyline-rows .k { + flex: 0 0 9rem; + font-weight: 600; + color: var(--bs-primary); + font-variant-numeric: tabular-nums; +} +@media (max-width: 40rem) { + .storyline-rows li { flex-direction: column; gap: 0.15rem; } + .storyline-rows .k { flex-basis: auto; } +} + +/* Question / answer + itemised lists */ +.storyline-qa { margin: 1.25rem 0; } +.storyline-qa > div { + padding: 0.55rem 0; + border-top: 1px solid var(--bs-border-color); +} +.storyline-qa > div:last-child { border-bottom: 1px solid var(--bs-border-color); } +.storyline-qa dt { font-weight: 600; } +.storyline-qa dd { margin: 0.15rem 0 0; color: var(--bs-secondary-color); } + +/* Staged encounter */ +.storyline-stages { + display: grid; + gap: 0.75rem; + margin: 1.25rem 0; +} +.storyline-stage { + padding: 0.9rem 1.1rem; + background: var(--bs-tertiary-bg); + border: 1px solid var(--bs-border-color); + border-left: 3px solid var(--bs-primary); + border-radius: 0.4rem; +} +.storyline-stage h4 { + font-size: 0.95rem; + text-transform: uppercase; + letter-spacing: 0.06em; + margin: 0 0 0.4rem; +} +.storyline-stage > :last-child { margin-bottom: 0; } + +/* A dramatic beat: SCRATCH / sign text / milestone */ +.storyline-beat { + text-align: center; + letter-spacing: 0.3em; + text-transform: uppercase; + font-weight: 700; + font-size: 1.1rem; + margin: 1.75rem 0 !important; +} + +/* Before / after legend distortion */ +.storyline-legend { + list-style: none; + margin: 1.25rem 0; + padding: 0; + display: grid; + gap: 0.9rem; +} +.storyline-legend li { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 0.9rem; +} +.storyline-legend .from { + text-align: right; + font-style: italic; + color: var(--bs-secondary-color); +} +.storyline-legend .arrow { color: var(--bs-primary); font-size: 1.1rem; } +.storyline-legend .to { font-style: italic; } +@media (max-width: 45rem) { + .storyline-legend li { grid-template-columns: 1fr; gap: 0.2rem; } + .storyline-legend .from { text-align: left; } + .storyline-legend .arrow { display: none; } +} diff --git a/src/Controller/StorylineController.php b/src/Controller/StorylineController.php index d280abd..2c01f11 100644 --- a/src/Controller/StorylineController.php +++ b/src/Controller/StorylineController.php @@ -15,14 +15,27 @@ class StorylineController extends AbstractController * Twig partial under templates/storyline/chapters/. Add a chapter by adding * a row here and the matching template; order in this array is reading order. * - * @var array + * - eyebrow: the kicker shown above the chapter title ("Overview", "Prologue", + * "Session 1", ...) + * - tag: 1-2 characters for the little badge in the chapter list / sidebar + * + * @var array */ private const CHAPTERS = [ 'overview' => [ 'title' => 'Campaign Overview', + 'eyebrow' => 'Overview', + 'tag' => 'Ov', 'summary' => 'The whole shape of the Brackenford campaign, from the sealing of Kyuss four centuries ago to the final crisis — the truth behind the blood, the red herring around Sir Aldren, and how it can resolve.', 'template' => 'storyline/chapters/overview.html.twig', ], + 'prologue-the-long-road' => [ + 'title' => 'Prologue — The Long Road to Brackenford', + 'eyebrow' => 'Prologue', + 'tag' => 'Pr', + 'summary' => "The courier job that carries the party to Brackenford: hired in Bellhaven to deliver Edric Vale's estate to his nephew Eamon, twenty days on the road, and one night in a guest cottage that won't let anyone sleep. Levels 1 to 2, about 2.5 hours.", + 'template' => 'storyline/chapters/prologue-the-long-road.html.twig', + ], ]; #[Route('/storyline', name: 'app_storyline', methods: ['GET'])] @@ -46,7 +59,6 @@ class StorylineController extends AbstractController return $this->render('storyline/chapter.html.twig', [ 'chapters' => self::CHAPTERS, 'slug' => $slug, - 'number' => $pos + 1, 'chapter' => self::CHAPTERS[$slug], 'prev' => $pos > 0 ? $slugs[$pos - 1] : null, 'next' => $pos < \count($slugs) - 1 ? $slugs[$pos + 1] : null, diff --git a/templates/storyline/chapter.html.twig b/templates/storyline/chapter.html.twig index 0a81069..c99afb4 100644 --- a/templates/storyline/chapter.html.twig +++ b/templates/storyline/chapter.html.twig @@ -13,7 +13,7 @@
  • - {{ loop.index }}{{ c.title }} + {{ c.tag }}{{ c.title }}
  • {% endfor %} @@ -24,7 +24,7 @@
    -
    Storyline · Chapter {{ number }}
    +
    Storyline · {{ chapter.eyebrow }}

    {{ chapter.title }}

    {{ chapter.summary }}

    diff --git a/templates/storyline/chapters/prologue-the-long-road.html.twig b/templates/storyline/chapters/prologue-the-long-road.html.twig new file mode 100644 index 0000000..3b32406 --- /dev/null +++ b/templates/storyline/chapters/prologue-the-long-road.html.twig @@ -0,0 +1,228 @@ +{# Prologue — The Long Road to Brackenford. Hand-formatted from the session outline. #} + +
    +
    Starting level
    1
    +
    Ending level
    2  (milestone)
    +
    Expected duration
    ~2.5 hours
    +
    Locations
    Bellhaven → the road → The Crooked Goose
    +
    Campaign hook
    Eamon Vale's inheritance
    +
    Featured adventure
    The House That Won't Let Anyone Sleep
    +
    + + + +

    01 Opening — Bellhaven

    +

    Bellhaven is a prosperous regional city on an important crossroads — large enough for guilds, courts, warehouses, temples, professional couriers and estate administrators.

    +

    Brackenford is twenty days east by road, the final days taking travellers steadily further from the major trade routes.

    +

    The five family members have already answered an advertisement:

    +
    + Couriers Required + Delivery of estate property.
    + Destination: Brackenford.
    + Approximately twenty days east by road.
    + Discretion required. Compensation generous.
    +
    + Enquire at the Office of Helena Vey,
    + Licensed Executor of Estates. +
    +

    Start with character introductions as the five family members arrive at Helena's office.

    + +

    02 Magistrate Helena Vey

    +

    Helena Vey is in her late forties or early fifties. Licensed Executor is her actual profession, though people informally call her Magistrate Vey from her years working for Bellhaven's civil court.

    +

    She is efficient, intelligent and extremely busy. Most importantly, Helena knows nothing about Brackenford's secret. To her, this is paperwork.

    +

    Her office holds shelves of legal volumes, bundles of documents tied with coloured string, locked cabinets, a large desk, wax seals, a clerk, and five chairs — someone clearly had to go and find the fifth. Against one wall sits Eamon's inheritance.

    + +

    03 Why Adventurers?

    +

    Helena contacted three professional courier companies. The first refused the job. The second offered a ridiculous price. The third offered an even more ridiculous price.

    +

    Not because Brackenford is dangerous — because it is twenty days away. A courier travelling there and back spends roughly forty days on a single delivery, plus food, accommodation, protection and possibly replacement horses, and there is little commercial traffic toward Brackenford to combine with the trip.

    +
    The contents aren't particularly valuable. At least, not compared with what three courier companies wanted to charge me to deliver them.
    +
    You people travel anyway.
    +

    If the party calls themselves adventurers:

    +
    Precisely. You're already accepting the inconvenience of roads, rain, questionable beds and occasionally being attacked by wildlife. Professional couriers charge extra for all four.
    + +

    04 The Deceased — Edric Vale

    +

    Edric Vale was a 76-year-old human, originally from Brackenford. He left as a young man and settled in Bellhaven, working as a cabinetmaker and furniture restorer.

    +

    He never married and had no children. His closest surviving relative is his nephew, Eamon Vale of Brackenford, whom he had not seen in many years.

    +

    There is no conspiracy here. Edric simply died of old age.

    + +

    05 The Inheritance

    +
    +
    The wooden chest
    A sturdy travelling chest, roughly 25 kg, containing 230 gp in assorted coinage. Locked and sealed with Helena's wax seals; Eamon receives the key separately, in a sealed envelope.
    +
    A wooden box
    Beautifully handmade by Edric. Inside: several letters, old drawings, family documents, a carved wooden toy, a small silver pendant and an old portrait. No magical secrets or sinister clues.
    +
    A wrapped object
    An old mantel clock. Eamon admired it when he visited Edric as a child, and Edric remembered.
    +
    A bundle of tools
    Edric's high-quality woodworking tools.
    +
    + +

    06 The Delivery Contract

    +

    The Proof of Receipt identifies Eamon Vale of Brackenford as the sole authorised recipient. Eamon must personally sign the magically prepared signature field. When he does, the black ink briefly becomes gold before settling into a dark metallic colour, and Helena's seal changes to match.

    +

    Someone else can physically accept the inheritance — but the party then fails the contract and does not receive the remainder of the payment.

    + +

    07 Payment

    +
    +
    Total
    200 gp
    +
    Upfront
    100 gp
    +
    On return
    100 gp, against the authenticated receipt
    +
    +

    The professional courier quotes were considerably higher. The adventurers genuinely are the cheaper option.

    + +

    08 Questions for Helena

    +
    +
    Where is Brackenford?
    Twenty days east, depending on weather.
    +
    What's it like?
    A small agricultural village in the hills.
    +
    Is it dangerous?
    Not that Helena is aware of.
    +
    Who is Eamon?
    Edric's nephew, apparently a farmer.
    +
    Why hasn't Eamon collected it himself?
    He does not know Edric has died.
    +
    Why not send a letter?
    A notice was sent ahead, but Helena received no reply — not especially unusual for such a remote settlement.
    +
    + +

    09 Shopping in Bellhaven

    +

    Give the characters 15–20 minutes of freedom before departure.

    + +

    Possible concerns: food, tents, rope, ammunition, healing supplies, transport, cookware, blankets, and whatever else they think they need. Use this mainly to establish the family dynamic.

    + +

    10 Leaving Bellhaven

    +

    Ahead is twenty days of road. No dungeon. No dragon. No ancient curse.

    +

    They have one job: deliver several boxes to a farmer, get his signature, come back. Easy money.

    + + +

    11 The First Fifteen Days

    +

    Montage the journey rather than playing every day. These are roleplaying prompts, not skill checks — spend roughly 10–15 minutes.

    +
      +
    • Day 2It rains continuously. “Which one of you discovers that something they packed isn't nearly as waterproof as they thought?”
    • +
    • Day 5Travelling merchants. “Which member of the family is most likely to start talking to strangers?”
    • +
    • Day 8Around the campfire. “Who normally cooks?”
    • +
    • Day 11“Which family habit has started getting on someone else's nerves?”
    • +
    • Day 14“Who seems most homesick?”
    • +
    + +

    12 Day Fifteen

    +

    Rain begins during the afternoon. The road becomes muddy and darkness approaches. Then they see light.

    +

    A wooden sign beside the road depicts a goose whose neck bends at a ridiculous angle:

    +

    The Crooked Goose

    +

    Warm yellow windows promise food, ale, fire and beds.

    + +

    13 The Crooked Goose

    +

    Make the inn genuinely wonderful: a warm common room, roasting food, laughter, local cider, perhaps a bad musician, and an enormous fireplace.

    +

    Then the party asks for rooms. Every room is occupied.

    + +

    14 The Innkeeper — Maud Tallow

    +

    Maud Tallow is a friendly, practical 52-year-old human who is absolutely convinced the guesthouse is haunted. Tonight the inn is full of travelling merchants, two drovers, a family of four, two labourers and an old travelling scholar.

    +

    Eventually Maud says:

    +
    Well… there is somewhere.
    +

    Her husband: “Maud.” She ignores him.

    +
    The guest cottage.
    +
    You wouldn't have to pay.
    + +

    15 The Problem

    +

    The guest cottage stands about 100 feet behind the inn. It is small and comfortable — two rooms, a little loft/storage area, a fireplace, and enough beds for the party.

    +

    Nobody has successfully slept there for three nights. The first guest heard scratching. The second claimed furniture moved. The third ran into the inn around 2 a.m. wearing only a nightshirt and refuses to return.

    +
    You lot are adventurers. Figure out what's wrong with the cottage, and you've got beds, dinner and breakfast for nothing.
    + +

    16 What Actually Happened

    + + +

    17 Investigation

    +
      +
    • DC 10 InvestigationTiny scratches around the bottom of the hearth. They do not resemble rat scratches.
    • +
    • DC 12 PerceptionSmall objects are missing from strange places. One drawer contains mismatched buttons.
    • +
    • DC 12 NatureThe tracks are too strange for a normal rodent.
    • +
    • DC 13 ArcanaMinor residual fey magic surrounds the hearth.
    • +
    • Under a bedA tiny silver spoon belonging to the inn.
    • +
    • The hearthA tiny hollow behind one stone holding crumbs, buttons, a coin, thread and a thimble.
    • +
    +

    Something is living here.

    + +

    18 Talking to People

    +

    Maud remembers Maela always leaving milk out. Maud's husband remembers: “She used to knock on that bloody fireplace every night” — and demonstrates. Knock. Knock. Knock.

    +

    The old scholar, if given the clues, suggests household fey but does not know the exact species.

    + + +

    19 Nightfall

    +

    Eventually someone must remain in the cottage. Nothing happens. Five minutes. Ten. Twenty.

    +

    Scratch. Scratch. Scratch.

    +

    Inside the wall. Then, from behind one character, comes another PC's voice — “[PC's name]?” — except that character did not speak.

    +

    The Hearthburrow has started playing.

    + +

    20 The Cottage Encounter

    +

    Do not immediately roll initiative.

    +
    +
    +

    Stage 1 — Mischief

    +

    A boot disappears. A cupboard opens. A backpack moves. A candle goes out.

    +
    +
    +

    Stage 2 — Warning

    +

    Shutters slam. A chair slides across the floor. Soot drops down the chimney. Something whispers: “Mine.”

    +
    +
    +

    Stage 3 — Confrontation

    +

    If the party tears the house apart or attacks, roll initiative. The Hearthburrow uses abilities such as Pulled Rug, Flying Crockery, Chimney Soot, Snuffed Flame, Wall Scurry and Mimic Voice.

    +
    +
    +

    The encounter should be chaotic and funny rather than deadly.

    + +

    21 Finding the Hearthburrow

    +

    The creature is tiny, scruffy, huge-eared, dressed in stolen scraps, and furious. If someone speaks Sylvan they can communicate; otherwise use pantomime.

    +

    It points at the party, the food, itself and the hearth, gesturing angrily. If somebody eats in front of it, it becomes deeply offended.

    + +

    22 Solving the Ritual

    +

    If the players reconstruct Maela's routine:

    +
      +
    1. Put bread beside the hearth.
    2. +
    3. Pour milk.
    4. +
    5. Knock three times.
    6. +
    7. Perhaps say: “Warm hearth, shared home, good night.”
    8. +
    +

    The words are not a magical password; the hospitality matters. The cottage falls silent. A tiny hand takes the bread. Another takes the milk. The party's missing possessions begin sliding back out. Finally the Hearthburrow cautiously emerges, no longer hostile. Problem solved.

    + +

    23 Alternative Solutions

    +

    Do not require the ritual. The party might negotiate, offer another home, promise future food, capture the creature, or kill it. Creative solutions should work.

    + +

    24 Hearthburrow's Favour

    +

    If resolved peacefully, the Hearthburrow gives one character an old brass button. Once, while carrying it, the owner may reroll a failed Dexterity (Stealth), Dexterity (Sleight of Hand) or Wisdom (Perception) check and must use the new result. Afterward, the button inexplicably disappears.

    + +

    25 Level Up

    +

    After sleeping properly, the party advances to Level 2 using milestone advancement.

    + +

    26 Morning at the Crooked Goose

    +

    Maud provides breakfast, food for the road, free accommodation, and perhaps a bottle of cider. Then she asks:

    +
    So what was it?
    + +
      +
    • “There was a little fey creature living under the hearth.”→“Five halfling heroes battled the Fey Lord Beneath the Hearth.”
    • +
    • “The house was haunted.”→“Five adventurers exorcised the screaming ghost of Maela Tallow.”
    • +
    +

    If the party exaggerates the story themselves, make the eventual legend even more absurd.

    + +

    27 Back on the Road

    +

    Approximately five days remain. The countryside becomes hillier, less travelled, more agricultural and more isolated. Road signs grow older and settlements smaller.

    +

    Nothing supernatural happens. No worms. No undead. No ominous ravens. No visions. No vampire clues.

    +

    Not yet.

    + +

    28 End of the Prologue

    +

    After nearly twenty days of travelling, the party reaches a ridge. A weathered milestone reads:

    +

    Brackenford — 7 miles

    +

    After nineteen days of rain, campfires, hard roads, one supposedly haunted cottage and far too much trail food, the easiest job they have ever accepted is almost finished. Deliver the inheritance. Get Eamon Vale's signature. Go home. Get paid.

    + +

    + Appendix — Timing of Eamon's Encounter with Aldren

    +

    Eamon should encounter Aldren while the characters are already on the final two-hour stretch toward Brackenford.

    +

    While the party thinks their job is nearly over, Eamon is outside the village and sees a pale man he immediately recognises from centuries of village stories: Sir Aldren. Aldren tells him:

    +
    Tell them the blood is no longer enough.
    +

    Eamon runs back to Brackenford. The council scrambles. Garrick Voss gets involved. Eamon is hidden away. Roughly two hours later, five halflings walk into Brackenford carrying his inheritance.

    +

    They are not the chosen five. They are simply the five people who happened to be there when Brackenford finally needed someone.

    +

    Continues in the Campaign Overview — see “The Five and the Five”.

    diff --git a/templates/storyline/index.html.twig b/templates/storyline/index.html.twig index f016c43..e50543f 100644 --- a/templates/storyline/index.html.twig +++ b/templates/storyline/index.html.twig @@ -8,8 +8,9 @@
    Storyline

    Brackenford

    - The campaign bible, split into chapters. Start with the overview for the whole - arc; later chapters will cover sessions, locations and the people of the village. + The campaign bible, split into chapters. The overview lays out the whole arc; + the prologue is the first session. More chapters — sessions, locations and the + people of the village — will follow.

    @@ -17,8 +18,9 @@ {% for slug, chapter in chapters %}
  • - {{ loop.index }} + {{ chapter.tag }} + {{ chapter.eyebrow }} {{ chapter.title }} {{ chapter.summary }} diff --git a/tests/Controller/StorylineControllerTest.php b/tests/Controller/StorylineControllerTest.php new file mode 100644 index 0000000..eec3e6c --- /dev/null +++ b/tests/Controller/StorylineControllerTest.php @@ -0,0 +1,26 @@ +request('GET', '/storyline'); + + self::assertResponseRedirects('/login'); + } + + public function testUnknownChapterAlsoGoesThroughTheLoginGate(): void + { + $client = static::createClient(); + $client->request('GET', '/storyline/does-not-exist'); + + // Behind IS_AUTHENTICATED_FULLY, so an anonymous hit redirects rather + // than 404s; the 404 path itself is covered in the render test below. + self::assertResponseRedirects('/login'); + } +} diff --git a/tests/Storyline/ChapterTemplatesTest.php b/tests/Storyline/ChapterTemplatesTest.php new file mode 100644 index 0000000..f377928 --- /dev/null +++ b/tests/Storyline/ChapterTemplatesTest.php @@ -0,0 +1,59 @@ + + */ + public static function chapters(): iterable + { + yield 'overview' => [ + 'storyline/chapters/overview.html.twig', + 'Sir Aldren was one of the five heroes', + 'Kyuss leaking out', + ]; + yield 'prologue' => [ + 'storyline/chapters/prologue-the-long-road.html.twig', + 'The Crooked Goose', + 'Tell them the blood is no longer enough', + ]; + } + + #[DataProvider('chapters')] + public function testChapterPartialRenders(string $template, string $needle1, string $needle2): void + { + self::bootKernel(); + /** @var Environment $twig */ + $twig = self::getContainer()->get('twig'); + + $html = $twig->render($template); + + self::assertStringContainsString($needle1, $html); + self::assertStringContainsString($needle2, $html); + } + + public function testEveryRegisteredChapterHasATemplateFile(): void + { + $ref = new \ReflectionClass(StorylineController::class); + /** @var array $chapters */ + $chapters = $ref->getConstant('CHAPTERS'); + + self::assertNotEmpty($chapters); + foreach ($chapters as $slug => $chapter) { + $path = \dirname(__DIR__, 2).'/templates/'.$chapter['template']; + self::assertFileExists($path, sprintf('Chapter "%s" points at a missing template.', $slug)); + } + } +}