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:
@@ -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
|
- a: matches
|
||||||
b: torch
|
b: torch
|
||||||
consume: [matches, torch]
|
consume: [matches, torch]
|
||||||
@@ -5,3 +9,42 @@
|
|||||||
response: |
|
response: |
|
||||||
You strike a match and touch it to the torch; it catches with a low hiss
|
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.
|
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.
|
||||||
|
|||||||
@@ -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"
|
title: "The Manor Reclaimed"
|
||||||
victory: true
|
victory: true
|
||||||
when: [{ flag: manor_secured }]
|
when: [{ flag: left_manor }, { flag: power_on }]
|
||||||
text: |
|
text: |
|
||||||
The lights hold steady and the whispers fade to nothing. Whatever held
|
Light burns in every window and the house's grip is broken. Whatever held
|
||||||
this place has loosened its grip. The manor is yours now.
|
this place has loosened — you walk out with your head high, the manor
|
||||||
|
reclaimed behind you.
|
||||||
|
|
||||||
- id: fled
|
- id: fled
|
||||||
title: "Into the Night"
|
title: "Into the Night"
|
||||||
victory: false
|
victory: false
|
||||||
when: [{ flag: fled }]
|
when: [{ flag: left_manor }]
|
||||||
text: |
|
text: |
|
||||||
You wrench the front door open and bolt into the dark. Safe — but the
|
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.
|
manor keeps its secrets, and they will keep you awake for years.
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
title: Haunted Manor
|
title: Haunted Manor
|
||||||
version: "1.0"
|
version: "1.0"
|
||||||
startRoom: kitchen
|
startRoom: foyer
|
||||||
startGold: 0
|
startGold: 0
|
||||||
welcomeMessage: |
|
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.
|
Type 'help' to see available commands.
|
||||||
|
|||||||
@@ -1,57 +1,18 @@
|
|||||||
- type: readable
|
# Haunted Manor items. Two items — lit_torch and fuse — are intentionally left
|
||||||
id: letter
|
# unplaced: they exist only as the products of combinations. valve, generator
|
||||||
name: Letter
|
# and shrine are scenery "fixtures": technically takeable, but written as fixed
|
||||||
description: A crumpled piece of paper.
|
# in place; combinations resolve them from the room just as well as the bag.
|
||||||
readText: |
|
|
||||||
"Meet me at midnight in the cellar. - A."
|
# ── Light sources ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
- type: switchable
|
- type: switchable
|
||||||
id: lamp
|
id: lamp
|
||||||
name: Oil 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
|
initialState: false
|
||||||
light: true
|
light: true
|
||||||
onText: The lamp flares to life, casting a warm glow.
|
onText: The lamp flares to life, casting a warm, steady glow.
|
||||||
offText: You snuff out the lamp.
|
offText: You snuff out the lamp and the dark rushes back in.
|
||||||
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
- type: plain
|
- type: plain
|
||||||
id: torch
|
id: torch
|
||||||
@@ -63,3 +24,101 @@
|
|||||||
name: Lit Torch
|
name: Lit Torch
|
||||||
description: A burning torch, throwing back the dark.
|
description: A burning torch, throwing back the dark.
|
||||||
light: true
|
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.
|
||||||
|
|||||||
@@ -1,17 +1,29 @@
|
|||||||
- id: old_man
|
- id: old_man
|
||||||
name: Old Man
|
name: Old Caretaker
|
||||||
description: A stooped old man with a grey beard.
|
description: A stooped old man with a grey beard, watching from among the shelves.
|
||||||
greeting: |
|
greeting: |
|
||||||
"Hello, traveller. If you bring me the oil lamp,
|
"Another lost soul in this house. Get the power running and tend to what
|
||||||
I will give you the key to the cellar."
|
waits upstairs — that is the only way out that's worth taking."
|
||||||
reactions:
|
# First matching line wins; otherwise the greeting is used.
|
||||||
- onReceive: lamp
|
|
||||||
response: |
|
|
||||||
"Thank you! Here, take this key."
|
|
||||||
gives: key
|
|
||||||
consumes: lamp
|
|
||||||
dialogue:
|
dialogue:
|
||||||
- when: [{ flag: power_on }]
|
- when: [{ flag: ghost_banished }]
|
||||||
text: |
|
text: |
|
||||||
"You got the power running! The whole house feels less...
|
"You laid the poor thing to rest. Bless you. Go now — the night is
|
||||||
watchful now."
|
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 }
|
||||||
|
|||||||
@@ -2,9 +2,17 @@
|
|||||||
title: "Bring the Manor to Life"
|
title: "Bring the Manor to Life"
|
||||||
autoStart: true
|
autoStart: true
|
||||||
stages:
|
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 }]
|
completion: [{ flag: power_on }]
|
||||||
- objective: "Earn the Old Man's brass key."
|
|
||||||
completion: [{ hasItem: key }]
|
|
||||||
onComplete:
|
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 }]
|
||||||
|
|||||||
@@ -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
|
- id: kitchen
|
||||||
name: Old Kitchen
|
name: Old Kitchen
|
||||||
description: |
|
description: |
|
||||||
A dusty kitchen. Cobwebs hang from the rafters
|
A dusty kitchen. Cobwebs hang from the rafters, a box of matches sits
|
||||||
and a letter lies on the table.
|
by the stove, and a letter lies on the table. A narrow stair descends
|
||||||
|
south into darkness.
|
||||||
music: manor-theme.ogg
|
music: manor-theme.ogg
|
||||||
exits:
|
exits:
|
||||||
north: hallway
|
west: grand_hall
|
||||||
east: cellar
|
south: cellar_stairs
|
||||||
south: dungeon
|
items: [matches, letter]
|
||||||
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]
|
|
||||||
npcs: []
|
npcs: []
|
||||||
|
|
||||||
- id: library
|
- id: library
|
||||||
name: Library
|
name: Library
|
||||||
description: |
|
description: |
|
||||||
Tall shelves crammed with mouldering books.
|
Tall shelves crammed with mouldering books. A rusty shovel leans in one
|
||||||
An old chest stands in the corner.
|
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:
|
exits:
|
||||||
east: hallway
|
east: grand_hall
|
||||||
items: [shovel, matches]
|
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: []
|
npcs: []
|
||||||
|
|
||||||
- id: cellar
|
- id: conservatory
|
||||||
name: Damp Cellar
|
name: Conservatory
|
||||||
description: |
|
description: |
|
||||||
Cold, damp, and very dark. You can barely
|
A glass-roofed room choked with dead ferns. Rusted plumbing lines the
|
||||||
make out shapes against the far wall.
|
walls, and a heavy iron valve wheel has been left on a potting bench.
|
||||||
|
music: manor-theme.ogg
|
||||||
exits:
|
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: []
|
items: []
|
||||||
npcs: []
|
npcs: []
|
||||||
descriptionStates:
|
exitLocks:
|
||||||
- when: [{ flag: power_on }]
|
- direction: north
|
||||||
text: |
|
requires: [{ flag: power_on }]
|
||||||
With the power back, a bare bulb buzzes overhead, revealing
|
blocked: |
|
||||||
shelves of dusty jars along the far wall.
|
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
|
# ── Cellar (dark — needs a carried light source) ────────────────────────────
|
||||||
name: Dungeon
|
|
||||||
|
- id: cellar_stairs
|
||||||
|
name: Cellar Landing
|
||||||
description: |
|
description: |
|
||||||
A cramped stone room, black as pitch. A rusty generator squats in the corner.
|
The bottom of the kitchen stair. The air is wet and close. Passages lead
|
||||||
music: dungeon-drone.ogg
|
east toward the sound of standing water and west toward a cold furnace.
|
||||||
|
music: cellar-drone.ogg
|
||||||
dark: true
|
dark: true
|
||||||
exits:
|
exits:
|
||||||
north: kitchen
|
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]
|
items: [generator]
|
||||||
npcs: []
|
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.
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|||||||
class TutorialLoaderTest {
|
class TutorialLoaderTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void loadsTheTestFixture() {
|
void loadsTheShippedTutorial() {
|
||||||
Tutorial t = new TutorialLoader().load(); // test-classpath /world/tutorial.yaml shadows prod
|
// The toy fixture is gone, so this resolves the real /world/tutorial.yaml.
|
||||||
|
Tutorial t = new TutorialLoader().load();
|
||||||
assertThat(t.isEmpty()).isFalse();
|
assertThat(t.isEmpty()).isFalse();
|
||||||
assertThat(t.intro()).isNotBlank();
|
assertThat(t.intro()).isNotBlank();
|
||||||
assertThat(t.closingTips()).isNotBlank();
|
assertThat(t.closingTips()).isNotBlank();
|
||||||
assertThat(t.steps()).hasSize(2);
|
assertThat(t.steps()).isNotEmpty();
|
||||||
assertThat(t.steps().getFirst().expect()).isEqualTo("look");
|
assertThat(t.steps().getFirst().expect()).isEqualTo("look");
|
||||||
assertThat(t.steps().getFirst().minArgs()).isZero();
|
assertThat(t.steps().getFirst().minArgs()).isZero();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,30 +6,41 @@ import thb.jeanluc.adventure.model.NpcReaction;
|
|||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the real, shipped Haunted Manor world (the toy test fixture has been
|
||||||
|
* removed, so {@code new WorldLoader().load()} resolves the production YAML).
|
||||||
|
* Assertions check form-invariants of the delivered world rather than fragile
|
||||||
|
* exact values, so adding flavour rooms/items won't break the suite — but a
|
||||||
|
* broken start room, missing core item, or unwired exit will.
|
||||||
|
*/
|
||||||
class WorldLoaderTest {
|
class WorldLoaderTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void load_happyPath_buildsWorldFromTestFixtures() {
|
void load_happyPath_buildsTheShippedManor() {
|
||||||
WorldLoader.LoadResult result = new WorldLoader().load();
|
WorldLoader.LoadResult result = new WorldLoader().load();
|
||||||
|
|
||||||
assertThat(result.world().getTitle()).isEqualTo("Test Manor");
|
assertThat(result.world().getTitle()).isEqualTo("Haunted Manor");
|
||||||
assertThat(result.world().getRooms().keySet()).containsExactlyInAnyOrder("kitchen", "hallway");
|
assertThat(result.world().getRooms()).hasSize(14);
|
||||||
|
assertThat(result.world().getRooms().keySet())
|
||||||
|
.contains("foyer", "grand_hall", "cellar_stairs", "boiler_room",
|
||||||
|
"grand_staircase", "landing", "chapel");
|
||||||
assertThat(result.world().getItems().keySet())
|
assertThat(result.world().getItems().keySet())
|
||||||
.containsExactlyInAnyOrder("letter", "lamp", "key", "matches", "torch", "lit_torch");
|
.contains("lamp", "valve_wheel", "valve", "fuse", "generator",
|
||||||
assertThat(result.world().getNpcs().keySet()).containsExactly("old_man");
|
"locket", "photograph", "shrine", "front_door", "lit_torch");
|
||||||
|
assertThat(result.world().getNpcs().keySet()).contains("old_man");
|
||||||
|
|
||||||
assertThat(result.player().getCurrentRoom().getId()).isEqualTo("kitchen");
|
assertThat(result.player().getCurrentRoom().getId()).isEqualTo("foyer");
|
||||||
assertThat(result.player().getGold()).isEqualTo(5);
|
assertThat(result.player().getGold()).isZero();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void load_resolvesExitsBidirectionally_whenYamlDeclaresThem() {
|
void load_resolvesExitsBidirectionally_whenYamlDeclaresThem() {
|
||||||
var loaded = new WorldLoader().load().world();
|
var loaded = new WorldLoader().load().world();
|
||||||
var kitchen = loaded.getRooms().get("kitchen");
|
var foyer = loaded.getRooms().get("foyer");
|
||||||
var hallway = loaded.getRooms().get("hallway");
|
var grandHall = loaded.getRooms().get("grand_hall");
|
||||||
|
|
||||||
assertThat(kitchen.getExit(Direction.NORTH)).contains(hallway);
|
assertThat(foyer.getExit(Direction.NORTH)).contains(grandHall);
|
||||||
assertThat(hallway.getExit(Direction.SOUTH)).contains(kitchen);
|
assertThat(grandHall.getExit(Direction.SOUTH)).contains(foyer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -37,9 +48,9 @@ class WorldLoaderTest {
|
|||||||
var loaded = new WorldLoader().load().world();
|
var loaded = new WorldLoader().load().world();
|
||||||
var oldMan = loaded.getNpcs().get("old_man");
|
var oldMan = loaded.getNpcs().get("old_man");
|
||||||
|
|
||||||
assertThat(oldMan.getReactions()).containsKey("lamp");
|
assertThat(oldMan.getReactions()).containsKey("letter");
|
||||||
NpcReaction r = oldMan.reactionFor("lamp").orElseThrow();
|
NpcReaction r = oldMan.reactionFor("letter").orElseThrow();
|
||||||
assertThat(r.getConsumes()).isEqualTo(loaded.getItems().get("lamp"));
|
assertThat(r.getConsumes()).isEqualTo(loaded.getItems().get("letter"));
|
||||||
assertThat(r.getGives()).isEqualTo(loaded.getItems().get("key"));
|
assertThat(r.getGives()).isNull();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package thb.jeanluc.adventure.loader;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.game.Combinations;
|
||||||
|
import thb.jeanluc.adventure.game.Conditions;
|
||||||
|
import thb.jeanluc.adventure.game.EndingEngine;
|
||||||
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
|
import thb.jeanluc.adventure.game.GameSession;
|
||||||
|
import thb.jeanluc.adventure.game.Light;
|
||||||
|
import thb.jeanluc.adventure.game.QuestEngine;
|
||||||
|
import thb.jeanluc.adventure.io.TestIO;
|
||||||
|
import thb.jeanluc.adventure.model.Direction;
|
||||||
|
import thb.jeanluc.adventure.model.Ending;
|
||||||
|
import thb.jeanluc.adventure.model.ExitLock;
|
||||||
|
import thb.jeanluc.adventure.model.Player;
|
||||||
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
import thb.jeanluc.adventure.model.World;
|
||||||
|
import thb.jeanluc.adventure.model.item.SwitchableItem;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automated solvability guarantee for the shipped Haunted Manor: walks the full
|
||||||
|
* critical path purely through the domain ({@link Combinations#tryUse}, flag
|
||||||
|
* checks, light/lock gating) — no IO loop, no command parsing. If a broken lock
|
||||||
|
* or flag makes the world unwinnable, this build fails instead of the player
|
||||||
|
* discovering it in-game.
|
||||||
|
*/
|
||||||
|
class WorldSolvableTest {
|
||||||
|
|
||||||
|
private GameContext freshGame() {
|
||||||
|
WorldLoader.LoadResult loaded = new WorldLoader().load();
|
||||||
|
GameContext ctx = new GameContext(
|
||||||
|
new GameSession(loaded.world(), loaded.player(), "test"), new TestIO());
|
||||||
|
ctx.setSaveCallback(() -> { }); // quest completion autosaves; no-op it here
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void criticalPath_isWinnable_andYieldsSpiritAtRest() {
|
||||||
|
GameContext ctx = freshGame();
|
||||||
|
World w = ctx.getWorld();
|
||||||
|
Player p = ctx.getPlayer();
|
||||||
|
|
||||||
|
// The cellar is dark: with no carried light, you cannot see in it.
|
||||||
|
Room cellarStairs = w.getRooms().get("cellar_stairs");
|
||||||
|
assertThat(Light.isLit(ctx, cellarStairs)).isFalse();
|
||||||
|
|
||||||
|
// Light the lamp (foyer) — now you carry a light source.
|
||||||
|
p.addItem(w.getItems().get("lamp"));
|
||||||
|
((SwitchableItem) p.findItem("lamp").orElseThrow()).use(ctx);
|
||||||
|
assertThat(Light.isLit(ctx, cellarStairs)).isTrue();
|
||||||
|
|
||||||
|
// The upward stair is power-locked until the generator runs.
|
||||||
|
Room staircase = w.getRooms().get("grand_staircase");
|
||||||
|
ExitLock stairLock = staircase.getExitLocks().get(Direction.NORTH);
|
||||||
|
assertThat(stairLock).isNotNull();
|
||||||
|
assertThat(Conditions.all(stairLock.requires(), ctx)).isFalse();
|
||||||
|
|
||||||
|
// Drain the cellar: valve_wheel (carried) + valve (fixture in the room).
|
||||||
|
p.addItem(w.getItems().get("valve_wheel"));
|
||||||
|
p.setCurrentRoom(w.getRooms().get("flooded_cellar"));
|
||||||
|
Combinations.tryUse(ctx, "valve_wheel", "valve");
|
||||||
|
assertThat(ctx.getState().isSet("cellar_drained")).isTrue();
|
||||||
|
assertThat(p.hasItem("fuse")).isTrue();
|
||||||
|
|
||||||
|
// Restore power: fuse (carried) + generator (fixture in the room).
|
||||||
|
p.setCurrentRoom(w.getRooms().get("boiler_room"));
|
||||||
|
Combinations.tryUse(ctx, "fuse", "generator");
|
||||||
|
assertThat(ctx.getState().isSet("power_on")).isTrue();
|
||||||
|
assertThat(p.hasItem("fuse")).isFalse();
|
||||||
|
|
||||||
|
// With power on, the stair lock opens.
|
||||||
|
assertThat(Conditions.all(stairLock.requires(), ctx)).isTrue();
|
||||||
|
|
||||||
|
// One quest tick: restore_power completes and starts lay_to_rest.
|
||||||
|
QuestEngine.tick(ctx);
|
||||||
|
assertThat(ctx.getQuestLog().isCompleted("restore_power")).isTrue();
|
||||||
|
assertThat(ctx.getQuestLog().isActive("lay_to_rest")
|
||||||
|
|| ctx.getQuestLog().isCompleted("lay_to_rest")).isTrue();
|
||||||
|
|
||||||
|
// Chapel: photograph BEFORE locket must fail (requires locket_placed).
|
||||||
|
p.addItem(w.getItems().get("locket"));
|
||||||
|
p.addItem(w.getItems().get("photograph"));
|
||||||
|
p.setCurrentRoom(w.getRooms().get("chapel"));
|
||||||
|
Combinations.tryUse(ctx, "photograph", "shrine");
|
||||||
|
assertThat(ctx.getState().isSet("ghost_banished")).isFalse();
|
||||||
|
assertThat(p.hasItem("photograph")).isTrue();
|
||||||
|
|
||||||
|
// Correct order: locket, then photograph -> spirit banished.
|
||||||
|
Combinations.tryUse(ctx, "locket", "shrine");
|
||||||
|
assertThat(ctx.getState().isSet("locket_placed")).isTrue();
|
||||||
|
Combinations.tryUse(ctx, "photograph", "shrine");
|
||||||
|
assertThat(ctx.getState().isSet("ghost_banished")).isTrue();
|
||||||
|
|
||||||
|
QuestEngine.tick(ctx);
|
||||||
|
assertThat(ctx.getQuestLog().isCompleted("lay_to_rest")).isTrue();
|
||||||
|
|
||||||
|
// Leave via the front door.
|
||||||
|
p.setCurrentRoom(w.getRooms().get("foyer"));
|
||||||
|
((SwitchableItem) w.getItems().get("front_door")).use(ctx);
|
||||||
|
assertThat(ctx.getState().isSet("left_manor")).isTrue();
|
||||||
|
|
||||||
|
// Highest-priority ending wins: spirit_at_rest (a victory).
|
||||||
|
Ending end = EndingEngine.triggered(ctx);
|
||||||
|
assertThat(end).isNotNull();
|
||||||
|
assertThat(end.id()).isEqualTo("spirit_at_rest");
|
||||||
|
assertThat(end.victory()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void endingPriority_powerOnlyGivesManorReclaimed() {
|
||||||
|
GameContext ctx = freshGame();
|
||||||
|
ctx.getState().set("power_on");
|
||||||
|
ctx.getState().set("left_manor");
|
||||||
|
|
||||||
|
Ending end = EndingEngine.triggered(ctx);
|
||||||
|
assertThat(end).isNotNull();
|
||||||
|
assertThat(end.id()).isEqualTo("manor_reclaimed");
|
||||||
|
assertThat(end.victory()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void endingPriority_leftOnlyGivesFled() {
|
||||||
|
GameContext ctx = freshGame();
|
||||||
|
ctx.getState().set("left_manor");
|
||||||
|
|
||||||
|
Ending end = EndingEngine.triggered(ctx);
|
||||||
|
assertThat(end).isNotNull();
|
||||||
|
assertThat(end.id()).isEqualTo("fled");
|
||||||
|
assertThat(end.victory()).isFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
- a: matches
|
|
||||||
b: torch
|
|
||||||
consume: [matches, torch]
|
|
||||||
produce: lit_torch
|
|
||||||
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.
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
title: Test Manor
|
|
||||||
version: "test"
|
|
||||||
startRoom: kitchen
|
|
||||||
startGold: 5
|
|
||||||
welcomeMessage: |
|
|
||||||
Welcome to the test.
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
- type: readable
|
|
||||||
id: letter
|
|
||||||
name: Letter
|
|
||||||
description: A note.
|
|
||||||
readText: |
|
|
||||||
hello
|
|
||||||
|
|
||||||
- type: switchable
|
|
||||||
id: lamp
|
|
||||||
name: Lamp
|
|
||||||
description: A lamp.
|
|
||||||
initialState: false
|
|
||||||
onText: on
|
|
||||||
offText: off
|
|
||||||
|
|
||||||
- type: plain
|
|
||||||
id: key
|
|
||||||
name: Key
|
|
||||||
description: A key.
|
|
||||||
|
|
||||||
- type: plain
|
|
||||||
id: matches
|
|
||||||
name: Box of Matches
|
|
||||||
description: A small box of dry matches.
|
|
||||||
|
|
||||||
- type: plain
|
|
||||||
id: torch
|
|
||||||
name: Unlit Torch
|
|
||||||
description: A pitch-soaked torch.
|
|
||||||
|
|
||||||
- type: plain
|
|
||||||
id: lit_torch
|
|
||||||
name: Lit Torch
|
|
||||||
description: A burning torch.
|
|
||||||
light: true
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
- id: old_man
|
|
||||||
name: Old Man
|
|
||||||
description: stooped
|
|
||||||
greeting: |
|
|
||||||
greetings
|
|
||||||
reactions:
|
|
||||||
- onReceive: lamp
|
|
||||||
response: thanks
|
|
||||||
gives: key
|
|
||||||
consumes: lamp
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
- id: kitchen
|
|
||||||
name: Kitchen
|
|
||||||
description: kitchen desc
|
|
||||||
exits:
|
|
||||||
north: hallway
|
|
||||||
items: [letter, lamp]
|
|
||||||
npcs: [old_man]
|
|
||||||
|
|
||||||
- id: hallway
|
|
||||||
name: Hallway
|
|
||||||
description: hallway desc
|
|
||||||
exits:
|
|
||||||
south: kitchen
|
|
||||||
items: []
|
|
||||||
npcs: []
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
intro: |
|
|
||||||
Test intro.
|
|
||||||
steps:
|
|
||||||
- instruction: "Look: type 'look'."
|
|
||||||
expect: look
|
|
||||||
confirm: "Looked."
|
|
||||||
hint: "Type look."
|
|
||||||
- instruction: "Take: type 'take <item>'."
|
|
||||||
expect: take
|
|
||||||
minArgs: 1
|
|
||||||
confirm: "Took it."
|
|
||||||
hint: "Take something."
|
|
||||||
closingTips: |
|
|
||||||
Test tips.
|
|
||||||
Reference in New Issue
Block a user