feat(world): Haunted Manor content expansion (14 rooms, one world)

Replace the 5-room demo with a condensed 14-room manor on one spine
(restore power -> escape) that stacks all five mechanics, plus an optional
spirit thread feeding 3 prioritized endings (no hard-fail). Pure YAML on the
existing engines: 14 rooms across 3 levels, ~16 items, 5 combinations, the
old_man hint NPC, restore_power + lay_to_rest quests, and 3 endings.

Consolidate to one world: remove the toy "Test Manor" test fixture so the
loader tests exercise the real shipped world. Adapt WorldLoaderTest and
TutorialLoaderTest to form-invariants of the real world, and add
WorldSolvableTest, which walks the critical path (light/lock gating +
Combinations.tryUse + flags) to guarantee power_on, ghost_banished, and a
victory ending are reachable — an unsolvable world now fails the build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 09:06:28 +02:00
parent 88cad77c83
commit d8e1a662bc
16 changed files with 549 additions and 217 deletions

View File

@@ -1,3 +1,7 @@
# Five recipes drive the whole manor. Operands are matched order-independently
# and may come from the inventory or the current room (so the cellar/chapel
# fixtures combine in place). Each sets the flag that unlocks the next step.
- a: matches
b: torch
consume: [matches, torch]
@@ -5,3 +9,42 @@
response: |
You strike a match and touch it to the torch; it catches with a low hiss
and burns steady. You now hold a lit torch.
- a: valve_wheel
b: valve
produce: fuse
effects:
- { setFlag: cellar_drained }
response: |
You fit the wheel and crank it hard. The valve groans, the black water
drops away — and a ceramic fuse, washed loose, settles at your feet.
- a: fuse
b: generator
consume: [fuse]
effects:
- { setFlag: power_on }
response: |
The fuse seats with a snap. The generator coughs, catches, and roars —
and somewhere above you the manor's lights stutter awake.
- a: locket
b: shrine
consume: [locket]
effects:
- { setFlag: locket_placed }
response: |
You lay the silver locket in the shrine's niche. The air goes utterly
still, as though the house were holding its breath.
- a: photograph
b: shrine
requires: [{ flag: locket_placed }]
consume: [photograph]
effects:
- { setFlag: ghost_banished }
response: |
You set the photograph beside the locket. A long sigh moves through the
chapel, and the cold presence loosens — the spirit finds its peace.
failText: |
The shrine feels unfinished — something else belongs here first.

View File

@@ -1,14 +1,29 @@
- id: victory
# Order is priority: the first ending whose conditions all hold wins. The most
# specific (best) ending is listed first. ghost_banished implies power_on
# implies the upper floor was reached, so #1 needs only left_manor + ghost_banished.
- id: spirit_at_rest
title: "Spirit at Rest"
victory: true
when: [{ flag: left_manor }, { flag: ghost_banished }]
text: |
You step out into the night. Behind you the manor is finally, utterly
still — no cold, no whispers, no weight. You set something free in there,
and it set you free in turn. The best way out there is.
- id: manor_reclaimed
title: "The Manor Reclaimed"
victory: true
when: [{ flag: manor_secured }]
when: [{ flag: left_manor }, { flag: power_on }]
text: |
The lights hold steady and the whispers fade to nothing. Whatever held
this place has loosened its grip. The manor is yours now.
Light burns in every window and the house's grip is broken. Whatever held
this place has loosened — you walk out with your head high, the manor
reclaimed behind you.
- id: fled
title: "Into the Night"
victory: false
when: [{ flag: fled }]
when: [{ flag: left_manor }]
text: |
You wrench the front door open and bolt into the dark. Safe — but the
manor keeps its secrets, and they will keep you awake for years.

View File

@@ -1,7 +1,9 @@
title: Haunted Manor
version: "1.0"
startRoom: kitchen
startRoom: foyer
startGold: 0
welcomeMessage: |
Welcome to the Haunted Manor.
The gate clangs shut behind you. The Haunted Manor looms — dark, cold,
and waiting. Restore its power, lay its restless spirit to rest, and find
your way back out into the night.
Type 'help' to see available commands.

View File

@@ -1,57 +1,18 @@
- type: readable
id: letter
name: Letter
description: A crumpled piece of paper.
readText: |
"Meet me at midnight in the cellar. - A."
# Haunted Manor items. Two items — lit_torch and fuse — are intentionally left
# unplaced: they exist only as the products of combinations. valve, generator
# and shrine are scenery "fixtures": technically takeable, but written as fixed
# in place; combinations resolve them from the room just as well as the bag.
# ── Light sources ───────────────────────────────────────────────────────────
- type: switchable
id: lamp
name: Oil Lamp
description: An old oil lamp, heavy with fuel.
description: An old oil lamp, heavy with fuel. Lighting it would push back the dark.
initialState: false
light: true
onText: The lamp flares to life, casting a warm glow.
offText: You snuff out the lamp.
- type: plain
id: shovel
name: Shovel
description: A rusty shovel. Sturdy enough to dig.
- type: plain
id: key
name: Brass Key
description: A small brass key, polished from use.
- type: switchable
id: generator
name: Generator
description: A rusty generator with a heavy lever.
initialState: false
onText: |
You heave the lever. Somewhere deep in the manor, the lights flicker on.
offText: |
You pull the lever back; the manor falls dark again.
effects:
- { setFlag: power_on }
- type: switchable
id: front_door
name: Front Door
description: The heavy front door. It would let you leave the manor for good.
initialState: false
onText: |
You haul the front door open and step out into the cold night.
offText: |
You ease the door shut again.
effects:
- { setFlag: fled }
- type: plain
id: matches
name: Box of Matches
description: A small box of dry matches. They rattle when you shake it.
onText: The lamp flares to life, casting a warm, steady glow.
offText: You snuff out the lamp and the dark rushes back in.
- type: plain
id: torch
@@ -63,3 +24,101 @@
name: Lit Torch
description: A burning torch, throwing back the dark.
light: true
- type: plain
id: matches
name: Box of Matches
description: A small box of dry matches. They rattle when you shake it.
# ── Power-restoration chain ──────────────────────────────────────────────────
- type: plain
id: valve_wheel
name: Valve Wheel
description: A heavy iron wheel, sized to fit a drainage valve.
- type: plain
id: valve
name: Drainage Valve
description: |
A rusted drainage valve set into the cellar wall. Its wheel is missing —
it is part of the house, not something you can carry off.
- type: plain
id: fuse
name: Ceramic Fuse
description: A fat ceramic fuse, the kind that feeds a generator's main bus.
- type: plain
id: generator
name: Generator
description: |
A rusty generator with an empty fuse socket. It is bolted to the floor —
you will have to work on it where it stands.
# ── Spirit / relic chain ─────────────────────────────────────────────────────
- type: plain
id: locket
name: Silver Locket
description: A tarnished silver locket. Something is engraved inside the clasp.
- type: plain
id: photograph
name: Faded Photograph
description: A faded photograph of two figures standing before the manor.
- type: plain
id: shrine
name: Stone Shrine
description: |
A small stone shrine with a shallow offering niche. It is mortared into
the chapel floor — a place to leave something, not to take it.
# ── Exit ─────────────────────────────────────────────────────────────────────
- type: switchable
id: front_door
name: Front Door
description: The heavy front door. It would let you leave the manor for good.
initialState: false
onText: |
You haul the front door open and step out into the cold night air.
offText: |
You ease the door shut again.
effects:
- { setFlag: left_manor }
# ── Lore & flavour ───────────────────────────────────────────────────────────
- type: readable
id: letter
name: Letter
description: A crumpled piece of paper, addressed to no one.
readText: |
"If you are reading this, the power has failed again. The fuse is in the
flooded cellar — drain it first. And whatever you hear upstairs at night:
it only wants to be remembered. — A."
- type: readable
id: journal
name: Caretaker's Journal
description: A leather journal in a careful, anxious hand.
readText: |
"Day 9. The generator died and the cellar flooded the same week. I keep
the valve wheel in the conservatory now. Day 14. The chapel shrine takes
the locket — but it wants the photograph too, in that order, or nothing
happens at all."
- type: readable
id: portrait
name: Family Portrait
description: A faded oil portrait above the dining-room hearth.
readText: |
A stern family poses before the manor. A small brass plate reads only:
"They never did leave."
- type: plain
id: shovel
name: Shovel
description: A rusty shovel. Sturdy enough to dig, though nothing here needs digging.

View File

@@ -1,17 +1,29 @@
- id: old_man
name: Old Man
description: A stooped old man with a grey beard.
name: Old Caretaker
description: A stooped old man with a grey beard, watching from among the shelves.
greeting: |
"Hello, traveller. If you bring me the oil lamp,
I will give you the key to the cellar."
reactions:
- onReceive: lamp
response: |
"Thank you! Here, take this key."
gives: key
consumes: lamp
"Another lost soul in this house. Get the power running and tend to what
waits upstairs — that is the only way out that's worth taking."
# First matching line wins; otherwise the greeting is used.
dialogue:
- when: [{ flag: power_on }]
- when: [{ flag: ghost_banished }]
text: |
"You got the power running! The whole house feels less...
watchful now."
"You laid the poor thing to rest. Bless you. Go now — the night is
clear and the house will hold its peace."
- when: [{ flag: power_on }, { notFlag: ghost_banished }]
text: |
"The lights are back — feel how the house has quieted? Climb the stair.
In the chapel there's a shrine: give it the locket, then the photograph,
in that order."
- when: [{ notFlag: cellar_drained }]
text: |
"Cold down there, isn't it? The cellar's flooded over the fuse. Find the
valve wheel — the conservatory, I think — and drain it."
reactions:
- onReceive: letter
response: |
"Ah... that letter. I wrote it, long ago, for whoever came after. So
someone finally read it. Keep going — you're closer than they ever got."
consumes: letter
effects:
- { setFlag: heard_tale }

View File

@@ -2,9 +2,17 @@
title: "Bring the Manor to Life"
autoStart: true
stages:
- objective: "Get the power running."
- objective: "Drain the flooded cellar."
completion: [{ flag: cellar_drained }]
- objective: "Get the manor's power running."
completion: [{ flag: power_on }]
- objective: "Earn the Old Man's brass key."
completion: [{ hasItem: key }]
onComplete:
- { setFlag: manor_secured }
- { startQuest: lay_to_rest }
- { say: "With the power back, the upper floor is reachable — and something stirs up there." }
- id: lay_to_rest
title: "Lay the Spirit to Rest"
autoStart: false
stages:
- objective: "Lay the restless spirit to rest in the chapel."
completion: [{ flag: ghost_banished }]

View File

@@ -1,64 +1,199 @@
# Haunted Manor — 14 rooms across three levels, wired over compass exits.
# Ground floor (manor-theme), cellar (cellar-drone, dark), upper floor
# (musicbox / lament). The upward stair is power-locked; everything else is open.
# ── Ground floor ──────────────────────────────────────────────────────────
- id: foyer
name: Entrance Foyer
description: |
A grand, decaying foyer. The heavy front door stands at your back; an
oil lamp waits on a side table. A wide archway opens north into the hall.
music: manor-theme.ogg
exits:
north: grand_hall
items: [front_door, lamp]
npcs: []
- id: grand_hall
name: Grand Hall
description: |
A cavernous hall, the hub of the house. Passages branch in every
direction: the foyer to the south, a dining room north, the kitchen
east, and the library west.
music: manor-theme.ogg
exits:
south: foyer
north: dining_room
east: kitchen
west: library
items: []
npcs: []
- id: kitchen
name: Old Kitchen
description: |
A dusty kitchen. Cobwebs hang from the rafters
and a letter lies on the table.
A dusty kitchen. Cobwebs hang from the rafters, a box of matches sits
by the stove, and a letter lies on the table. A narrow stair descends
south into darkness.
music: manor-theme.ogg
exits:
north: hallway
east: cellar
south: dungeon
items: [letter, lamp, front_door]
npcs: [old_man]
exitLocks:
- direction: east
requires: [{ flag: power_on }]
blocked: |
The cellar door is held shut by a magnetic lock — dead without power.
- id: hallway
name: Dark Hallway
description: |
A long, dimly lit hallway. It smells musty.
Faint footsteps echo somewhere far away.
exits:
south: kitchen
west: library
items: [torch]
west: grand_hall
south: cellar_stairs
items: [matches, letter]
npcs: []
- id: library
name: Library
description: |
Tall shelves crammed with mouldering books.
An old chest stands in the corner.
Tall shelves crammed with mouldering books. A rusty shovel leans in one
corner and a leather journal lies open on a reading stand. A stooped old
man keeps watch among the stacks.
music: manor-theme.ogg
exits:
east: hallway
items: [shovel, matches]
east: grand_hall
items: [shovel, journal]
npcs: [old_man]
- id: dining_room
name: Dining Room
description: |
A long banquet table under a sheet of dust. An unlit torch rests in a
wall bracket and a faded portrait hangs above the hearth. A conservatory
lies north; a grand staircase rises to the east.
music: manor-theme.ogg
exits:
south: grand_hall
north: conservatory
east: grand_staircase
items: [torch, portrait]
npcs: []
- id: cellar
name: Damp Cellar
- id: conservatory
name: Conservatory
description: |
Cold, damp, and very dark. You can barely
make out shapes against the far wall.
A glass-roofed room choked with dead ferns. Rusted plumbing lines the
walls, and a heavy iron valve wheel has been left on a potting bench.
music: manor-theme.ogg
exits:
west: kitchen
south: dining_room
items: [valve_wheel]
npcs: []
- id: grand_staircase
name: Grand Staircase
description: |
A sweeping staircase climbs into the gloom of the upper floor. The dining
room lies west. Without power, the lift gate at the top is sealed shut.
music: manor-theme.ogg
exits:
west: dining_room
north: landing
items: []
npcs: []
descriptionStates:
- when: [{ flag: power_on }]
text: |
With the power back, a bare bulb buzzes overhead, revealing
shelves of dusty jars along the far wall.
exitLocks:
- direction: north
requires: [{ flag: power_on }]
blocked: |
The lift gate at the head of the stairs is bolted by a dead electric
lock. Until the manor has power, the upper floor stays out of reach.
- id: dungeon
name: Dungeon
# ── Cellar (dark — needs a carried light source) ────────────────────────────
- id: cellar_stairs
name: Cellar Landing
description: |
A cramped stone room, black as pitch. A rusty generator squats in the corner.
music: dungeon-drone.ogg
The bottom of the kitchen stair. The air is wet and close. Passages lead
east toward the sound of standing water and west toward a cold furnace.
music: cellar-drone.ogg
dark: true
exits:
north: kitchen
east: flooded_cellar
west: boiler_room
items: []
npcs: []
- id: flooded_cellar
name: Flooded Cellar
description: |
You stand knee-deep in black, freezing water. A drainage valve juts from
the wall, its wheel long since removed.
music: cellar-drone.ogg
dark: true
exits:
west: cellar_stairs
items: [valve]
npcs: []
descriptionStates:
- when: [{ flag: cellar_drained }]
text: |
The drained basin has given up its muddy floor. The valve still
stands open against the wall, water trickling away below.
- id: boiler_room
name: Boiler Room
description: |
A cramped stone room behind the furnace. A rusty generator squats in the
corner, an empty fuse socket gaping in its housing.
music: cellar-drone.ogg
dark: true
exits:
east: cellar_stairs
items: [generator]
npcs: []
# ── Upper floor (reachable only once the power is on) ───────────────────────
- id: landing
name: Upper Landing
description: |
A railed landing above the hall, lit now by the restored lights. Doors
open north to the master bedroom, east to a study, and west to a small
chapel. The staircase falls away to the south.
music: musicbox.ogg
exits:
south: grand_staircase
north: master_bedroom
east: study
west: chapel
items: []
npcs: []
- id: master_bedroom
name: Master Bedroom
description: |
A grand four-poster bed, its drapes rotted to lace. On the nightstand,
catching the light, lies a silver locket.
music: musicbox.ogg
exits:
south: landing
items: [locket]
npcs: []
- id: study
name: Study
description: |
A cluttered writing study. Papers lie scattered across the desk, and a
framed photograph has fallen face-up among them.
music: musicbox.ogg
exits:
west: landing
items: [photograph]
npcs: []
- id: chapel
name: Manor Chapel
description: |
A tiny private chapel. A stone shrine stands at the far end beneath a
narrow window. A cold presence presses against you here.
music: lament.ogg
exits:
east: landing
items: [shrine]
npcs: []
descriptionStates:
- when: [{ flag: ghost_banished }]
text: |
A tiny private chapel. The shrine stands quiet beneath the window,
and only still moonlight remains. The cold presence is gone.